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 { 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 cfbe1ac8d966..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; @@ -4570,7 +4564,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 +4586,6 @@ function ChatViewContent(props: ChatViewProps) { } } - const command = resolveShortcutCommand(event, keybindings, { - context: shortcutContext, - }); if (!command) return; if (command === "terminal.toggle") { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b8bacf4b6be2..79b100a2ae1a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -39,9 +39,14 @@ import { detectComposerTrigger, expandCollapsedComposerCursor, replaceTextRange, + shouldInterruptRunningThreadFromKeybinding, shouldSubmitComposerOnEnter, } from "../../composer-logic"; -import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; +import { + deriveComposerSendState, + OPEN_FLOATING_LAYER_SELECTOR, + readFileAsDataUrl, +} from "../ChatView.logic"; import { dataTransferHasComposerMention, makeComposerMentionDragHandlers, @@ -2275,6 +2280,52 @@ 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. + // + // 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, + terminalOpen, + modelPickerOpen: isComposerModelPickerOpen, + }, + }); + if ( + !shouldInterruptRunningThreadFromKeybinding({ command, isRunning: phase === "running" }) + ) { + return; + } + // 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(); + onInterrupt(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [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..203213d9a698 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 && !modelPickerOpen" }, ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ key: `mod+${index + 1}`, command,