From 584139568d15478acb0d97254f17164fdb5de679 Mon Sep 17 00:00:00 2001 From: ChristmasSun Date: Fri, 7 Aug 2026 17:06:49 -0700 Subject: [PATCH 1/6] fix(server): match keybinding rules whose key uses an alias spelling A key can be written more than one way: parseKeybindingShortcut normalizes "esc" to "escape" and "space" to " ". The settings UI renders a stored rule back using the alias, so editing a rule bound to Escape sent a replace target of "esc" for a rule persisted as "escape". Rule comparison used raw string equality, so the target never matched, the original rule survived, and the command ended up bound twice. Compare the parsed shortcut when the raw keys differ. No default binding uses an aliased key today, which is why this has gone unnoticed. --- apps/server/src/keybindings.test.ts | 23 +++++++++++++++++++++++ apps/server/src/keybindings.ts | 20 +++++++++++++++----- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index b674688f0410..aec04b467d28 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -436,6 +436,29 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); + it.effect("replaces a rule whose stored key uses an alias spelling", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "escape", command: "script.run-tests.run" }, + ]); + yield* Effect.gen(function* () { + const keybindings = yield* Keybindings.Keybindings; + // The settings UI renders a stored "escape" rule as "esc", so the + // replace target arrives spelled differently than it was persisted. + return yield* keybindings.upsertKeybindingRule({ + key: "mod+m", + command: "script.run-tests.run", + replace: { key: "esc", command: "script.run-tests.run" }, + }); + }); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + const persistedView = persisted.map(({ key, command }) => ({ key, command })); + assert.deepEqual(persistedView, [{ key: "mod+m", command: "script.run-tests.run" }]); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + it.effect("removes only the targeted custom keybinding", () => Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 10d98bf64290..e48280e4071f 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -101,11 +101,21 @@ export const ResolvedKeybindingsFromConfig = Schema.Array(ResolvedKeybindingFrom ); function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): boolean { - return ( - left.command === right.command && - left.key === right.key && - (left.when ?? undefined) === (right.when ?? undefined) - ); + if (left.command !== right.command) return false; + if ((left.when ?? undefined) !== (right.when ?? undefined)) return false; + if (left.key === right.key) return true; + // A key can be spelled more than one way ("esc"/"escape", "space"/" "), and + // the settings UI renders a stored rule back as the alias. Comparing raw + // strings would then fail to match a rule against itself, so replacing that + // rule would leave the original behind instead of updating it. + const leftKey = canonicalKeybindingKey(left); + return leftKey !== null && leftKey === canonicalKeybindingKey(right); +} + +function canonicalKeybindingKey(rule: KeybindingRule): string | null { + const parsed = parseKeybindingShortcut(rule.key); + if (!parsed) return null; + return encodeShortcut(parsed); } function keybindingShortcutContext(rule: KeybindingRule): string | null { From 4eb8dddbda45d87f68a5969ae508c0b42efac0c0 Mon Sep 17 00:00:00 2001 From: ChristmasSun Date: Fri, 7 Aug 2026 17:07:16 -0700 Subject: [PATCH 2/6] feat(web): add a rebindable thread.interrupt keybinding Stopping a running turn required clicking the stop button; there was no keyboard path and nothing to bind. Register thread.interrupt as a keybinding command, defaulting to Escape, so it appears in Settings as "Thread: Interrupt" and can be rebound. The shortcut is only claimed while a turn is running, so Escape keeps its existing behavior everywhere else, and the default is scoped to !terminalFocus so it still reaches the terminal. --- apps/web/src/components/chat/ChatComposer.tsx | 26 +++++++++++++++++++ apps/web/src/composer-logic.test.ts | 22 ++++++++++++++++ apps/web/src/composer-logic.ts | 10 +++++++ packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 5 files changed, 60 insertions(+) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b8bacf4b6be2..317101b57adc 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -39,6 +39,7 @@ import { detectComposerTrigger, expandCollapsedComposerCursor, replaceTextRange, + shouldInterruptRunningThreadFromKeybinding, shouldSubmitComposerOnEnter, } from "../../composer-logic"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; @@ -2275,6 +2276,31 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) terminalOpen, ]); + // Interrupting the running turn is a keybinding rather than a hard-coded key + // so it shows up in Settings and can be rebound. It only claims the shortcut + // while a turn is running; otherwise the key falls through untouched. + useEffect(() => { + const handler = (event: globalThis.KeyboardEvent) => { + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: getTerminalFocusOwner() !== null, + terminalOpen, + modelPickerOpen: isComposerModelPickerOpen, + }, + }); + if ( + !shouldInterruptRunningThreadFromKeybinding({ command, isRunning: phase === "running" }) + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + onInterrupt(); + }; + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); + }, [isComposerModelPickerOpen, keybindings, onInterrupt, phase, terminalOpen]); + // ------------------------------------------------------------------ // Callbacks: images // ------------------------------------------------------------------ diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index b8ef7443611a..df6fb53fb757 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -8,6 +8,7 @@ import { isCollapsedCursorAdjacentToInlineToken, parseStandaloneComposerSlashCommand, replaceTextRange, + shouldInterruptRunningThreadFromKeybinding, shouldSubmitComposerOnEnter, } from "./composer-logic"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; @@ -26,6 +27,27 @@ describe("shouldSubmitComposerOnEnter", () => { }); }); +describe("shouldInterruptRunningThreadFromKeybinding", () => { + it("interrupts a running thread", () => { + expect( + shouldInterruptRunningThreadFromKeybinding({ command: "thread.interrupt", isRunning: true }), + ).toBe(true); + }); + + it("leaves the shortcut alone when the thread is not running", () => { + expect( + shouldInterruptRunningThreadFromKeybinding({ command: "thread.interrupt", isRunning: false }), + ).toBe(false); + }); + + it.each(["composer.stash", "thread.next", null] as const)( + "ignores the unrelated command %s", + (command) => { + expect(shouldInterruptRunningThreadFromKeybinding({ command, isRunning: true })).toBe(false); + }, + ); +}); + 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 2d1d3aed3b1e..eebb94408a08 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -18,6 +18,16 @@ export function shouldSubmitComposerOnEnter(input: { return !input.isMobileViewport && !input.shiftKey; } +// The interrupt shortcut is only ours while a turn is actually running. When +// the thread is idle the key stays free for whatever else claims it, so the +// default Escape binding does not swallow dismiss behavior. +export function shouldInterruptRunningThreadFromKeybinding(input: { + command: string | null; + isRunning: boolean; +}): boolean { + return input.command === "thread.interrupt" && input.isRunning; +} + const isInlineTokenSegment = ( segment: | { type: "text"; text: string } diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 3fcbf6ef5fdb..66fcf8ae0b6f 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -37,6 +37,7 @@ export type ModelPickerJumpKeybindingCommand = export const THREAD_KEYBINDING_COMMANDS = [ "thread.previous", "thread.next", + "thread.interrupt", ...THREAD_JUMP_KEYBINDING_COMMANDS, ] as const; export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number]; diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 158a9ffb1ac9..7d78e0012a02 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -46,6 +46,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" }, + { key: "escape", command: "thread.interrupt", when: "!terminalFocus" }, ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ key: `mod+${index + 1}`, command, From 25860a911beef279806eeee1a230874d1c7516ae Mon Sep 17 00:00:00 2001 From: ChristmasSun Date: Fri, 7 Aug 2026 17:41:09 -0700 Subject: [PATCH 3/6] fix(web): guard the interrupt keybinding against repeat and open overlays Review follow-ups: ignore key auto-repeat so holding the shortcut cannot spray concurrent interrupt requests at the same turn, skip the shortcut while the command palette or stash menu is open so those keep their dismiss behavior, and exclude the model picker via the default binding's when clause. --- apps/web/src/components/chat/ChatComposer.tsx | 10 +++++++++- packages/shared/src/keybindings.ts | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 317101b57adc..2b1fb57478be 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2281,6 +2281,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // while a turn is running; otherwise the key falls through untouched. useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { + // Interrupt is one-shot: key auto-repeat would spray concurrent + // interrupt requests that race each other for the same turn. + if (event.repeat) return; const command = resolveShortcutCommand(event, keybindings, { context: { terminalFocus: getTerminalFocusOwner() !== null, @@ -2293,13 +2296,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) { return; } + // Overlays that dismiss on the same key keep their dismiss behavior; + // the model picker is excluded via the default binding's when clause. + if (isCommandPaletteOpen() || isStashMenuOpen) { + return; + } event.preventDefault(); event.stopPropagation(); onInterrupt(); }; window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); - }, [isComposerModelPickerOpen, keybindings, onInterrupt, phase, terminalOpen]); + }, [isComposerModelPickerOpen, isStashMenuOpen, keybindings, onInterrupt, phase, terminalOpen]); // ------------------------------------------------------------------ // Callbacks: images diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 7d78e0012a02..203213d9a698 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -46,7 +46,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" }, - { key: "escape", command: "thread.interrupt", when: "!terminalFocus" }, + { key: "escape", command: "thread.interrupt", when: "!terminalFocus && !modelPickerOpen" }, ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ key: `mod+${index + 1}`, command, From 0b759f74f6dbcc376417e1c344dce14f417b3598 Mon Sep 17 00:00:00 2001 From: ChristmasSun Date: Fri, 7 Aug 2026 17:46:42 -0700 Subject: [PATCH 4/6] fix(web): let any overlay's Escape dismiss win over the interrupt keybinding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The listener ran in the capture phase and stopped propagation, so its allowlist of two overlays was the only thing standing between the shortcut and every other Escape-dismissible surface — dialogs, menus, selects — which could neither dismiss nor be enumerated exhaustively. Listen in the bubble phase instead and skip when the event was already consumed: by the time the event reaches window, every overlay handler has run, and a dismiss marks it via preventDefault or stops it from arriving at all. The palette/stash checks stay as backstops for dismiss handlers that do not mark the event. --- apps/web/src/components/chat/ChatComposer.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2b1fb57478be..c959ff132c93 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2279,11 +2279,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Interrupting the running turn is a keybinding rather than a hard-coded key // so it shows up in Settings and can be rebound. It only claims the shortcut // while a turn is running; otherwise the key falls through untouched. + // + // Bubble phase, unlike the capture-phase composer.stash listener above: + // stash must beat the browser's save dialog, while interrupt must lose to + // any open overlay that dismisses on the same key. By the time the event + // bubbles to window every overlay handler has run, and a dismiss marks the + // event consumed via preventDefault. useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { // Interrupt is one-shot: key auto-repeat would spray concurrent // interrupt requests that race each other for the same turn. if (event.repeat) return; + // An overlay (dialog, menu, select, command palette) already consumed + // this press to dismiss itself. + if (event.defaultPrevented) return; const command = resolveShortcutCommand(event, keybindings, { context: { terminalFocus: getTerminalFocusOwner() !== null, @@ -2296,17 +2305,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) { return; } - // Overlays that dismiss on the same key keep their dismiss behavior; - // the model picker is excluded via the default binding's when clause. + // Belt and suspenders for overlays whose dismiss handlers do not mark + // the event consumed; the model picker is excluded via the default + // binding's when clause. if (isCommandPaletteOpen() || isStashMenuOpen) { return; } event.preventDefault(); - event.stopPropagation(); onInterrupt(); }; - window.addEventListener("keydown", handler, true); - return () => window.removeEventListener("keydown", handler, true); + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); }, [isComposerModelPickerOpen, isStashMenuOpen, keybindings, onInterrupt, phase, terminalOpen]); // ------------------------------------------------------------------ From b00ab8c79cdb4af583f9fe229765b09e4120abbf Mon Sep 17 00:00:00 2001 From: ChristmasSun Date: Fri, 7 Aug 2026 17:51:05 -0700 Subject: [PATCH 5/6] fix(web): resolve keybindings before type-to-focus claims printable keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatView's capture-phase handler inserted bare printable keys into the composer before resolving the shortcut, so a command bound to a printable key (for example thread.interrupt rebound to a letter) was shadowed whenever focus sat on neutral chat content — the key typed instead of dispatching, and bubble-phase listeners never saw the event. Resolve the command first and let type-to-focus claim only keys that resolve to no binding. An explicit binding on a bare printable key now wins consistently, matching how every modifier-based binding already behaves. Shipped defaults are all modifier chords, so nothing changes without a custom binding. --- apps/web/src/components/ChatView.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cfbe1ac8d966..56bb08a016e2 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4570,7 +4570,17 @@ function ChatViewContent(props: ChatViewProps) { modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, }; + const command = resolveShortcutCommand(event, keybindings, { + context: shortcutContext, + }); + + // Type-to-focus only claims keys that resolve to no binding: a user who + // explicitly bound a bare printable key (for example to + // thread.interrupt) means the command, and this handler runs in the + // capture phase, so consuming the key here would shadow bindings + // dispatched by bubble-phase listeners. if ( + !command && !shortcutContext.terminalFocus && !shortcutContext.modelPickerOpen && shouldTypeToFocusComposer(event) @@ -4582,9 +4592,6 @@ function ChatViewContent(props: ChatViewProps) { } } - const command = resolveShortcutCommand(event, keybindings, { - context: shortcutContext, - }); if (!command) return; if (command === "terminal.toggle") { From 0c3b6923f4720bdc448e4d350ebf380e59b10232 Mon Sep 17 00:00:00 2001 From: ChristmasSun Date: Fri, 7 Aug 2026 17:58:19 -0700 Subject: [PATCH 6/6] fix(web): detect open overlays from the DOM instead of listener ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Escape closing the expanded image viewer during a running turn also interrupted the turn: both listeners sit on window bubble, the dialog mounts later so it registers later, and the interrupt handler had already seen the event before the dialog marked it consumed. Ordering among same-phase listeners cannot answer "is an overlay open", so ask the DOM: share the floating-layer selector that type-to-focus already uses (moved to ChatView.logic, with role=dialog added for ad-hoc dialogs like the image viewer) and skip the shortcut while any floating layer is present. This replaces the stash-menu allowlist check — the popover matches the selector directly. --- apps/web/src/components/ChatView.logic.ts | 16 +++++++++++++++ apps/web/src/components/ChatView.tsx | 10 ++-------- apps/web/src/components/chat/ChatComposer.tsx | 20 +++++++++++++------ 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 04b35fd45516..f86feaeb97a7 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -22,6 +22,22 @@ import { } from "../lib/terminalContext"; import type { DraftThreadEnvMode } from "../composerDraftStore"; +/** + * Matches any open floating layer: dialogs, menus, selects, popovers, and + * ad-hoc dialogs that only carry `role="dialog"` (like the expanded image + * viewer). Used to suppress global key behavior — type-to-focus and the + * thread.interrupt shortcut — while an overlay owns the keyboard. + */ +export const OPEN_FLOATING_LAYER_SELECTOR = [ + '[data-slot="dialog"]', + '[data-slot="menu-popup"]', + '[data-slot="select-popup"]', + '[data-slot="popover-popup"]', + '[data-slot="combobox-popup"]', + '[data-slot="autocomplete-popup"]', + '[role="dialog"]', +].join(","); + export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 56bb08a016e2..129577217247 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -293,6 +293,7 @@ import { PullRequestDialogState, cloneComposerImageForRetry, deriveLockedProvider, + OPEN_FLOATING_LAYER_SELECTOR, readFileAsDataUrl, reconcileMountedTerminalThreadIds, resolveThreadMetadataUpdateForNextTurn, @@ -436,14 +437,7 @@ const TYPE_TO_FOCUS_INTERACTIVE_SELECTOR = [ '[role="switch"]', '[role="tab"]', ].join(","); -const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [ - '[data-slot="dialog"]', - '[data-slot="menu-popup"]', - '[data-slot="select-popup"]', - '[data-slot="popover-popup"]', - '[data-slot="combobox-popup"]', - '[data-slot="autocomplete-popup"]', -].join(","); +const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = OPEN_FLOATING_LAYER_SELECTOR; type EnvironmentUnavailableState = { readonly environmentId: EnvironmentId; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c959ff132c93..79b100a2ae1a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -42,7 +42,11 @@ import { shouldInterruptRunningThreadFromKeybinding, shouldSubmitComposerOnEnter, } from "../../composer-logic"; -import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; +import { + deriveComposerSendState, + OPEN_FLOATING_LAYER_SELECTOR, + readFileAsDataUrl, +} from "../ChatView.logic"; import { dataTransferHasComposerMention, makeComposerMentionDragHandlers, @@ -2305,10 +2309,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) { return; } - // Belt and suspenders for overlays whose dismiss handlers do not mark - // the event consumed; the model picker is excluded via the default - // binding's when clause. - if (isCommandPaletteOpen() || isStashMenuOpen) { + // An open floating layer owns the keyboard even when its dismiss + // handler has not run yet — window bubble listeners fire in + // registration order, so a later-mounted overlay (the expanded image + // viewer) marks the event only after this handler has already seen it. + // Checking the DOM instead of the event answers "is an overlay open" + // regardless of listener ordering; the model picker is excluded via + // the default binding's when clause. + if (isCommandPaletteOpen() || document.querySelector(OPEN_FLOATING_LAYER_SELECTOR) !== null) { return; } event.preventDefault(); @@ -2316,7 +2324,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [isComposerModelPickerOpen, isStashMenuOpen, keybindings, onInterrupt, phase, terminalOpen]); + }, [isComposerModelPickerOpen, keybindings, onInterrupt, phase, terminalOpen]); // ------------------------------------------------------------------ // Callbacks: images