From 88c4576c9b0745ea45b67fda4d7747e63eb2dfd4 Mon Sep 17 00:00:00 2001 From: Daniel Dunderfelt Date: Tue, 8 Sep 2026 17:39:07 +0300 Subject: [PATCH 1/4] feat(composer): allow users to set cmd/ctrl+enter as the prompt send key. --- apps/web/src/components/chat/ChatComposer.tsx | 4 +- .../components/settings/SettingsPanels.tsx | 49 +++++++++++++ .../src/components/settings/settingsSearch.ts | 8 +++ apps/web/src/composer-logic.test.ts | 69 ++++++++++++++++--- apps/web/src/composer-logic.ts | 15 +++- docs/user/composer.md | 8 +++ docs/user/thread-sidebar.md | 3 +- packages/contracts/src/settings.ts | 9 +++ 8 files changed, 151 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 0a54a2a55abb..3b9dfb5f867c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2913,12 +2913,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const submitCitationAndSend = useCallback(() => { const intent = composerSubmissionIntentForEnter({ isMobileViewport, + sendKey: settings.composerSendKey, shiftKey: false, modifierKey: true, isDraftThread: routeKind === "draft", }); submitComposer(undefined, intent ?? "foreground"); - }, [isMobileViewport, routeKind, submitComposer]); + }, [isMobileViewport, routeKind, settings.composerSendKey, submitComposer]); const compactThreadContext = useCallback(() => { if ( compactDisabled || @@ -3108,6 +3109,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) key === "Enter" ? composerSubmissionIntentForEnter({ isMobileViewport, + sendKey: settings.composerSendKey, shiftKey: event.shiftKey, modifierKey: event.metaKey || event.ctrlKey, isDraftThread: routeKind === "draft", diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index c3d5d7ba812f..be052b407bac 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -168,6 +168,13 @@ const ENVIRONMENT_IDENTIFICATION_LABELS: Record void) { settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll ? ["Collapse composer"] : []), + ...(settings.composerSendKey !== DEFAULT_UNIFIED_SETTINGS.composerSendKey + ? ["Send prompt with"] + : []), ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled ? ["Context window indicator"] : []), @@ -611,6 +621,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadUnpin, settings.composerCollapseOnBlur, settings.composerCollapseOnScroll, + settings.composerSendKey, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, @@ -717,6 +728,7 @@ export function useSettingsRestore(onRestored?: () => void) { showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, composerCollapseOnBlur: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur, composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, + composerSendKey: DEFAULT_UNIFIED_SETTINGS.composerSendKey, contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, @@ -2409,6 +2421,43 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ composerSendKey: DEFAULT_UNIFIED_SETTINGS.composerSendKey }) + } + /> + ) : null + } + control={ + + } + /> + { }); describe("composerSubmissionIntentForEnter", () => { + const desktop = { isMobileViewport: false, isDraftThread: true } as const; + it("submits plain Enter on desktop", () => { expect( composerSubmissionIntentForEnter({ - isMobileViewport: false, + ...desktop, + sendKey: "enter", shiftKey: false, modifierKey: false, - isDraftThread: true, }), ).toBe("foreground"); }); @@ -67,10 +69,11 @@ describe("composerSubmissionIntentForEnter", () => { it("inserts a newline for plain Enter on mobile", () => { expect( composerSubmissionIntentForEnter({ + ...desktop, isMobileViewport: true, + sendKey: "enter", shiftKey: false, modifierKey: false, - isDraftThread: true, }), ).toBeNull(); }); @@ -78,10 +81,10 @@ describe("composerSubmissionIntentForEnter", () => { it("inserts a newline for Shift+Enter", () => { expect( composerSubmissionIntentForEnter({ - isMobileViewport: false, + ...desktop, + sendKey: "enter", shiftKey: true, modifierKey: false, - isDraftThread: true, }), ).toBeNull(); }); @@ -89,10 +92,10 @@ describe("composerSubmissionIntentForEnter", () => { it("submits a new thread in the background with Mod+Enter", () => { expect( composerSubmissionIntentForEnter({ - isMobileViewport: false, + ...desktop, + sendKey: "enter", shiftKey: false, modifierKey: true, - isDraftThread: true, }), ).toBe("background"); }); @@ -100,13 +103,61 @@ describe("composerSubmissionIntentForEnter", () => { it("keeps Mod+Enter in the foreground for an active thread", () => { expect( composerSubmissionIntentForEnter({ - isMobileViewport: false, + ...desktop, + isDraftThread: false, + sendKey: "enter", shiftKey: false, modifierKey: true, - isDraftThread: false, }), ).toBe("foreground"); }); + + describe("with Mod+Enter as the send key", () => { + it.each([ + ["plain Enter", false], + ["Shift+Enter", true], + ])("inserts a newline for %s", (_label, shiftKey) => { + expect( + composerSubmissionIntentForEnter({ + ...desktop, + sendKey: "mod-enter", + shiftKey, + modifierKey: false, + }), + ).toBeNull(); + }); + + it("submits a draft in the foreground with Mod+Enter", () => { + expect( + composerSubmissionIntentForEnter({ + ...desktop, + sendKey: "mod-enter", + shiftKey: false, + modifierKey: true, + }), + ).toBe("foreground"); + }); + + it("moves the background start to Shift+Mod+Enter", () => { + expect( + composerSubmissionIntentForEnter({ + ...desktop, + sendKey: "mod-enter", + shiftKey: true, + modifierKey: true, + }), + ).toBe("background"); + expect( + composerSubmissionIntentForEnter({ + ...desktop, + isDraftThread: false, + sendKey: "mod-enter", + shiftKey: true, + modifierKey: true, + }), + ).toBe("foreground"); + }); + }); }); describe("detectComposerTrigger", () => { diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 4a5b7022adc7..d197048fe404 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -1,4 +1,4 @@ -import type { AssistantCitation } from "@t3tools/contracts"; +import type { AssistantCitation, ComposerSendKey } from "@t3tools/contracts"; import { serializeAssistantCitation, withAssistantCitationComment, @@ -26,14 +26,23 @@ export function formatAssistantCitationForComposer(citation: AssistantCitation, export function composerSubmissionIntentForEnter(input: { isMobileViewport: boolean; + sendKey: ComposerSendKey; shiftKey: boolean; modifierKey: boolean; isDraftThread: boolean; }): ComposerSubmissionIntent | null { - if (input.isMobileViewport || input.shiftKey) { + if (input.isMobileViewport) { return null; } - return input.modifierKey && input.isDraftThread ? "background" : "foreground"; + const { sendKey, shiftKey, modifierKey } = input; + const sends = sendKey === "mod-enter" ? modifierKey : !shiftKey; + if (!sends) { + return null; + } + // Mod+Enter starts a draft in the background; when it is the send key itself, + // Shift+Mod+Enter takes over that role. + const background = sendKey === "mod-enter" ? modifierKey && shiftKey : modifierKey; + return background && input.isDraftThread ? "background" : "foreground"; } const isInlineTokenSegment = (segment: ComposerPromptSegment): boolean => segment.type !== "text"; diff --git a/docs/user/composer.md b/docs/user/composer.md index 4a8df5333664..8a2e965bc8ee 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -56,6 +56,14 @@ is unavailable or has changed, the saved quote remains readable. Mobile displays saved quotes and comments, but does not create citations or navigate to their sources. +## Require Cmd+Enter to send + +On web and desktop, Enter sends the prompt and Shift+Enter starts a new line. If +you keep sending drafts early, switch Settings → General → Behavior → +**Send prompt with** to `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and +Linux. Enter then starts a new line, and `Shift+Cmd+Enter` starts a new thread +in the background. On phones, Enter always starts a new line. + ## Recall a sent prompt Press `ArrowUp` in an empty composer to bring back the last prompt you sent in this thread. Press diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index e6c89c0343e7..762acb404ed1 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -18,7 +18,8 @@ if that project exists there. Otherwise it selects an environment that has it. In a desktop browser or the desktop app, press `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and Linux to start a new thread and immediately open another draft. The next draft keeps the workspace mode and base branch you selected. With **New -worktree**, each background submission creates its own worktree. +worktree**, each background submission creates its own worktree. If `Cmd+Enter` +is your send key, hold Shift as well. ## Pin and reorder threads diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1ddf66cde63c..30e53e402758 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -42,6 +42,11 @@ export const DiffLayout = Schema.Literals(["stacked", "split"]); export type DiffLayout = typeof DiffLayout.Type; const DEFAULT_DIFF_LAYOUT: DiffLayout = "stacked"; +// Which Enter chord sends the composer prompt. "mod" is Cmd on macOS and Ctrl elsewhere. +export const ComposerSendKey = Schema.Literals(["enter", "mod-enter"]); +export type ComposerSendKey = typeof ComposerSendKey.Type; +const DEFAULT_COMPOSER_SEND_KEY: ComposerSendKey = "enter"; + export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; @@ -341,6 +346,9 @@ export const ClientSettingsSchema = Schema.Struct({ // composer into its single-line layout can be turned off on its own. composerCollapseOnBlur: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + composerSendKey: ComposerSendKey.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_COMPOSER_SEND_KEY)), + ), proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Legacy sidebar (the original per-project tree). Deliberately a fresh key @@ -1272,6 +1280,7 @@ export const ClientSettingsPatch = Schema.Struct({ contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), composerCollapseOnBlur: Schema.optionalKey(Schema.Boolean), composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean), + composerSendKey: Schema.optionalKey(ComposerSendKey), proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), From d0de6dd402e5ab34bc488bff67e28f13a965ddb9 Mon Sep 17 00:00:00 2001 From: Daniel Dunderfelt Date: Tue, 8 Sep 2026 18:13:54 +0300 Subject: [PATCH 2/4] update setting label. --- apps/web/src/components/settings/SettingsPanels.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 0fd82c3fc434..0666d1e17653 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2384,7 +2384,7 @@ export function GeneralSettingsPanel() { Date: Tue, 8 Sep 2026 18:19:00 +0300 Subject: [PATCH 3/4] update composer documentation for windows/linux. --- docs/user/composer.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/composer.md b/docs/user/composer.md index 8a2e965bc8ee..960573925cec 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -61,8 +61,8 @@ navigate to their sources. On web and desktop, Enter sends the prompt and Shift+Enter starts a new line. If you keep sending drafts early, switch Settings → General → Behavior → **Send prompt with** to `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and -Linux. Enter then starts a new line, and `Shift+Cmd+Enter` starts a new thread -in the background. On phones, Enter always starts a new line. +Linux. Enter then starts a new line, and `Shift+Cmd+Enter` on macOS or +`Shift+Ctrl+Enter` on Windows and Linux starts a new thread in the background. ## Recall a sent prompt From a79b7561d5f0874e8adaa0ea6ff74149d57b1ec3 Mon Sep 17 00:00:00 2001 From: Daniel Dunderfelt Date: Wed, 9 Sep 2026 08:36:02 +0300 Subject: [PATCH 4/4] fix completion menu and documentation. --- apps/web/src/components/chat/ChatComposer.tsx | 37 ++++++----- apps/web/src/composer-logic.test.ts | 65 +++++++++++++++++++ apps/web/src/composer-logic.ts | 26 ++++++++ docs/user/composer.md | 2 +- 4 files changed, 113 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ea0173c9eb69..331a764b1fbf 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -50,6 +50,7 @@ import { type ComposerSubmissionIntent, type ComposerTrigger, collapseExpandedComposerCursor, + composerEnterCommandAction, composerSubmissionIntentForEnter, detectComposerTrigger, expandCollapsedComposerCursor, @@ -3221,9 +3222,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } const { trigger } = resolveActiveComposerTrigger(); const menuIsActive = composerMenuOpenRef.current || trigger !== null; + const currentItems = composerMenuItemsRef.current; + const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; if (menuIsActive) { - const currentItems = composerMenuItemsRef.current; - const selectedItem = activeComposerMenuItemRef.current ?? currentItems[0]; if (key === "ArrowDown" && currentItems.length > 0) { nudgeComposerMenuHighlight("ArrowDown"); return true; @@ -3232,7 +3233,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) nudgeComposerMenuHighlight("ArrowUp"); return true; } - if ((key === "Enter" || key === "Tab") && selectedItem) { + if (key === "Tab" && selectedItem) { onSelectComposerItem(selectedItem); return true; } @@ -3240,19 +3241,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (key === "ArrowUp" || key === "ArrowDown") { return navigatePromptHistory(key === "ArrowUp" ? "backward" : "forward", event); } - const submissionIntent = - key === "Enter" - ? composerSubmissionIntentForEnter({ - isMobileViewport, - sendKey: settings.composerSendKey, - shiftKey: event.shiftKey, - modifierKey: event.metaKey || event.ctrlKey, - isDraftThread: routeKind === "draft", - }) - : null; - if (submissionIntent) { - submitComposer(undefined, submissionIntent); - return true; + if (key === "Enter") { + const action = composerEnterCommandAction({ + menuCanSelect: menuIsActive && selectedItem != null, + isMobileViewport, + sendKey: settings.composerSendKey, + shiftKey: event.shiftKey, + modifierKey: event.metaKey || event.ctrlKey, + isDraftThread: routeKind === "draft", + }); + if (action?.kind === "submit") { + submitComposer(undefined, action.intent); + return true; + } + if (action?.kind === "select-menu" && selectedItem) { + onSelectComposerItem(selectedItem); + return true; + } } return false; }; diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index a8345502b678..232aae2a6a18 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from "vite-plus/test"; import { clampCollapsedComposerCursor, collapseExpandedComposerCursor, + composerEnterCommandAction, composerSubmissionIntentForEnter, detectComposerTrigger, expandCollapsedComposerCursor, @@ -160,6 +161,70 @@ describe("composerSubmissionIntentForEnter", () => { }); }); +describe("composerEnterCommandAction", () => { + const desktop = { isMobileViewport: false, isDraftThread: true } as const; + + it("lets the completion menu consume unmodified Enter", () => { + expect( + composerEnterCommandAction({ + ...desktop, + menuCanSelect: true, + sendKey: "enter", + shiftKey: false, + modifierKey: false, + }), + ).toEqual({ kind: "select-menu" }); + }); + + it("sends with Mod+Enter even when the completion menu is open", () => { + expect( + composerEnterCommandAction({ + ...desktop, + menuCanSelect: true, + sendKey: "mod-enter", + shiftKey: false, + modifierKey: true, + }), + ).toEqual({ kind: "submit", intent: "foreground" }); + }); + + it("starts a background draft with Shift+Mod+Enter while the menu is open", () => { + expect( + composerEnterCommandAction({ + ...desktop, + menuCanSelect: true, + sendKey: "mod-enter", + shiftKey: true, + modifierKey: true, + }), + ).toEqual({ kind: "submit", intent: "background" }); + }); + + it("still lets the menu consume Enter when send is Mod+Enter", () => { + expect( + composerEnterCommandAction({ + ...desktop, + menuCanSelect: true, + sendKey: "mod-enter", + shiftKey: false, + modifierKey: false, + }), + ).toEqual({ kind: "select-menu" }); + }); + + it("submits Mod+Enter as a background draft when send is Enter", () => { + expect( + composerEnterCommandAction({ + ...desktop, + menuCanSelect: true, + sendKey: "enter", + shiftKey: false, + modifierKey: true, + }), + ).toEqual({ kind: "submit", intent: "background" }); + }); +}); + describe("detectComposerTrigger", () => { it("detects @path trigger at cursor", () => { const text = "Please check @src/com"; diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index d197048fe404..5e701366af22 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -45,6 +45,32 @@ export function composerSubmissionIntentForEnter(input: { return background && input.isDraftThread ? "background" : "foreground"; } +export type ComposerEnterCommandAction = + | { kind: "submit"; intent: ComposerSubmissionIntent } + | { kind: "select-menu" }; + +/** Unmodified Enter confirms a completion. Modified Enter is a send chord. */ +export function composerEnterCommandAction(input: { + menuCanSelect: boolean; + isMobileViewport: boolean; + sendKey: ComposerSendKey; + shiftKey: boolean; + modifierKey: boolean; + isDraftThread: boolean; +}): ComposerEnterCommandAction | null { + const intent = composerSubmissionIntentForEnter(input); + if (intent && input.modifierKey) { + return { kind: "submit", intent }; + } + if (input.menuCanSelect) { + return { kind: "select-menu" }; + } + if (intent) { + return { kind: "submit", intent }; + } + return null; +} + const isInlineTokenSegment = (segment: ComposerPromptSegment): boolean => segment.type !== "text"; function clampCursor(text: string, cursor: number): number { diff --git a/docs/user/composer.md b/docs/user/composer.md index 960573925cec..241b0f50904c 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -56,7 +56,7 @@ is unavailable or has changed, the saved quote remains readable. Mobile displays saved quotes and comments, but does not create citations or navigate to their sources. -## Require Cmd+Enter to send +## Require Cmd/Ctrl+Enter to send On web and desktop, Enter sends the prompt and Shift+Enter starts a new line. If you keep sending drafts early, switch Settings → General → Behavior →