Skip to content

fix(terminal): let Ctrl+G reach the shell when search is closed - #209

Merged
kipavy merged 4 commits into
VoltiusApp:devfrom
tiepduong7c9:fix/ctrl-g-passthrough
Sep 7, 2026
Merged

fix(terminal): let Ctrl+G reach the shell when search is closed#209
kipavy merged 4 commits into
VoltiusApp:devfrom
tiepduong7c9:fix/ctrl-g-passthrough

Conversation

@tiepduong7c9

Copy link
Copy Markdown
Contributor

Fixes #208.

Problem

Ctrl+G never reached the PTY, so bash could not abort a Ctrl+R
reverse-i-search — readline's abort needs ^G (0x07) and the byte was never
sent. Ctrl+R worked, then the key was simply dead, which is what made it look
like a Ctrl+G-specific fault. Every other shell/TUI use of the key went the
same way: emacs keyboard-quit, nano's help toggle, and so on.

useTerminal.ts's custom key handler returned false for Ctrl+G
unconditionally — the return sat outside the check for an open search widget:

if (e.ctrlKey && !e.altKey && (e.key === "g" || e.key === "G")) {
  if (e.type === "keydown") {
    const ctrl = getTerminalSearchController(sessionId);
    if (ctrl?.getSnapshot().open) { /* next / prev */ }
  }
  return false;   // <- reached whether or not the widget is open
}

Returning false makes xterm skip the key entirely, encoding nothing, so with
the widget closed the branch did no work and still swallowed the key. Reported
on Fedora 44 / v0.31.1, but the path has no platform branch.

Fix

The chord is now only claimed while the widget is open. On the pass-through
xterm marks ctrl+letter cancel: true and calls preventDefault() itself, so
the native webview find-next — the reason 8eda22a blocked the key, and not a
problem I wanted to reintroduce — stays suppressed.

Measured against the real @xterm/xterm@6.0.0 in a browser, driving actual
Control+g keystrokes rather than synthetic ones:

search widget onData event ends reaches window handler
closed [0x07] defaultPrevented: true no — xterm stops propagation
open nothing defaultPrevented: false yes — useKeyboard cancels it there

So both paths keep the native dialog shut, by different routes: xterm's own
cancel on the shell path, and the existing window handler on the widget path.

The reporter has also confirmed it by hand on the Fedora 44 / WebKitGTK build
this was found on: Ctrl+G aborts a reverse-i-search again, and the Ctrl+F
widget still walks its hits.

Tests

src/hooks/useTerminal.ctrlG.test.tsx covers the handler's verdict for closed
widget, open widget (including that keyup stays consumed without moving the
hit again), and closed-again. Two of its three cases fail against the code this
replaces.

pnpm test — 522 files / 3991 tests pass. pnpm build clean. No Rust touched.

The first commit

The two existing tests that mount a terminal each carried their own copy of the
same ~35-line xterm stub, and the regression test would have been a third. Per
the DRY note in CLAUDE.md I extracted one fixture instead of adding a copy.
It's a separate commit and drops cleanly if you would rather review a one-file
diff — the fix itself does not depend on it beyond using the fixture.

tiepduong7c9 and others added 4 commits September 7, 2026 16:19
The two tests that mount a terminal each carried their own copy of the same
~35-line stub of the Terminal and addon surface useTerminal touches, so an
xterm API change had to be taught to both. A third copy was about to land with
the Ctrl+G regression test.

One fixture now owns the fake. Each test still declares its own vi.mock calls —
those are hoisted per file — but they point at the shared classes instead of
redefining them. Two additions the copies lacked: getSelection(), which the
search controller calls on open, and a SearchAddon that records its finds,
since the real one reports hits through onDidChangeResults and a stub cannot.
Ctrl+G never reached the PTY, so bash could not abort a Ctrl+R reverse-i-search:
readline's abort needs ^G (0x07) and the byte was never sent. Every shell and
TUI use of the key was dead the same way.

The custom key handler returned false for Ctrl+G unconditionally — the return
sat outside the check for an open search widget. Returning false makes xterm
skip the key entirely, encoding nothing, so with the widget closed the branch
did no work and still swallowed the key.

