diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 880bf8a507fe..07e938b0e35c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4310,6 +4310,20 @@ function ChatViewContent(props: ChatViewProps) { } } }, [activeThread, environmentId, interruptThreadTurn, setThreadError]); + const onInterrupt = useCallback(async () => { + if (!activeThread) return; + const result = await interruptThreadTurn({ + environmentId, + input: buildThreadTurnInterruptInput(activeThread), + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThread.id, + error instanceof Error ? error.message : "Failed to interrupt the current turn.", + ); + } + }, [activeThread, environmentId, interruptThreadTurn, setThreadError]); const backgroundLivenessBannerItem = useMemo(() => { if (activeBackgroundLiveness === null || !activeThread) { return null; @@ -4593,7 +4607,18 @@ function ChatViewContent(props: ChatViewProps) { modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, }; + const command = resolveShortcutCommand(event, keybindings, { + context: shortcutContext, + }); + + // chat.interrupt outranks type-to-focus while a turn is running: a + // printable-key binding would otherwise be typed into the composer + // instead of stopping the turn. When idle, the key keeps its usual + // meaning, so bindings like plain Escape stay safe. + const interruptClaimsKey = command === "chat.interrupt" && phase === "running"; + if ( + !interruptClaimsKey && !shortcutContext.terminalFocus && !shortcutContext.modelPickerOpen && shouldTypeToFocusComposer(event) @@ -4605,9 +4630,6 @@ function ChatViewContent(props: ChatViewProps) { } } - const command = resolveShortcutCommand(event, keybindings, { - context: shortcutContext, - }); if (!command) return; if (command === "terminal.toggle") { @@ -4692,6 +4714,14 @@ function ChatViewContent(props: ChatViewProps) { return; } + if (command === "chat.interrupt") { + if (!interruptClaimsKey) return; + event.preventDefault(); + event.stopPropagation(); + void onInterrupt(); + return; + } + const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; const script = activeProject.scripts.find((entry) => entry.id === scriptId); @@ -4717,7 +4747,9 @@ function ChatViewContent(props: ChatViewProps) { splitTerminal, splitPanelTerminal, keybindings, + onInterrupt, onToggleDiff, + phase, toggleRightPanel, toggleTerminalVisibility, composerRef, @@ -5236,21 +5268,6 @@ function ChatViewContent(props: ChatViewProps) { } }; - const onInterrupt = async () => { - if (!activeThread) return; - const result = await interruptThreadTurn({ - environmentId, - input: buildThreadTurnInterruptInput(activeThread), - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setThreadError( - activeThread.id, - error instanceof Error ? error.message : "Failed to interrupt the current turn.", - ); - } - }; - const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { if (!activeThreadId) return; diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 22a91b7c1504..2660ea7d7e99 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -150,7 +150,9 @@ describe("KeybindingsSettings.logic", () => { }, ] satisfies ResolvedKeybindingsConfig); - expect(options).toEqual(expect.arrayContaining(["chat.new", "script.setup-db.run"])); + expect(options).toEqual( + expect.arrayContaining(["chat.new", "chat.interrupt", "script.setup-db.run"]), + ); }); it("reports unknown when variables without rejecting parseable expressions", () => { diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index da54e86e42e1..e94031255fed 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -4,6 +4,7 @@ import { type KeybindingWhenNode, type ResolvedKeybindingRule, type ResolvedKeybindingsConfig, + STATIC_KEYBINDING_COMMANDS, } from "@t3tools/contracts"; import { DEFAULT_RESOLVED_KEYBINDINGS, @@ -255,7 +256,9 @@ export function buildWhenVariableOptions(): ReadonlyArray { export function buildKeybindingCommandOptions( keybindings: ResolvedKeybindingsConfig, ): ReadonlyArray { - const commands = new Set(); + // Static commands are listed directly so commands that ship without a + // default binding (like chat.interrupt) stay bindable from the UI. + const commands = new Set(STATIC_KEYBINDING_COMMANDS); for (const binding of DEFAULT_RESOLVED_KEYBINDINGS) { commands.add(binding.command); } diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index f7f6facbe594..ccd928238323 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -47,6 +47,11 @@ Use **Inspect** to pick an element in the app and reveal its color token. Inspec successful pick; its hover glow and badge preview the element and token that click will select. **Cancel** or `Escape` exits Inspect and clears its selection and spotlight. +`chat.interrupt` stops the active thread's running turn, same as the composer's stop button. It +ships without a default shortcut; bind one in **Settings** → **Keybindings**. The shortcut only +takes effect while a turn is running, so a key you bind keeps its normal behavior the rest of the +time. + The command palette searches active thread titles, projects, branches, user messages, and final agent responses across connected environments. Message matches show one labeled excerpt while keeping the thread's project, branch, and machine context visible. Message search begins after two diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 342e44678938..2447baa8b0e0 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -84,6 +84,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedLocal.command, "chat.newLocal"); + const parsedInterrupt = yield* decode(KeybindingRule, { + key: "mod+shift+c", + command: "chat.interrupt", + }); + assert.strictEqual(parsedInterrupt.command, "chat.interrupt"); + const parsedModelPickerToggle = yield* decode(KeybindingRule, { key: "mod+shift+m", command: "modelPicker.toggle", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 3fcbf6ef5fdb..50e174c053e1 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -47,7 +47,7 @@ export const MODEL_PICKER_KEYBINDING_COMMANDS = [ ] as const; export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number]; -const STATIC_KEYBINDING_COMMANDS = [ +export const STATIC_KEYBINDING_COMMANDS = [ "sidebar.toggle", "terminal.toggle", "terminal.split", @@ -69,6 +69,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "composer.stash", "chat.new", "chat.newLocal", + "chat.interrupt", "editor.openFavorite", ...MODEL_PICKER_KEYBINDING_COMMANDS, ...THREAD_KEYBINDING_COMMANDS,