From 6fb68f244e319909c4d987b48239b27b4da7c043 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 29 Oct 2026 01:52:57 +0000 Subject: [PATCH 1/7] feat(web): switch the composer between rich text and Markdown source beside attach --- apps/web/src/components/chat/ChatComposer.tsx | 10 +++++- .../chat/ComposerRichTextToggle.tsx | 32 +++++++++++++++++++ docs/user/composer.md | 4 ++- 3 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerRichTextToggle.tsx diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d7878cc29a165..8244828598f5c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -198,6 +198,7 @@ import { ComposerRestingImagePreviews, } from "./ComposerAttachmentStrip"; import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; +import { ComposerRichTextToggle } from "./ComposerRichTextToggle"; import { ComposerFooterPrimaryActions } from "./ComposerFooterControls"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; @@ -3429,7 +3430,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) showMobilePendingAnswerActions && "hidden sm:flex", )} > - {composerAttachButton ?
{composerAttachButton}
: null} + {composerAttachButton || !isLiteralPendingAnswer ? ( +
+ {composerAttachButton} + {isLiteralPendingAnswer ? null : ( + + )} +
+ ) : null}
+ event.preventDefault()} + onClick={() => void updateClientSettings({ composerRichTextEnabled: showingSource })} + /> + } + > + + + {showingSource ? "Show formatting" : "Show Markdown source"} + + ); +} diff --git a/docs/user/composer.md b/docs/user/composer.md index 714572dfd5005..c75fab8b3fe84 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -19,7 +19,9 @@ The composer writes Markdown and shows it styled as you type. Markers such as them. Lines starting with `- `, `1. `, `- [ ] `, `> `, `# ` or `---` become lists, task lists, quotes, headings and rules; ` ``` ` followed by Enter opens a code block. What you typed is what the agent receives, markers and numbering -included, and `#1234` without a space still looks up a pull request. +included, and `#1234` without a space still looks up a pull request. To see the +plain Markdown instead, press the code button beside attach; it is the same +switch as **Settings → Chat → Rich text composer**. Enter sends. In a list or quote, **Shift+Enter** continues it, and Shift+Enter on an empty line moves out one level, then leaves it; **Tab** nests a list item From d5b7f4c823ba35199d75fd76664a08f2dcbfbf04 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 29 Oct 2026 02:22:22 +0000 Subject: [PATCH 2/7] fix(web): use the pilcrow icon for the composer Markdown source button --- apps/web/src/components/chat/ComposerRichTextToggle.tsx | 4 ++-- docs/user/composer.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/ComposerRichTextToggle.tsx b/apps/web/src/components/chat/ComposerRichTextToggle.tsx index b4c46a6aae030..4477a44085e25 100644 --- a/apps/web/src/components/chat/ComposerRichTextToggle.tsx +++ b/apps/web/src/components/chat/ComposerRichTextToggle.tsx @@ -1,4 +1,4 @@ -import { CodeIcon } from "lucide-react"; +import { PilcrowIcon } from "lucide-react"; import { updateClientSettings } from "../../hooks/useSettings"; import { Button } from "../ui/button"; @@ -24,7 +24,7 @@ export function ComposerRichTextToggle({ richTextEnabled }: { readonly richTextE /> } > - + {showingSource ? "Show formatting" : "Show Markdown source"} diff --git a/docs/user/composer.md b/docs/user/composer.md index c75fab8b3fe84..6156a126ca246 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -20,7 +20,7 @@ them. Lines starting with `- `, `1. `, `- [ ] `, `> `, `# ` or `---` become list task lists, quotes, headings and rules; ` ``` ` followed by Enter opens a code block. What you typed is what the agent receives, markers and numbering included, and `#1234` without a space still looks up a pull request. To see the -plain Markdown instead, press the code button beside attach; it is the same +plain Markdown instead, press the **¶** button beside attach; it is the same switch as **Settings → Chat → Rich text composer**. Enter sends. In a list or quote, **Shift+Enter** continues it, and Shift+Enter From 74e9c2d22f267366fc6c7736a250faff8adc4bf2 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 00:31:24 +0000 Subject: [PATCH 3/7] fix(web): use a feather for the composer rich text toggle --- apps/web/src/components/chat/ComposerRichTextToggle.tsx | 4 ++-- docs/user/composer.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/ComposerRichTextToggle.tsx b/apps/web/src/components/chat/ComposerRichTextToggle.tsx index 4477a44085e25..a67f757a2b68a 100644 --- a/apps/web/src/components/chat/ComposerRichTextToggle.tsx +++ b/apps/web/src/components/chat/ComposerRichTextToggle.tsx @@ -1,4 +1,4 @@ -import { PilcrowIcon } from "lucide-react"; +import { FeatherIcon } from "lucide-react"; import { updateClientSettings } from "../../hooks/useSettings"; import { Button } from "../ui/button"; @@ -24,7 +24,7 @@ export function ComposerRichTextToggle({ richTextEnabled }: { readonly richTextE /> } > - + {showingSource ? "Show formatting" : "Show Markdown source"} diff --git a/docs/user/composer.md b/docs/user/composer.md index 6156a126ca246..afac8ce64cf3b 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -20,7 +20,7 @@ them. Lines starting with `- `, `1. `, `- [ ] `, `> `, `# ` or `---` become list task lists, quotes, headings and rules; ` ``` ` followed by Enter opens a code block. What you typed is what the agent receives, markers and numbering included, and `#1234` without a space still looks up a pull request. To see the -plain Markdown instead, press the **¶** button beside attach; it is the same +plain Markdown instead, press the button beside attach; it is the same switch as **Settings → Chat → Rich text composer**. Enter sends. In a list or quote, **Shift+Enter** continues it, and Shift+Enter From 4514c4a8054a002e87b664667d5f7f6504df30a3 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 00:32:05 +0000 Subject: [PATCH 4/7] feat(web): toggle composer rich text with a configurable shortcut --- apps/web/src/components/chat/ChatComposer.tsx | 23 ++++++++++++++++++- .../chat/ComposerRichTextToggle.tsx | 13 +++++++++-- .../src/components/chat/composerShortcuts.ts | 3 +++ apps/web/src/keybindings.test.ts | 11 ++++++++- docs/user/keybindings.md | 1 + packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 7 files changed, 49 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8244828598f5c..4db24b3e4a5ff 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -133,6 +133,11 @@ import { import { attachmentUploadBlockReason } from "../../lib/attachmentUploadState"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { shortcutLabelForCommand } from "../../keybindings"; +import { + getClientSettings, + isClientSettingsHydrated, + updateClientSettings, +} from "../../hooks/useSettings"; import { type TerminalContextDraft, type TerminalContextSelection, @@ -305,6 +310,7 @@ export interface ChatComposerHandle { openModelPicker: () => void; stepReasoningEffort: (direction: 1 | -1) => void; toggleModelPicker: () => void; + toggleRichText: () => void; openControl: (command: KeybindingCommand) => void; isModelPickerOpen: () => boolean; isCaretAtStart: () => boolean; @@ -2853,6 +2859,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) openModelPickerStable(); } }, + toggleRichText: () => { + if (!isClientSettingsHydrated()) return; + void updateClientSettings({ + composerRichTextEnabled: !getClientSettings().composerRichTextEnabled, + }); + }, openControl: (command) => { if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); @@ -3434,7 +3446,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
{composerAttachButton} {isLiteralPendingAnswer ? null : ( - + )}
) : null} diff --git a/apps/web/src/components/chat/ComposerRichTextToggle.tsx b/apps/web/src/components/chat/ComposerRichTextToggle.tsx index a67f757a2b68a..b705180c20993 100644 --- a/apps/web/src/components/chat/ComposerRichTextToggle.tsx +++ b/apps/web/src/components/chat/ComposerRichTextToggle.tsx @@ -6,7 +6,13 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; // Same setting as Settings → Chat → Rich text composer. Flipping it remounts the // editor; pointer-down is swallowed so a focused editor keeps its caret across that. -export function ComposerRichTextToggle({ richTextEnabled }: { readonly richTextEnabled: boolean }) { +export function ComposerRichTextToggle({ + richTextEnabled, + shortcutLabel, +}: { + readonly richTextEnabled: boolean; + readonly shortcutLabel: string | null; +}) { const showingSource = !richTextEnabled; return ( @@ -26,7 +32,10 @@ export function ComposerRichTextToggle({ richTextEnabled }: { readonly richTextE > - {showingSource ? "Show formatting" : "Show Markdown source"} + + {showingSource ? "Show formatting" : "Show Markdown source"} + {shortcutLabel ? ` · ${shortcutLabel}` : null} + ); } diff --git a/apps/web/src/components/chat/composerShortcuts.ts b/apps/web/src/components/chat/composerShortcuts.ts index 30beb9771968f..1d7d3774332fb 100644 --- a/apps/web/src/components/chat/composerShortcuts.ts +++ b/apps/web/src/components/chat/composerShortcuts.ts @@ -18,6 +18,9 @@ export function composerShortcutCommands( "modelPicker.toggle": ({ event }) => { if (!event.repeat) composerRef.current?.toggleModelPicker(); }, + "composer.toggleRichText": ({ event }) => { + if (!event.repeat) composerRef.current?.toggleRichText(); + }, "composer.increaseEffort": ({ event }) => { if (!event.repeat) composerRef.current?.stepReasoningEffort(1); }, diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index a0ba7fa7ee043..96ae09296dea5 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -768,12 +768,21 @@ describe("composer and pull request shortcuts", () => { }); it.each(["MacIntel", "Win32", "Linux"])( - "reserves the alternate send chord for the focused composer on %s", + "resolves composer toggle and alternate send chords in their contexts on %s", (platform) => { const modifier = { metaKey: platform === "MacIntel", ctrlKey: platform !== "MacIntel", }; + for (const terminalFocus of [false, true]) { + assert.strictEqual( + resolveShortcutCommand(event({ key: "/", ...modifier }), DEFAULT_RESOLVED_KEYBINDINGS, { + platform, + context: { terminalFocus }, + }), + terminalFocus ? null : "composer.toggleRichText", + ); + } const shiftedEnter = event({ key: "Enter", shiftKey: true, ...modifier }); for (const composerRequiresModifier of [false, true]) { const context = { diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 83989762c5b07..0e8e17744019e 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -28,6 +28,7 @@ Use `mod+shift+e` for effort, `mod+shift+a` for permissions, `mod+shift+x` for t workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the current checkout, a new worktree, and the previous worktree when available. Use `mod+shift+l` to reuse the previous worktree directly. +Use `mod+/` to switch the composer between rich text and its Markdown source. To change effort one step without opening the picker, bind **Composer: Increase Effort** and **Composer: Decrease Effort** in Keybindings. diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 714e0a1d486a1..cc55adb9ca81b 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -87,6 +87,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "appearance.cycle", "themeEditor.toggle", "composer.stash", + "composer.toggleRichText", "composer.sendAlternate", "composer.sendBackground", "composer.sendAndNewThread", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 32292c0f9ef21..8ae3f052afede 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -179,6 +179,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+alt+shift+a", command: "appearance.cycle", when: "!terminalFocus" }, { key: "mod+alt+shift+t", command: "themeEditor.toggle" }, { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, + { key: "mod+/", command: "composer.toggleRichText", when: "!terminalFocus" }, { key: "mod+shift+enter", command: "thread.steerQueuedMessage", when: "!terminalFocus" }, { key: "alt+arrowup", command: "thread.editQueuedMessage", when: "composerFocus" }, { key: "mod+enter", command: "composer.sendAlternate", when: "composerFocus && turnRunning" }, From 0dc58e703379088569a1713592fcd380752a905c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 00:49:35 +0000 Subject: [PATCH 5/7] fix(web): leave the rich text setting alone while a question takes a literal answer --- apps/web/src/components/chat/ChatComposer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 4db24b3e4a5ff..1464e02816401 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2860,7 +2860,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } }, toggleRichText: () => { - if (!isClientSettingsHydrated()) return; + if (isLiteralPendingAnswer || !isClientSettingsHydrated()) return; void updateClientSettings({ composerRichTextEnabled: !getClientSettings().composerRichTextEnabled, }); From 1396b753ee08e815a1304dd8f6839dd2b47daaa9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 01:12:08 +0000 Subject: [PATCH 6/7] fix(web): hydrate client settings before the rich text shortcut flips --- apps/web/src/components/chat/ChatComposer.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 1464e02816401..56c73bcec16a6 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -133,11 +133,7 @@ import { import { attachmentUploadBlockReason } from "../../lib/attachmentUploadState"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { shortcutLabelForCommand } from "../../keybindings"; -import { - getClientSettings, - isClientSettingsHydrated, - updateClientSettings, -} from "../../hooks/useSettings"; +import { persistClientSettingsUpdate } from "../../hooks/useSettings"; import { type TerminalContextDraft, type TerminalContextSelection, @@ -2860,10 +2856,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } }, toggleRichText: () => { - if (isLiteralPendingAnswer || !isClientSettingsHydrated()) return; - void updateClientSettings({ - composerRichTextEnabled: !getClientSettings().composerRichTextEnabled, - }); + if (isLiteralPendingAnswer) return; + persistClientSettingsUpdate((current) => ({ + ...current, + composerRichTextEnabled: !current.composerRichTextEnabled, + })).catch(console.error); }, openControl: (command) => { if (composerBlurFrameRef.current !== null) { From a1c25006bc381061ca7ac452af06c811d5777f68 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 01:33:26 +0000 Subject: [PATCH 7/7] fix(web): keep an open citation comment when the rich text shortcut fires --- apps/web/src/components/chat/composerShortcuts.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/components/chat/composerShortcuts.ts b/apps/web/src/components/chat/composerShortcuts.ts index 1d7d3774332fb..e55398996cea9 100644 --- a/apps/web/src/components/chat/composerShortcuts.ts +++ b/apps/web/src/components/chat/composerShortcuts.ts @@ -19,6 +19,9 @@ export function composerShortcutCommands( if (!event.repeat) composerRef.current?.toggleModelPicker(); }, "composer.toggleRichText": ({ event }) => { + // The flip remounts the editor, which would drop an unsaved citation comment. + if (event.target instanceof Element && event.target.closest("[data-citation-comment-editor]")) + return false; if (!event.repeat) composerRef.current?.toggleRichText(); }, "composer.increaseEffort": ({ event }) => {