It now only claims the chord while the widget is open. On the pass-through xterm
marks ctrl+letter cancel:true and calls preventDefault itself, so the webview's
native find-next — the reason 8eda22a blocked the key in the first place —
stays suppressed. Verified against xterm 6.0.0 in a browser: with the widget
closed onData receives a single 0x07 and the event ends up defaultPrevented;
with it open xterm bails out early and the event still reaches the window
handler, which is what cancels the native dialog on that path.

The chord match and the next/prev drive were duplicated between the terminal
handler and the window handler; both now call one pair of helpers.

Fixes VoltiusApp#208
xterm's _keyDown returns early on a false verdict from the custom key
handler without calling cancel(), so a chord the handler claimed still
bubbled to useKeyboard's window listener. With the search widget open and
the canvas focused both handlers ran next()/prev(), and one press moved
two hits.

The canvas handler now takes the event itself when it drives the widget.
It is the right owner: it has this pane's session id, where the window
listener keys off activeSessionId.

The pass-through path is unchanged — it must leave the event alone so
xterm can encode ^G and cancel it on the way out.
Ctrl+F had the same double-fire as Ctrl+G: the canvas handler opened the
widget and returned false, xterm returned early without cancelling, and
useKeyboard then ran its own Ctrl+F branch — focusing the right panel's
search bar over the widget that had just opened. Its comment already
claimed useTerminal wins when the canvas has focus; now it does.

These two are the only branches that can double-fire. Every other
shortcut useKeyboard shares with the canvas sits below its `isInput`
guard, and the canvas is xterm's helper textarea.

Extracted claimChord() rather than write preventDefault/stopPropagation
twice, and renamed the test file, which is no longer only about Ctrl+G.
@kipavy

kipavy commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Reviewed, and the diagnosis holds up — I checked the claims about @xterm/xterm@6.0.0 against the shipped lib/xterm.js rather than taking the table on faith, and both are right: the ctrl+letter pass-through ends in cancel(e, true), which is preventDefault() and stopPropagation(), so the shell path keeps the native find-next shut without help. Reverting the two source files makes 2 of the 3 new cases fail, so the regression test earns its place. The fixture extraction was the right call too.

I've pushed two commits onto the branch rather than sending you round again for something this small.

3c1256d5 — Ctrl+G could still move two hits per press.

The other half of that _keyDown is the problem:

if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;

It returns early without calling cancel(). So on the path this PR keeps — widget open, handler returns false — the event is never stopped and goes on to useKeyboard's window listener, which runs its own next()/prev() on top of the one the canvas handler just ran. Reachable by opening the widget with Ctrl+F, clicking back into the terminal (the widget only closes on Escape or the ✕), then pressing Ctrl+G. Pre-existing, not something this PR introduced, but it lives in the lines the PR rewrites and the new comment in useKeyboard.ts reads as though the case were handled.

The canvas handler now takes the event when it drives the widget. It is the right owner: it has the pane's own session id, where the window listener keys off activeSessionId. The pass-through is untouched — it has to leave the event alone so xterm can encode ^G and cancel it on the way out.

ea0bdcad — Ctrl+F had the same shape, with a worse symptom.

useKeyboard's Ctrl+F branch prefers the right panel's search bar when one is open on a searchable section. With History or Snippets open and the terminal focused, Ctrl+F opened the terminal widget and then pulled focus into the panel's search bar. The comment in useKeyboard.ts already asserted that useTerminal handles it when the canvas has focus; now that is actually true.

Both call one claimChord() helper instead of two copies of preventDefault + stopPropagation.

Worth writing down, because it bounds the bug: these two are the only branches that can double-fire. They are the two placed above useKeyboard's if (isInput) return; guard, so they can suppress the native find dialog even inside inputs — and xterm's canvas is its helper <textarea>, so every other shortcut the two handlers share is already unreachable from the terminal. matchShortcut("omni") sits above the guard as well but is idempotent.

I renamed useTerminal.ctrlG.test.tsx to useTerminal.searchChords.test.tsx, since it now covers both chords, and added a case per chord asserting the event is claimed with the widget open and deliberately not claimed on the pass-through. Each fails against the code it replaces.

Local runs: tsc --noEmit clean, and the useTerminal.* / useKeyboard.* files pass. I could not get an honest full-suite run — my box kept OOM-killing vitest — so I am leaning on CI for that.

@kipavy
kipavy merged commit 8e4b537 into VoltiusApp:dev Sep 7, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants