diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 4a76b3fcf93ae..6c2212ff93aaa 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -143,6 +143,92 @@ describe("composerSubmissionIntentForKey", () => { ).toBe("alternate"); }); + it.each(["MacIntel", "Win32", "Linux"])( + "honors the send shortcut when starting a draft on %s", + (platform) => { + const modEnter = { + ...enter, + metaKey: platform === "MacIntel", + ctrlKey: platform !== "MacIntel", + }; + for (const [sendShortcut, prompt, requiresModifier] of [ + ["enter", "one line", false], + ["enter", "two\nlines", false], + ["mod-enter", "one line", true], + ["mod-enter", "two\nlines", true], + ["mod-enter-multiline", "one line", false], + ["mod-enter-multiline", "two\nlines", true], + ] as const) { + const draft = { ...input, platform, sendShortcut, prompt, isDraftThread: true }; + expect(composerSubmissionIntentForKey({ ...draft, event: modEnter })).toBe( + requiresModifier ? "foreground" : "background", + ); + expect( + composerSubmissionIntentForKey({ + ...draft, + event: { ...modEnter, shiftKey: true }, + }), + ).toBe(requiresModifier ? "background" : null); + expect(composerSubmissionIntentForKey({ ...draft, event: enter })).toBe( + requiresModifier ? null : "foreground", + ); + expect( + composerSubmissionIntentForKey({ ...draft, event: { ...enter, shiftKey: true } }), + ).toBeNull(); + expect( + composerSubmissionIntentForKey({ ...draft, event: { ...modEnter, altKey: true } }), + ).toBe("background"); + } + }, + ); + + it("honors the send preference with an existing install's saved background binding", () => { + const keybindings = mergeWithDefaultKeybindings( + compileResolvedKeybindingsConfig([ + { + key: "mod+enter", + command: "composer.sendBackground", + when: "composerFocus && draftThreadRoute", + }, + ]), + ); + const draft = { + ...input, + keybindings, + isDraftThread: true, + sendShortcut: "mod-enter" as const, + }; + expect(composerSubmissionIntentForKey({ ...draft, event: { ...enter, ctrlKey: true } })).toBe( + "foreground", + ); + expect( + composerSubmissionIntentForKey({ + ...draft, + event: { ...enter, ctrlKey: true, shiftKey: true }, + }), + ).toBe("background"); + }); + + it.each([ + ["mod+arrowup", { key: "ArrowUp", ctrlKey: true }], + ["shift+tab", { key: "Tab", shiftKey: true }], + ] as const)("accepts a remapped %s action", (key, event) => { + const keybindings = mergeWithDefaultKeybindings( + compileResolvedKeybindingsConfig([ + { key, command: "composer.sendBackground", when: "composerFocus && draftThreadRoute" }, + ]), + ); + expect( + composerSubmissionIntentForKey({ + ...input, + keybindings, + sendShortcut: "mod-enter", + isDraftThread: true, + event: { ...enter, ...event }, + }), + ).toBe("background"); + }); + it.each(["enter", "mod-enter", "mod-enter-multiline"] as const)( "uses remapped keys and removes the old action bindings with %s", (sendShortcut) => { diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 1f0a096fccaec..5f00229290f27 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -835,6 +835,65 @@ describe("composer and pull request shortcuts", () => { }, ); + it.each(["MacIntel", "Win32", "Linux"])( + "reserves the normal send chord on new drafts on %s", + (platform) => { + const modifier = { + metaKey: platform === "MacIntel", + ctrlKey: platform !== "MacIntel", + }; + for (const composerRequiresModifier of [false, true]) { + const context = { + composerFocus: true, + draftThreadRoute: true, + composerRequiresModifier, + }; + assert.strictEqual( + resolveShortcutCommand( + event({ key: "Enter", ...modifier }), + DEFAULT_RESOLVED_KEYBINDINGS, + { + platform, + context, + }, + ), + composerRequiresModifier ? null : "composer.sendBackground", + ); + assert.strictEqual( + resolveShortcutCommand( + event({ key: "Enter", shiftKey: true, ...modifier }), + DEFAULT_RESOLVED_KEYBINDINGS, + { platform, context }, + ), + composerRequiresModifier ? "composer.sendBackground" : "thread.steerQueuedMessage", + ); + const backgroundShortcuts = effectiveShortcutsForCommand( + DEFAULT_RESOLVED_KEYBINDINGS, + "composer.sendBackground", + { platform, context }, + ); + assert.deepEqual( + backgroundShortcuts.map((shortcut) => formatShortcutLabel(shortcut, platform)), + [ + formatShortcutLabel(modShortcut("enter", { altKey: true }), platform), + formatShortcutLabel( + modShortcut("enter", { shiftKey: composerRequiresModifier }), + platform, + ), + ], + ); + assert.strictEqual( + resolveShortcutCommand( + event({ key: "Enter", shiftKey: true, ...modifier }), + DEFAULT_RESOLVED_KEYBINDINGS, + { platform, context: { ...context, composerFocus: false } }, + ), + "thread.steerQueuedMessage", + ); + } + }, + ); + const altEffortBindings = compileResolvedKeybindingsConfig([ { key: "mod+alt+e", command: "composer.effort", when: "!terminalFocus" }, ]); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 18c1b4c53c5a9..5fa772c9e5b42 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -110,7 +110,8 @@ function shortcutForContext(binding: ResolvedKeybindingRule, context: ShortcutMa // Resolve persisted Mod+Enter against send preference without rewriting saved shortcuts. if ( context.composerRequiresModifier && - binding.command === "composer.sendAlternate" && + (binding.command === "composer.sendAlternate" || + binding.command === "composer.sendBackground") && shortcut.key === "enter" && shortcut.modKey && !shortcut.metaKey && diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index ff0df9bf17646..b36141c8fccd5 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -10,16 +10,24 @@ In **Settings → Chat → Send shortcut**, choose whether Enter sends, requires inserts a new line. This applies to the web and desktop composer at desktop widths. **Follow-up behavior** chooses Queue or Steer while the agent runs. Use -`mod+Enter` to do the opposite for one message, even when the send shortcut -requires a modifier. `mod+Alt+Enter` sends, keeps that thread running in the -background, and opens a fresh new-thread composer. In a new thread, `mod+Enter` -does the same. Change these shortcuts in **Settings → Keybindings** under +`mod+Enter` to do the opposite for one message when Enter sends. If your send +shortcut requires `mod+Enter`, add Shift for the opposite action. + +On a new thread, your configured send shortcut starts it in the foreground. +When Enter sends, `mod+Enter` starts it in the background and opens a fresh +new-thread composer. When sending requires `mod+Enter`, use `mod+Shift+Enter` +for that background action instead. In multiline mode, this depends on whether +the draft contains a newline. `mod+Alt+Enter` sends in the background and opens +a fresh composer on both new and existing threads. + +Change these shortcuts in **Settings → Keybindings** under **Composer: Opposite Queue or Steer Action**, **Composer: Start in Background**, -or **Composer: Send and Start New Thread**. These bindings take priority over the -send shortcut. Click the send button to use the configured follow-up behavior. +or **Composer: Send and Start New Thread**. The default `mod+Enter` bindings +adapt to your send preference; other remapped shortcuts take priority over it. When a thread has queued messages, `mod+Shift+Enter` sends the first one as a steer -while a turn is running, or as a new turn when the thread is stopped. Change it under +while a turn is running, or as a new turn when the thread is stopped, unless the +focused composer uses that chord for its opposite follow-up action. Change it under **Queue: Send First Queued Message** in Keybindings. Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host.