Skip to content

feat(web): add a rebindable thread.interrupt keybinding - #5669

Open
ChristmasSun wants to merge 6 commits into
pingdotgg:mainfrom
ChristmasSun:feat/thread-interrupt-keybinding
Open

feat(web): add a rebindable thread.interrupt keybinding#5669
ChristmasSun wants to merge 6 commits into
pingdotgg:mainfrom
ChristmasSun:feat/thread-interrupt-keybinding

Conversation

@ChristmasSun

@ChristmasSun ChristmasSun commented Aug 8, 2026

Copy link
Copy Markdown

Closes #5667. Stacked on #5668 — rebinding the default Escape shortcut duplicates the rule without that fix, so this branch contains that commit too. Reviewing #5668 first leaves this diff at just the feature (5 files, +60).

What changed

thread.interrupt is registered as a keybinding command, defaulting to Escape. It appears in Settings → Keybindings as "Thread: Interrupt" and can be rebound like any other shortcut.

Why

Stopping a running turn required clicking the square stop button — there was no keyboard path, and no command to bind. Codex and Claude Code both stop on Escape, so the reflex already exists.

#4298 did this as a hard-coded Escape in the composer and was closed. This registers a command instead, so the key is configurable rather than baked in.

Behavior

  • The shortcut is only claimed while a turn is running. When the thread is idle the key falls through untouched, so Escape keeps its existing behavior everywhere else.
  • The default is scoped to !terminalFocus, so Escape still reaches the terminal.
  • The decision is a pure function (shouldInterruptRunningThreadFromKeybinding) with unit tests; the listener follows the existing composer.stash pattern in ChatComposer.
  • commandLabel derives "Thread: Interrupt" from the command id, so no label map changes were needed.

Rebinding back to Escape

The recorder uses bare Escape as its cancel action (KeybindingsSettings.tsx:807), matching how Codex behaves, so Escape is not recordable as a shortcut. Reset-to-default is the way back to it after rebinding, which works because Escape ships as this command's default.

Verification

  • apps/web composer-logic: 43 pass, including 3 new cases
  • packages/shared + settings suites: 422 pass
  • apps/server keybindings: 23 pass
  • vp lint clean, typecheck clean across web, contracts, shared, server

note: video here is for this fix + the fix on #5668 as if not when you change the stop thread keybind from escape to something else it duplicates which is an unrelated bug that #5668 fixes.

Screen.Recording.2026-08-07.at.5.20.36.PM.mov

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • Video of the interaction

Note

Medium Risk
Touches global keyboard routing (capture vs bubble) and default Escape behavior during active turns; changes are scoped with overlay checks and idle-thread fall-through, plus a server-side keybinding persistence fix.

Overview
Introduces thread.interrupt as a first-class keybinding (default escape, when !terminalFocus && !modelPickerOpen) so users can stop a running turn from the keyboard and rebind it in Settings.

ChatComposer listens on window keydown (bubble phase), resolves the binding, and calls onInterrupt only while the thread phase is running—ignoring repeat events, consumed keys, the command palette, and any open floating layer via shared OPEN_FLOATING_LAYER_SELECTOR (dialogs, menus, [role="dialog"], etc.).

ChatView type-to-focus now runs only when the key resolves to no command, so printable keys explicitly bound (e.g. to interrupt) are not swallowed in capture phase.

Server isSameKeybindingRule compares keys through parsed/canonical encoding so alias spellings (esc/escape) match when replacing rules from the settings UI.

Reviewed by Cursor Bugbot for commit 0c3b692. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add rebindable thread.interrupt keybinding defaulting to Escape

  • Adds thread.interrupt to the keybinding contracts and sets a default binding of Escape (when terminal and model picker are not focused) in packages/shared/src/keybindings.ts.
  • Wires the interrupt shortcut in ChatComposer via a window keydown bubble-phase handler that calls onInterrupt when the thread is running, guarded against floating layers and command palette.
  • Fixes type-to-focus in ChatView so it no longer intercepts keys that have an explicit keybinding — commands are resolved first and type-to-focus only activates when none match.
  • Fixes isSameKeybindingRule in keybindings.ts to canonicalize keys before comparison, treating alias spellings like esc/escape as equivalent.
  • Behavioral Change: Pressing Escape while a thread is running now triggers an interrupt unless a dialog, menu, select, popover, or command palette is open.

Macroscope summarized 0c3b692.

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.
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.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 09ecd654-eaa1-4a05-83d3-3a365a8e3c80

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added size:M 30-99 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list. labels Aug 8, 2026
Comment thread apps/web/src/components/chat/ChatComposer.tsx
Comment thread apps/web/src/components/chat/ChatComposer.tsx
@macroscopeapp

macroscopeapp Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

1 blocking correctness issue found. This PR introduces a new rebindable thread.interrupt keybinding with new keyboard event handling logic across multiple components. New features adding user-facing behavior warrant human review. An unresolved High-severity finding about event phase ordering has a claimed fix that should be verified.

You can customize Macroscope's approvability policy. Learn more.

…lays

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.
Comment thread apps/web/src/components/chat/ChatComposer.tsx
…binding

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.
event.preventDefault();
onInterrupt();
};
window.addEventListener("keydown", handler);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High chat/ChatComposer.tsx:2317

The thread.interrupt listener on window runs in bubble phase, but ChatView's type-to-focus handler runs in capture phase on the same window. When thread.interrupt is rebound to a bare printable key (e.g. x) and focus is on non-interactive chat content, the capture-phase handler calls shouldTypeToFocusComposer, inserts the key into the composer, and calls stopPropagation — so the bubble-phase interrupt handler never fires and the running turn is not interrupted. The interrupt binding needs to be resolved before type-to-focus can consume printable shortcuts, or otherwise excluded from that path.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/ChatComposer.tsx around line 2317:

The `thread.interrupt` listener on `window` runs in bubble phase, but `ChatView`'s type-to-focus handler runs in capture phase on the same `window`. When `thread.interrupt` is rebound to a bare printable key (e.g. `x`) and focus is on non-interactive chat content, the capture-phase handler calls `shouldTypeToFocusComposer`, inserts the key into the composer, and calls `stopPropagation` — so the bubble-phase interrupt handler never fires and the running turn is not interrupted. The interrupt binding needs to be resolved before type-to-focus can consume printable shortcuts, or otherwise excluded from that path.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in b00ab8c: ChatView's handler now resolves the shortcut before the type-to-focus branch, and type-to-focus only claims keys that resolve to no binding. An explicit binding on a bare printable key wins consistently — for every command, not just thread.interrupt — and the event then falls through the dispatch chain untouched to the bubble-phase interrupt listener. One deliberate trade-off: such a binding now always claims its key, so it no longer types into the composer even while the thread is idle. That matches how every modifier-chord binding already behaves, and the alternative — key types when idle, interrupts when running — seemed worse. Shipped defaults are all modifier chords, so nothing changes without a custom binding.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b00ab8c. Configure here.

Comment thread apps/web/src/components/chat/ChatComposer.tsx
Comment thread apps/web/src/components/chat/ChatComposer.tsx Outdated
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.
@github-actions github-actions Bot added size:L 100-499 changed lines (additions + deletions). and removed size:M 30-99 changed lines (additions + deletions). labels Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No keyboard shortcut to interrupt a running turn

1 participant