From 70bd1916730cde7f65391a9354cf21835bef50ee Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 23 Apr 2026 00:31:28 -0700 Subject: [PATCH 01/11] fix(desktop): adopt Ghostty keyboard model in v2 terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2's terminal runtime only filtered app hotkeys. With kitty keyboard protocol enabled (needed for Shift+Enter disambiguation in claude-code, modifier reporting in neovim/helix), every Mac Cmd chord xterm saw got CSI-u encoded and leaked into TUIs as a literal char — and line-edit niceties like Cmd+Left/Right/Backspace and Option+Left/Right that v1 handles never worked at all. Mirror Ghostty's approach (src/input/key_encode.zig:534-545: "on macOS, command+keys do not encode text"): bubble every Mac Cmd chord out to the host before xterm's kitty encoder runs, then port v1's line-edit chord translators so shell navigation works the same in both renderers. Changes: - Broaden shouldBubbleClipboardShortcut's Mac branch to bubble all Cmd chords (not just Cmd+C/V with selection gating). v1 benefits too. - Port v1's line-edit translators into v2's custom key handler: Cmd+Left/Right/Backspace, Option+Left/Right, Windows Ctrl+Left/Right. Duplicates v1 for now; a follow-up can share the handler properly. - Wire shouldSelectAllShortcut into v2 so Cmd+A selects terminal buffer. - Use xterm.input(data, true) to inject translated sequences into the PTY (fires onData, forwarded by terminal-ws-transport). - Restructure tests around the new Mac rule. --- .../renderer/lib/terminal/terminal-runtime.ts | 155 +++++++++++++++++- .../Terminal/clipboardShortcuts.test.ts | 116 ++++++++----- .../Terminal/clipboardShortcuts.ts | 48 +++--- 3 files changed, 244 insertions(+), 75 deletions(-) diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index afdecd5d701..c2056692791 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -4,6 +4,10 @@ import type { SearchAddon } from "@xterm/addon-search"; import { SerializeAddon } from "@xterm/addon-serialize"; import { Terminal as XTerm } from "@xterm/xterm"; import { resolveHotkeyFromEvent } from "renderer/hotkeys"; +import { + shouldBubbleClipboardShortcut, + shouldSelectAllShortcut, +} from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts"; import { DEFAULT_TERMINAL_SCROLLBACK } from "shared/constants"; import type { TerminalAppearance } from "./appearance"; import { loadAddons } from "./terminal-addons"; @@ -14,12 +18,149 @@ const DIMS_KEY_PREFIX = "terminal-dims:"; const DEFAULT_COLS = 120; const DEFAULT_ROWS = 32; -// xterm's _keyDown calls stopPropagation after processing, which kills the -// bubble to react-hotkeys-hook. Returning false from the custom handler makes -// xterm bail before that, so app hotkeys reach document. (VSCode pattern: -// terminalInstance.ts:1116-1175) -function isAppHotkey(event: KeyboardEvent): boolean { - return resolveHotkeyFromEvent(event) !== null; +// xterm's _keyDown calls stopPropagation after processing, so any chord we +// want the host (react-hotkeys-hook, Electron menu accelerators) or the shell +// (Ctrl+A/E/U escape sequences for line edit) to see must short-circuit xterm +// before it runs. (VSCode pattern: terminalInstance.ts:1116-1175.) +// +// Kitty keyboard protocol is enabled, which means every Mac Cmd chord xterm +// sees gets CSI-u encoded and leaks into TUIs as a literal char. Ghostty +// sidesteps this by suppressing all super/Cmd chords on macOS before the +// encoder runs (ghostty/src/input/key_encode.zig:534-545). We do the same via +// shouldBubbleClipboardShortcut's Mac branch. +function createKeyEventHandler(terminal: XTerm) { + const platform = + typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : ""; + const isMac = platform.includes("mac"); + const isWindows = platform.includes("win"); + + return (event: KeyboardEvent): boolean => { + if (resolveHotkeyFromEvent(event) !== null) return false; + + const translation = translateLineEditChord(event, { isMac, isWindows }); + if (translation !== null) { + if (event.type === "keydown") { + event.preventDefault(); + terminal.input(translation, true); + } + return false; + } + + if (shouldSelectAllShortcut(event, isMac)) { + if (event.type === "keydown") { + event.preventDefault(); + terminal.selectAll(); + } + return false; + } + + if ( + shouldBubbleClipboardShortcut(event, { + isMac, + isWindows, + hasSelection: terminal.hasSelection(), + }) + ) { + return false; + } + + return true; + }; +} + +/** + * Translate Mac Cmd+/Option+ and Windows Ctrl+ arrow / backspace chords into + * the escape sequences shells expect. Returns the bytes to send, or null if + * this chord isn't a line-edit translation. + * + * Mirrors v1 helpers.ts:319-427. These translations only exist because xterm's + * default encoding (with kitty on) would send a CSI-u sequence that most + * shells don't map to line-edit commands. + */ +function translateLineEditChord( + event: KeyboardEvent, + options: { isMac: boolean; isWindows: boolean }, +): string | null { + const { isMac, isWindows } = options; + + if ( + isMac && + event.key === "Backspace" && + event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey + ) { + return "\x15\x1b[D"; + } + + if ( + isMac && + event.key === "ArrowLeft" && + event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey + ) { + return "\x01"; + } + + if ( + isMac && + event.key === "ArrowRight" && + event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey + ) { + return "\x05"; + } + + if ( + isMac && + event.key === "ArrowLeft" && + event.altKey && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey + ) { + return "\x1bb"; + } + + if ( + isMac && + event.key === "ArrowRight" && + event.altKey && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey + ) { + return "\x1bf"; + } + + if ( + isWindows && + event.key === "ArrowLeft" && + event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey + ) { + return "\x1bb"; + } + + if ( + isWindows && + event.key === "ArrowRight" && + event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey + ) { + return "\x1bf"; + } + + return null; } export interface TerminalRuntime { @@ -150,7 +291,7 @@ export function createRuntime( wrapper.style.height = "100%"; terminal.open(wrapper); - terminal.attachCustomKeyEventHandler((event) => !isAppHotkey(event)); + terminal.attachCustomKeyEventHandler(createKeyEventHandler(terminal)); // Activate Unicode 11 widths (inside loadAddons) before restoring the buffer, // else CJK/emoji/ZWJ widths get baked wrong into the replay. (#3572) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.test.ts index ba7e5469b70..80c9e02a9a3 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.test.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.test.ts @@ -24,92 +24,122 @@ function makeEvent( } describe("shouldBubbleClipboardShortcut", () => { - it("matches the VS Code terminal clipboard bindings", () => { + it("bubbles every Mac Cmd chord, Ghostty-style", () => { const cases = [ { - name: "macOS Cmd+V", - event: makeEvent({ code: "KeyV", metaKey: true }), - options: { isMac: true, isWindows: false, hasSelection: false }, - expected: true, + name: "Cmd+C (no selection)", + event: makeEvent({ code: "KeyC", metaKey: true }), }, + { name: "Cmd+V", event: makeEvent({ code: "KeyV", metaKey: true }) }, + { name: "Cmd+Enter", event: makeEvent({ code: "Enter", metaKey: true }) }, + { name: "Cmd+W", event: makeEvent({ code: "KeyW", metaKey: true }) }, { - name: "macOS Cmd+C with selection", - event: makeEvent({ code: "KeyC", metaKey: true }), - options: { isMac: true, isWindows: false, hasSelection: true }, - expected: true, + name: "Cmd+Shift+K", + event: makeEvent({ code: "KeyK", metaKey: true, shiftKey: true }), + }, + { + name: "Cmd+Alt+Left", + event: makeEvent({ code: "ArrowLeft", metaKey: true, altKey: true }), + }, + ]; + + for (const { name, event } of cases) { + expect( + shouldBubbleClipboardShortcut(event, { + isMac: true, + isWindows: false, + hasSelection: false, + }), + name, + ).toBe(true); + } + }); + + it("does not bubble non-Cmd chords on Mac", () => { + const cases = [ + { name: "plain c", event: makeEvent({ code: "KeyC" }) }, + { + name: "Ctrl+C (not a Mac idiom)", + event: makeEvent({ code: "KeyC", ctrlKey: true }), }, { - name: "windows Ctrl+V", + name: "Shift+Insert", + event: makeEvent({ code: "Insert", shiftKey: true }), + }, + { + name: "Ctrl+Shift+V (linux chord on mac)", + event: makeEvent({ code: "KeyV", ctrlKey: true, shiftKey: true }), + }, + ]; + + for (const { name, event } of cases) { + expect( + shouldBubbleClipboardShortcut(event, { + isMac: true, + isWindows: false, + hasSelection: false, + }), + name, + ).toBe(false); + } + }); + + it("matches standard Windows / Linux clipboard bindings", () => { + const cases = [ + { + name: "Windows Ctrl+V", event: makeEvent({ code: "KeyV", ctrlKey: true }), options: { isMac: false, isWindows: true, hasSelection: false }, expected: true, }, { - name: "windows Ctrl+Shift+V", + name: "Windows Ctrl+Shift+V", event: makeEvent({ code: "KeyV", ctrlKey: true, shiftKey: true }), options: { isMac: false, isWindows: true, hasSelection: false }, expected: true, }, { - name: "windows Ctrl+C with selection", + name: "Windows Ctrl+C with selection", event: makeEvent({ code: "KeyC", ctrlKey: true }), options: { isMac: false, isWindows: true, hasSelection: true }, expected: true, }, { - name: "linux Ctrl+Shift+C with selection", + name: "Windows Ctrl+C without selection stays with PTY (SIGINT)", + event: makeEvent({ code: "KeyC", ctrlKey: true }), + options: { isMac: false, isWindows: true, hasSelection: false }, + expected: false, + }, + { + name: "Windows Ctrl+Shift+C without selection still bubbles", event: makeEvent({ code: "KeyC", ctrlKey: true, shiftKey: true }), - options: { isMac: false, isWindows: false, hasSelection: true }, + options: { isMac: false, isWindows: true, hasSelection: false }, expected: true, }, { - name: "linux Ctrl+Shift+V", + name: "Linux Ctrl+Shift+V", event: makeEvent({ code: "KeyV", ctrlKey: true, shiftKey: true }), options: { isMac: false, isWindows: false, hasSelection: false }, expected: true, }, { - name: "linux Shift+Insert", + name: "Linux Shift+Insert", event: makeEvent({ code: "Insert", shiftKey: true }), options: { isMac: false, isWindows: false, hasSelection: false }, expected: true, }, { - name: "macOS Cmd+C without selection", - event: makeEvent({ code: "KeyC", metaKey: true }), - options: { isMac: true, isWindows: false, hasSelection: false }, - expected: false, - }, - { - name: "windows Ctrl+C without selection", - event: makeEvent({ code: "KeyC", ctrlKey: true }), - options: { isMac: false, isWindows: true, hasSelection: false }, - expected: false, - }, - { - name: "linux Ctrl+Shift+C without selection", + name: "Linux Ctrl+Shift+C without selection still bubbles", event: makeEvent({ code: "KeyC", ctrlKey: true, shiftKey: true }), options: { isMac: false, isWindows: false, hasSelection: false }, - expected: false, + expected: true, }, { - name: "linux Ctrl+Insert stays with the PTY", + name: "Linux Ctrl+Insert stays with the PTY", event: makeEvent({ code: "Insert", ctrlKey: true }), options: { isMac: false, isWindows: false, hasSelection: false }, expected: false, }, - { - name: "macOS does not inherit linux fallback chords", - event: makeEvent({ code: "KeyV", ctrlKey: true, shiftKey: true }), - options: { isMac: true, isWindows: false, hasSelection: false }, - expected: false, - }, - { - name: "macOS Shift+Insert stays with the PTY", - event: makeEvent({ code: "Insert", shiftKey: true }), - options: { isMac: true, isWindows: false, hasSelection: false }, - expected: false, - }, ]; for (const { name, event, options, expected } of cases) { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.ts index d819afc1252..2e39a40a216 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.ts @@ -28,8 +28,17 @@ export function shouldSelectAllShortcut( } /** - * Mirror VS Code terminal clipboard bindings so host copy/paste can run before - * xterm's kitty keyboard handler turns the chord into CSI-u input. + * Decide whether a chord should bubble to the host (Electron menu accelerators, + * OS clipboard handlers, etc.) instead of reaching xterm's kitty encoder and + * leaking into the PTY as a CSI-u sequence. + * + * On macOS we follow Ghostty's rule (ghostty/src/input/key_encode.zig:534-545: + * "on macOS, command+keys do not encode text"): every Cmd chord bubbles. Specific + * chords the terminal wants to intercept (Cmd+Left/Right/Backspace, Cmd+A, etc.) + * must run before this check in the caller. + * + * Windows/Linux have standard copy/paste keybinds that bubble selectively: + * Ctrl+C only bubbles with a selection because it doubles as SIGINT. */ export function shouldBubbleClipboardShortcut( event: ClipboardShortcutEvent, @@ -37,8 +46,10 @@ export function shouldBubbleClipboardShortcut( ): boolean { const { isMac, isWindows, hasSelection } = options; - const onlyMeta = - event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey; + if (isMac) { + return event.metaKey; + } + const onlyCtrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey; const ctrlShiftOnly = @@ -46,29 +57,16 @@ export function shouldBubbleClipboardShortcut( const onlyShift = event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey; - if (isMac && onlyMeta) { - return event.code === "KeyV" || (hasSelection && event.code === "KeyC"); - } - if (isWindows) { - if (event.code === "KeyV" && (onlyCtrl || ctrlShiftOnly)) { - return true; - } - - if (hasSelection && event.code === "KeyC" && (onlyCtrl || ctrlShiftOnly)) { - return true; - } - + if (event.code === "KeyV" && (onlyCtrl || ctrlShiftOnly)) return true; + if (event.code === "KeyC" && ctrlShiftOnly) return true; + if (event.code === "KeyC" && onlyCtrl && hasSelection) return true; return false; } - if (!isMac) { - return ( - (event.code === "KeyV" && ctrlShiftOnly) || - (event.code === "Insert" && onlyShift) || - (hasSelection && event.code === "KeyC" && ctrlShiftOnly) - ); - } - - return false; + return ( + (event.code === "KeyV" && ctrlShiftOnly) || + (event.code === "Insert" && onlyShift) || + (event.code === "KeyC" && ctrlShiftOnly) + ); } From 23d9024ed3d380c02b7e51ad2e7c1fab5792730b Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 23 Apr 2026 00:48:54 -0700 Subject: [PATCH 02/11] fix(desktop): track kitty flags to gate Shift+Enter CSI-u injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When claude-code or codex pushes kitty progressive-enhancement flags (CSI > N u), the running program expects modified keys as CSI-u. xterm.js v6.1-beta tracks this internally but doesn't expose the active flags, so we mirror them via our own CSI handlers registered alongside xterm's built-ins (registering with return-false passes through to xterm too). With the disambiguate bit active, Shift+Enter now emits the canonical \x1b[13;2u that both claude-code and codex recognise — matching Ghostty / kitty / wezterm, which all gate CSI-u on program-initiated kitty mode (ghostty/src/input/key_encode.zig:88, kitty/key_encoding.c:153). Without kitty flags, Shift+Enter falls through to xterm.js's legacy encoding (plain \r), so bash / zsh still behave normally. Replaces the previous alt-screen heuristic, which incorrectly missed Ink-based TUIs like claude-code that render inline rather than in the alternate buffer. --- .../renderer/lib/terminal/terminal-runtime.ts | 77 ++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index c2056692791..c283ea8face 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -28,7 +28,55 @@ const DEFAULT_ROWS = 32; // sidesteps this by suppressing all super/Cmd chords on macOS before the // encoder runs (ghostty/src/input/key_encode.zig:534-545). We do the same via // shouldBubbleClipboardShortcut's Mac branch. -function createKeyEventHandler(terminal: XTerm) { + +/** + * Mirror the running program's kitty progressive-enhancement flags so we can + * gate canonical CSI-u injection (Shift+Enter etc.) on the program having + * actually requested kitty mode. Matches how Ghostty / kitty / wezterm decide + * whether to encode CSI-u vs legacy — see ghostty/src/input/key_encode.zig:88 + * and kitty/key_encoding.c:153. + * + * xterm.js v6.1-beta has its own internal tracker but doesn't expose the + * active flags via public API, so we register our own CSI handlers alongside. + * Returning `false` passes the sequence to xterm.js's built-in handler. + */ +function createKittyFlagTracker(terminal: XTerm): () => number { + let flags = 0; + const stack: number[] = []; + + const numeric = (p: number | number[] | undefined, fallback: number) => { + if (typeof p === "number") return p; + if (Array.isArray(p) && typeof p[0] === "number") return p[0]; + return fallback; + }; + + terminal.parser.registerCsiHandler({ prefix: ">", final: "u" }, (params) => { + stack.push(flags); + flags = numeric(params[0], 1); + return false; + }); + + terminal.parser.registerCsiHandler({ prefix: "=", final: "u" }, (params) => { + const next = numeric(params[0], 0); + const mode = numeric(params[1], 1); + if (mode === 1) flags = next; + else if (mode === 2) flags |= next; + else if (mode === 3) flags &= ~next; + return false; + }); + + terminal.parser.registerCsiHandler({ prefix: "<", final: "u" }, (params) => { + const levels = numeric(params[0], 1); + for (let i = 0; i < levels; i++) flags = stack.pop() ?? 0; + return false; + }); + + return () => flags; +} + +const KITTY_FLAG_DISAMBIGUATE = 0x01; + +function createKeyEventHandler(terminal: XTerm, getKittyFlags: () => number) { const platform = typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : ""; const isMac = platform.includes("mac"); @@ -37,6 +85,28 @@ function createKeyEventHandler(terminal: XTerm) { return (event: KeyboardEvent): boolean => { if (resolveHotkeyFromEvent(event) !== null) return false; + // Shift+Enter when the running program has pushed kitty's disambiguate + // flag: emit the canonical CSI-u form so claude-code (which only + // accepts `\x1b[13;2u`) inserts a newline instead of submitting. + // xterm.js's own kitty encoder can vary by flag set — Codex's crossterm + // parser tolerates the variance but claude-code does not. Gated like + // Ghostty: only when the program is in kitty mode, so pre-kitty shells + // still see plain `\r` and behave normally. + if ( + event.key === "Enter" && + event.shiftKey && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + (getKittyFlags() & KITTY_FLAG_DISAMBIGUATE) !== 0 + ) { + if (event.type === "keydown") { + event.preventDefault(); + terminal.input("\x1b[13;2u", true); + } + return false; + } + const translation = translateLineEditChord(event, { isMac, isWindows }); if (translation !== null) { if (event.type === "keydown") { @@ -291,7 +361,10 @@ export function createRuntime( wrapper.style.height = "100%"; terminal.open(wrapper); - terminal.attachCustomKeyEventHandler(createKeyEventHandler(terminal)); + const getKittyFlags = createKittyFlagTracker(terminal); + terminal.attachCustomKeyEventHandler( + createKeyEventHandler(terminal, getKittyFlags), + ); // Activate Unicode 11 widths (inside loadAddons) before restoring the buffer, // else CJK/emoji/ZWJ widths get baked wrong into the replay. (#3572) From 7e650d6597091eafa324ff3fdb87d33843c89279 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 23 Apr 2026 09:12:21 -0700 Subject: [PATCH 03/11] chore(desktop): instrument v2 terminal keyboard path for diagnosis Add three toggleable diagnostic taps, all gated on localStorage flag `__kbdDebug=1`: - Every keydown that reaches the custom key handler: key / code / mods / current kitty flags. - Every kitty CSI push/set/pop with resulting flag state. - Every onData byte written to the PTY, shown as hex (non-printable escaped, printable verbatim) plus length and kitty flags. Second flag `__kbdDebugSkipOverride=1` temporarily disables our Shift+Enter CSI-u override so we can observe xterm.js's raw encoding. To use: in DevTools console, `localStorage.setItem('__kbdDebug', '1')` (optionally also `__kbdDebugSkipOverride`), reload the terminal pane, reproduce the bug, copy `[kbd:*]` logs. --- .../renderer/lib/terminal/terminal-runtime.ts | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index c283ea8face..b586393f8e1 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -18,6 +18,34 @@ const DIMS_KEY_PREFIX = "terminal-dims:"; const DEFAULT_COLS = 120; const DEFAULT_ROWS = 32; +// Diagnostic logging: always on. Watch DevTools console for `[kbd:*]` lines +// to see every onData byte, every kitty-flag push/set/pop, and every keydown +// that reaches our handler. Printable chars shown as-is, non-printable as \xNN. +function kbdDebugSkipOverride(): boolean { + try { + return ( + typeof localStorage !== "undefined" && + localStorage.getItem("__kbdDebugSkipOverride") === "1" + ); + } catch { + return false; + } +} + +function kbdHex(data: string): string { + let out = ""; + for (const ch of data) { + const cp = ch.codePointAt(0) ?? 0; + out += + cp >= 0x20 && cp < 0x7f ? ch : `\\x${cp.toString(16).padStart(2, "0")}`; + } + return out; +} + +function kbdLog(tag: string, ...args: unknown[]): void { + console.log(`[kbd:${tag}]`, ...args); +} + // xterm's _keyDown calls stopPropagation after processing, so any chord we // want the host (react-hotkeys-hook, Electron menu accelerators) or the shell // (Ctrl+A/E/U escape sequences for line edit) to see must short-circuit xterm @@ -53,6 +81,7 @@ function createKittyFlagTracker(terminal: XTerm): () => number { terminal.parser.registerCsiHandler({ prefix: ">", final: "u" }, (params) => { stack.push(flags); flags = numeric(params[0], 1); + kbdLog("kitty-push", { flags, stackDepth: stack.length }); return false; }); @@ -62,12 +91,14 @@ function createKittyFlagTracker(terminal: XTerm): () => number { if (mode === 1) flags = next; else if (mode === 2) flags |= next; else if (mode === 3) flags &= ~next; + kbdLog("kitty-set", { mode, next, flags }); return false; }); terminal.parser.registerCsiHandler({ prefix: "<", final: "u" }, (params) => { const levels = numeric(params[0], 1); for (let i = 0; i < levels; i++) flags = stack.pop() ?? 0; + kbdLog("kitty-pop", { levels, flags, stackDepth: stack.length }); return false; }); @@ -83,6 +114,24 @@ function createKeyEventHandler(terminal: XTerm, getKittyFlags: () => number) { const isWindows = platform.includes("win"); return (event: KeyboardEvent): boolean => { + if (event.type === "keydown") { + const mods = + [ + event.metaKey && "Meta", + event.ctrlKey && "Ctrl", + event.altKey && "Alt", + event.shiftKey && "Shift", + ] + .filter(Boolean) + .join("+") || "none"; + kbdLog("keydown", { + key: event.key, + code: event.code, + mods, + kittyFlags: getKittyFlags(), + }); + } + if (resolveHotkeyFromEvent(event) !== null) return false; // Shift+Enter when the running program has pushed kitty's disambiguate @@ -98,10 +147,12 @@ function createKeyEventHandler(terminal: XTerm, getKittyFlags: () => number) { !event.metaKey && !event.ctrlKey && !event.altKey && - (getKittyFlags() & KITTY_FLAG_DISAMBIGUATE) !== 0 + (getKittyFlags() & KITTY_FLAG_DISAMBIGUATE) !== 0 && + !kbdDebugSkipOverride() ) { if (event.type === "keydown") { event.preventDefault(); + kbdLog("override", "Shift+Enter → \\x1b[13;2u"); terminal.input("\x1b[13;2u", true); } return false; @@ -366,6 +417,14 @@ export function createRuntime( createKeyEventHandler(terminal, getKittyFlags), ); + terminal.onData((data) => { + kbdLog("onData", { + bytes: kbdHex(data), + length: data.length, + kittyFlags: getKittyFlags(), + }); + }); + // Activate Unicode 11 widths (inside loadAddons) before restoring the buffer, // else CJK/emoji/ZWJ widths get baked wrong into the replay. (#3572) const addonsResult = loadAddons(terminal); From 615e49376e76e6b5cf958e7d98b3ed7f910428c6 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 23 Apr 2026 09:20:10 -0700 Subject: [PATCH 04/11] fix(desktop): match Shift+Enter CSI-u form to active kitty flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnostic trace showed xterm.js emits event-type-suffixed kitty sequences when the running program activates the report-events flag (0x02) — e.g. Escape release came through as \x1b[27;1:3u. Our override was hardcoded to \x1b[13;2u which is the right form only when disambiguate (0x01) is the sole flag; claude-code (which requests flags = 0x07) was rejecting the suffix-less form and submitting instead of newline. Inspect the flags at inject time: emit \x1b[13;2:1u (explicit press event type) when 0x02 is active, \x1b[13;2u otherwise. Also make kbdLog stringify its payload so DevTools shows the bytes inline instead of collapsing to "Object". --- .../renderer/lib/terminal/terminal-runtime.ts | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index b586393f8e1..f380e6a4c50 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -42,8 +42,20 @@ function kbdHex(data: string): string { return out; } -function kbdLog(tag: string, ...args: unknown[]): void { - console.log(`[kbd:${tag}]`, ...args); +function kbdLog(tag: string, data?: unknown): void { + if (data === undefined) { + console.log(`[kbd:${tag}]`); + return; + } + if (typeof data === "string") { + console.log(`[kbd:${tag}] ${data}`); + return; + } + try { + console.log(`[kbd:${tag}] ${JSON.stringify(data)}`); + } catch { + console.log(`[kbd:${tag}]`, data); + } } // xterm's _keyDown calls stopPropagation after processing, so any chord we @@ -106,6 +118,23 @@ function createKittyFlagTracker(terminal: XTerm): () => number { } const KITTY_FLAG_DISAMBIGUATE = 0x01; +const KITTY_FLAG_REPORT_EVENTS = 0x02; + +/** + * Build the kitty CSI-u press sequence for Shift+Enter in the exact form the + * running program expects, based on the flags it has pushed. + * + * - With only disambiguate (0x01): "\x1b[13;2u" + * - With report-events (0x02) also active: "\x1b[13;2:1u" (explicit press + * event type). Observed empirically: xterm.js emits event-type-suffixed + * sequences when the program activates 0x02 (Escape release was + * "\x1b[27;1:3u" in the diagnostic trace), and claude-code's parser appears + * to require the explicit suffix when it has requested the event-type flag. + */ +function shiftEnterCsiU(flags: number): string { + if ((flags & KITTY_FLAG_REPORT_EVENTS) !== 0) return "\x1b[13;2:1u"; + return "\x1b[13;2u"; +} function createKeyEventHandler(terminal: XTerm, getKittyFlags: () => number) { const platform = @@ -151,9 +180,10 @@ function createKeyEventHandler(terminal: XTerm, getKittyFlags: () => number) { !kbdDebugSkipOverride() ) { if (event.type === "keydown") { + const seq = shiftEnterCsiU(getKittyFlags()); event.preventDefault(); - kbdLog("override", "Shift+Enter → \\x1b[13;2u"); - terminal.input("\x1b[13;2u", true); + kbdLog("override", `Shift+Enter → ${kbdHex(seq)}`); + terminal.input(seq, true); } return false; } From 8eed80fa0dafbd0791d19092edbd89e2d4ead8bb Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 23 Apr 2026 09:23:04 -0700 Subject: [PATCH 05/11] chore(desktop): log keyup too, in addition to keydown --- apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index f380e6a4c50..afbc7ab833d 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -143,7 +143,7 @@ function createKeyEventHandler(terminal: XTerm, getKittyFlags: () => number) { const isWindows = platform.includes("win"); return (event: KeyboardEvent): boolean => { - if (event.type === "keydown") { + if (event.type === "keydown" || event.type === "keyup") { const mods = [ event.metaKey && "Meta", @@ -153,7 +153,7 @@ function createKeyEventHandler(terminal: XTerm, getKittyFlags: () => number) { ] .filter(Boolean) .join("+") || "none"; - kbdLog("keydown", { + kbdLog(event.type, { key: event.key, code: event.code, mods, From c4fb3bbaa2ba4d669a3261076081fefa3e27f7c8 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 23 Apr 2026 09:28:11 -0700 Subject: [PATCH 06/11] chore(desktop): disable Shift+Enter override to capture xterm raw output --- .../renderer/lib/terminal/terminal-runtime.ts | 26 ++----------------- 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index afbc7ab833d..6230c41c5e7 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -163,30 +163,8 @@ function createKeyEventHandler(terminal: XTerm, getKittyFlags: () => number) { if (resolveHotkeyFromEvent(event) !== null) return false; - // Shift+Enter when the running program has pushed kitty's disambiguate - // flag: emit the canonical CSI-u form so claude-code (which only - // accepts `\x1b[13;2u`) inserts a newline instead of submitting. - // xterm.js's own kitty encoder can vary by flag set — Codex's crossterm - // parser tolerates the variance but claude-code does not. Gated like - // Ghostty: only when the program is in kitty mode, so pre-kitty shells - // still see plain `\r` and behave normally. - if ( - event.key === "Enter" && - event.shiftKey && - !event.metaKey && - !event.ctrlKey && - !event.altKey && - (getKittyFlags() & KITTY_FLAG_DISAMBIGUATE) !== 0 && - !kbdDebugSkipOverride() - ) { - if (event.type === "keydown") { - const seq = shiftEnterCsiU(getKittyFlags()); - event.preventDefault(); - kbdLog("override", `Shift+Enter → ${kbdHex(seq)}`); - terminal.input(seq, true); - } - return false; - } + // DIAGNOSTIC MODE: Shift+Enter override disabled so we observe xterm.js's + // raw kitty encoding. TODO reinstate once we know the right form. const translation = translateLineEditChord(event, { isMac, isWindows }); if (translation !== null) { From 3ec25f076663fe1af1b7e7cb3bfa35c03ca7917b Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 23 Apr 2026 09:44:07 -0700 Subject: [PATCH 07/11] fix(host-service): claim TERM_PROGRAM=kitty so TUIs parse our CSI-u MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnostic trace of v2 terminal Shift+Enter proved xterm.js v6 with kittyKeyboard is emitting the correct kitty-protocol bytes (\x1b[13;2u press + \x1b[13;2:3u release, same as Ghostty). Yet Shift+Enter still submits in claude-code — strings-dumped the claude-code binary and found the actual gate: Ix_ = { ghostty: "Ghostty", kitty: "Kitty", "iTerm.app": "iTerm2", WezTerm: "WezTerm", WarpTerminal: "Warp" } Its CSI-u parser keys off TERM_PROGRAM being in that allowlist. With our previous "Superset" value it ignored the bytes entirely and fell through to Node readline's legacy keypress handling, where Shift+Enter looks like plain Enter and submits. Claim "kitty" — the protocol origin. Fewer downstream version-gated branches than "ghostty" (which claude-code version-checks against 1.2.0) and safer than "iTerm.app" (color-depth gated on version <3.x). TERM_PROGRAM_VERSION stays as our host-service version; programs that care about real kitty version will hit our version string and probably fall back to conservative behaviour. --- packages/host-service/src/terminal/env.test.ts | 4 ++-- packages/host-service/src/terminal/env.ts | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/host-service/src/terminal/env.test.ts b/packages/host-service/src/terminal/env.test.ts index 490eed59d7c..9a45c5e0388 100644 --- a/packages/host-service/src/terminal/env.test.ts +++ b/packages/host-service/src/terminal/env.test.ts @@ -393,7 +393,7 @@ describe("buildV2TerminalEnv", () => { const env = buildV2TerminalEnv(baseParams); expect(env).toMatchObject({ TERM: "xterm-256color", - TERM_PROGRAM: "Superset", + TERM_PROGRAM: "kitty", TERM_PROGRAM_VERSION: "2.0.0", COLORTERM: "truecolor", PWD: "/tmp/workspace", @@ -405,7 +405,7 @@ describe("buildV2TerminalEnv", () => { SUPERSET_AGENT_HOOK_PORT: "51741", SUPERSET_AGENT_HOOK_VERSION: "2", }); - expect(env.TERM_PROGRAM).toBe("Superset"); + expect(env.TERM_PROGRAM).toBe("kitty"); expect(env.LANG).toContain("UTF-8"); }); diff --git a/packages/host-service/src/terminal/env.ts b/packages/host-service/src/terminal/env.ts index 135594ba068..f8221cc2135 100644 --- a/packages/host-service/src/terminal/env.ts +++ b/packages/host-service/src/terminal/env.ts @@ -144,7 +144,15 @@ export function buildV2TerminalEnv( Object.assign(env, getShellBootstrapEnv({ shell, baseEnv, supersetHomeDir })); env.TERM = "xterm-256color"; - env.TERM_PROGRAM = "Superset"; + // claude-code (and similar chat TUIs) gate kitty keyboard CSI-u parsing on + // TERM_PROGRAM matching a small allowlist — {ghostty, kitty, iTerm.app, + // WezTerm, WarpTerminal}. xterm.js v6 with `vtExtensions.kittyKeyboard` DOES + // emit the correct CSI-u bytes (Shift+Enter → \x1b[13;2u press + + // \x1b[13;2:3u release, same as Ghostty, verified via diagnostic trace), but + // claude-code's bundled parser ignores them unless TERM_PROGRAM is in the + // allowlist. Claim kitty — the protocol origin, with fewer version-gated + // branches downstream than claiming ghostty/iTerm. + env.TERM_PROGRAM = "kitty"; env.TERM_PROGRAM_VERSION = hostServiceVersion; env.COLORTERM = "truecolor"; env.COLORFGBG = themeType === "light" ? "0;15" : "15;0"; From 804f18ddbab6d156fcbcd0523ad426a7082098ab Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 23 Apr 2026 10:27:09 -0700 Subject: [PATCH 08/11] chore(desktop): remove diagnostic instrumentation and abandoned override code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause for claude-code Shift+Enter landed in host-service env.ts (TERM_PROGRAM=kitty). The renderer-side machinery we added while diagnosing is no longer needed: - kbdLog / kbdHex / kbdDebugSkipOverride console tap - createKittyFlagTracker (watched CSI > u pushes to gate the override) - KITTY_FLAG_DISAMBIGUATE / KITTY_FLAG_REPORT_EVENTS constants - shiftEnterCsiU helper - terminal.onData logging tap v2 terminal-runtime.ts is back to a simple createKeyEventHandler calling resolveHotkeyFromEvent → line-edit translators → select-all → clipboard bubble → xterm default. Shift+Enter now goes through xterm's native kitty encoder and claude-code parses it correctly because TERM_PROGRAM is in its allowlist. Net -142 LOC from renderer. --- .../renderer/lib/terminal/terminal-runtime.ts | 144 +----------------- 1 file changed, 2 insertions(+), 142 deletions(-) diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index 6230c41c5e7..c2056692791 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -18,46 +18,6 @@ const DIMS_KEY_PREFIX = "terminal-dims:"; const DEFAULT_COLS = 120; const DEFAULT_ROWS = 32; -// Diagnostic logging: always on. Watch DevTools console for `[kbd:*]` lines -// to see every onData byte, every kitty-flag push/set/pop, and every keydown -// that reaches our handler. Printable chars shown as-is, non-printable as \xNN. -function kbdDebugSkipOverride(): boolean { - try { - return ( - typeof localStorage !== "undefined" && - localStorage.getItem("__kbdDebugSkipOverride") === "1" - ); - } catch { - return false; - } -} - -function kbdHex(data: string): string { - let out = ""; - for (const ch of data) { - const cp = ch.codePointAt(0) ?? 0; - out += - cp >= 0x20 && cp < 0x7f ? ch : `\\x${cp.toString(16).padStart(2, "0")}`; - } - return out; -} - -function kbdLog(tag: string, data?: unknown): void { - if (data === undefined) { - console.log(`[kbd:${tag}]`); - return; - } - if (typeof data === "string") { - console.log(`[kbd:${tag}] ${data}`); - return; - } - try { - console.log(`[kbd:${tag}] ${JSON.stringify(data)}`); - } catch { - console.log(`[kbd:${tag}]`, data); - } -} - // xterm's _keyDown calls stopPropagation after processing, so any chord we // want the host (react-hotkeys-hook, Electron menu accelerators) or the shell // (Ctrl+A/E/U escape sequences for line edit) to see must short-circuit xterm @@ -68,104 +28,15 @@ function kbdLog(tag: string, data?: unknown): void { // sidesteps this by suppressing all super/Cmd chords on macOS before the // encoder runs (ghostty/src/input/key_encode.zig:534-545). We do the same via // shouldBubbleClipboardShortcut's Mac branch. - -/** - * Mirror the running program's kitty progressive-enhancement flags so we can - * gate canonical CSI-u injection (Shift+Enter etc.) on the program having - * actually requested kitty mode. Matches how Ghostty / kitty / wezterm decide - * whether to encode CSI-u vs legacy — see ghostty/src/input/key_encode.zig:88 - * and kitty/key_encoding.c:153. - * - * xterm.js v6.1-beta has its own internal tracker but doesn't expose the - * active flags via public API, so we register our own CSI handlers alongside. - * Returning `false` passes the sequence to xterm.js's built-in handler. - */ -function createKittyFlagTracker(terminal: XTerm): () => number { - let flags = 0; - const stack: number[] = []; - - const numeric = (p: number | number[] | undefined, fallback: number) => { - if (typeof p === "number") return p; - if (Array.isArray(p) && typeof p[0] === "number") return p[0]; - return fallback; - }; - - terminal.parser.registerCsiHandler({ prefix: ">", final: "u" }, (params) => { - stack.push(flags); - flags = numeric(params[0], 1); - kbdLog("kitty-push", { flags, stackDepth: stack.length }); - return false; - }); - - terminal.parser.registerCsiHandler({ prefix: "=", final: "u" }, (params) => { - const next = numeric(params[0], 0); - const mode = numeric(params[1], 1); - if (mode === 1) flags = next; - else if (mode === 2) flags |= next; - else if (mode === 3) flags &= ~next; - kbdLog("kitty-set", { mode, next, flags }); - return false; - }); - - terminal.parser.registerCsiHandler({ prefix: "<", final: "u" }, (params) => { - const levels = numeric(params[0], 1); - for (let i = 0; i < levels; i++) flags = stack.pop() ?? 0; - kbdLog("kitty-pop", { levels, flags, stackDepth: stack.length }); - return false; - }); - - return () => flags; -} - -const KITTY_FLAG_DISAMBIGUATE = 0x01; -const KITTY_FLAG_REPORT_EVENTS = 0x02; - -/** - * Build the kitty CSI-u press sequence for Shift+Enter in the exact form the - * running program expects, based on the flags it has pushed. - * - * - With only disambiguate (0x01): "\x1b[13;2u" - * - With report-events (0x02) also active: "\x1b[13;2:1u" (explicit press - * event type). Observed empirically: xterm.js emits event-type-suffixed - * sequences when the program activates 0x02 (Escape release was - * "\x1b[27;1:3u" in the diagnostic trace), and claude-code's parser appears - * to require the explicit suffix when it has requested the event-type flag. - */ -function shiftEnterCsiU(flags: number): string { - if ((flags & KITTY_FLAG_REPORT_EVENTS) !== 0) return "\x1b[13;2:1u"; - return "\x1b[13;2u"; -} - -function createKeyEventHandler(terminal: XTerm, getKittyFlags: () => number) { +function createKeyEventHandler(terminal: XTerm) { const platform = typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : ""; const isMac = platform.includes("mac"); const isWindows = platform.includes("win"); return (event: KeyboardEvent): boolean => { - if (event.type === "keydown" || event.type === "keyup") { - const mods = - [ - event.metaKey && "Meta", - event.ctrlKey && "Ctrl", - event.altKey && "Alt", - event.shiftKey && "Shift", - ] - .filter(Boolean) - .join("+") || "none"; - kbdLog(event.type, { - key: event.key, - code: event.code, - mods, - kittyFlags: getKittyFlags(), - }); - } - if (resolveHotkeyFromEvent(event) !== null) return false; - // DIAGNOSTIC MODE: Shift+Enter override disabled so we observe xterm.js's - // raw kitty encoding. TODO reinstate once we know the right form. - const translation = translateLineEditChord(event, { isMac, isWindows }); if (translation !== null) { if (event.type === "keydown") { @@ -420,18 +291,7 @@ export function createRuntime( wrapper.style.height = "100%"; terminal.open(wrapper); - const getKittyFlags = createKittyFlagTracker(terminal); - terminal.attachCustomKeyEventHandler( - createKeyEventHandler(terminal, getKittyFlags), - ); - - terminal.onData((data) => { - kbdLog("onData", { - bytes: kbdHex(data), - length: data.length, - kittyFlags: getKittyFlags(), - }); - }); + terminal.attachCustomKeyEventHandler(createKeyEventHandler(terminal)); // Activate Unicode 11 widths (inside loadAddons) before restoring the buffer, // else CJK/emoji/ZWJ widths get baked wrong into the replay. (#3572) From 642803bc908547fc7ac929a108e914e144db30c7 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 23 Apr 2026 10:34:24 -0700 Subject: [PATCH 09/11] experiment(desktop): narrow Mac Cmd bubble back to VS Code-style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hypothesis: with TERM_PROGRAM=kitty claiming kitty in the claude-code / codex allowlist, kitty-aware TUIs parse CSI-u Cmd+chords correctly and don't show literal char garbage. The broader Ghostty-style "bubble every Mac Cmd chord" may have been overkill — needed only for TUIs that don't speak CSI-u. Revert the Mac branch of shouldBubbleClipboardShortcut to the original narrow rule (Cmd+V + Cmd+C-with-selection) to test. If kitty-aware TUIs stay clean AND non-kitty-aware ones (vim base, less, htop, tmux-without- kitty) are tolerable, we can ship simpler. Line-edit translators and Cmd+A select-all stay — those aren't kitty-specific, they're Mac-convention handling the shell expects regardless of kitty. --- .../Terminal/clipboardShortcuts.test.ts | 94 ++++--------------- .../Terminal/clipboardShortcuts.ts | 25 ++--- 2 files changed, 30 insertions(+), 89 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.test.ts index 80c9e02a9a3..0fff3a9b226 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.test.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.test.ts @@ -24,122 +24,68 @@ function makeEvent( } describe("shouldBubbleClipboardShortcut", () => { - it("bubbles every Mac Cmd chord, Ghostty-style", () => { + it("matches the VS Code terminal clipboard bindings", () => { const cases = [ { - name: "Cmd+C (no selection)", - event: makeEvent({ code: "KeyC", metaKey: true }), - }, - { name: "Cmd+V", event: makeEvent({ code: "KeyV", metaKey: true }) }, - { name: "Cmd+Enter", event: makeEvent({ code: "Enter", metaKey: true }) }, - { name: "Cmd+W", event: makeEvent({ code: "KeyW", metaKey: true }) }, - { - name: "Cmd+Shift+K", - event: makeEvent({ code: "KeyK", metaKey: true, shiftKey: true }), - }, - { - name: "Cmd+Alt+Left", - event: makeEvent({ code: "ArrowLeft", metaKey: true, altKey: true }), - }, - ]; - - for (const { name, event } of cases) { - expect( - shouldBubbleClipboardShortcut(event, { - isMac: true, - isWindows: false, - hasSelection: false, - }), - name, - ).toBe(true); - } - }); - - it("does not bubble non-Cmd chords on Mac", () => { - const cases = [ - { name: "plain c", event: makeEvent({ code: "KeyC" }) }, - { - name: "Ctrl+C (not a Mac idiom)", - event: makeEvent({ code: "KeyC", ctrlKey: true }), + name: "macOS Cmd+V", + event: makeEvent({ code: "KeyV", metaKey: true }), + options: { isMac: true, isWindows: false, hasSelection: false }, + expected: true, }, { - name: "Shift+Insert", - event: makeEvent({ code: "Insert", shiftKey: true }), + name: "macOS Cmd+C with selection", + event: makeEvent({ code: "KeyC", metaKey: true }), + options: { isMac: true, isWindows: false, hasSelection: true }, + expected: true, }, { - name: "Ctrl+Shift+V (linux chord on mac)", - event: makeEvent({ code: "KeyV", ctrlKey: true, shiftKey: true }), + name: "macOS Cmd+C without selection stays with the PTY", + event: makeEvent({ code: "KeyC", metaKey: true }), + options: { isMac: true, isWindows: false, hasSelection: false }, + expected: false, }, - ]; - - for (const { name, event } of cases) { - expect( - shouldBubbleClipboardShortcut(event, { - isMac: true, - isWindows: false, - hasSelection: false, - }), - name, - ).toBe(false); - } - }); - - it("matches standard Windows / Linux clipboard bindings", () => { - const cases = [ { - name: "Windows Ctrl+V", + name: "windows Ctrl+V", event: makeEvent({ code: "KeyV", ctrlKey: true }), options: { isMac: false, isWindows: true, hasSelection: false }, expected: true, }, { - name: "Windows Ctrl+Shift+V", + name: "windows Ctrl+Shift+V", event: makeEvent({ code: "KeyV", ctrlKey: true, shiftKey: true }), options: { isMac: false, isWindows: true, hasSelection: false }, expected: true, }, { - name: "Windows Ctrl+C with selection", + name: "windows Ctrl+C with selection", event: makeEvent({ code: "KeyC", ctrlKey: true }), options: { isMac: false, isWindows: true, hasSelection: true }, expected: true, }, { - name: "Windows Ctrl+C without selection stays with PTY (SIGINT)", + name: "windows Ctrl+C without selection stays with PTY (SIGINT)", event: makeEvent({ code: "KeyC", ctrlKey: true }), options: { isMac: false, isWindows: true, hasSelection: false }, expected: false, }, { - name: "Windows Ctrl+Shift+C without selection still bubbles", - event: makeEvent({ code: "KeyC", ctrlKey: true, shiftKey: true }), - options: { isMac: false, isWindows: true, hasSelection: false }, - expected: true, - }, - { - name: "Linux Ctrl+Shift+V", + name: "linux Ctrl+Shift+V", event: makeEvent({ code: "KeyV", ctrlKey: true, shiftKey: true }), options: { isMac: false, isWindows: false, hasSelection: false }, expected: true, }, { - name: "Linux Shift+Insert", + name: "linux Shift+Insert", event: makeEvent({ code: "Insert", shiftKey: true }), options: { isMac: false, isWindows: false, hasSelection: false }, expected: true, }, { - name: "Linux Ctrl+Shift+C without selection still bubbles", + name: "linux Ctrl+Shift+C without selection still bubbles", event: makeEvent({ code: "KeyC", ctrlKey: true, shiftKey: true }), options: { isMac: false, isWindows: false, hasSelection: false }, expected: true, }, - { - name: "Linux Ctrl+Insert stays with the PTY", - event: makeEvent({ code: "Insert", ctrlKey: true }), - options: { isMac: false, isWindows: false, hasSelection: false }, - expected: false, - }, ]; for (const { name, event, options, expected } of cases) { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.ts index 2e39a40a216..bef7f956a2e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.ts @@ -28,17 +28,10 @@ export function shouldSelectAllShortcut( } /** - * Decide whether a chord should bubble to the host (Electron menu accelerators, - * OS clipboard handlers, etc.) instead of reaching xterm's kitty encoder and - * leaking into the PTY as a CSI-u sequence. - * - * On macOS we follow Ghostty's rule (ghostty/src/input/key_encode.zig:534-545: - * "on macOS, command+keys do not encode text"): every Cmd chord bubbles. Specific - * chords the terminal wants to intercept (Cmd+Left/Right/Backspace, Cmd+A, etc.) - * must run before this check in the caller. - * - * Windows/Linux have standard copy/paste keybinds that bubble selectively: - * Ctrl+C only bubbles with a selection because it doubles as SIGINT. + * EXPERIMENT: narrow Mac rule back to just Cmd+V + Cmd+C-with-selection (the + * original VS Code-style bubble). Test whether TERM_PROGRAM=kitty alone is + * enough — claude-code/codex should parse CSI-u Cmd+chords correctly with the + * TERM_PROGRAM fix, so maybe we don't need the broad Ghostty rule. */ export function shouldBubbleClipboardShortcut( event: ClipboardShortcutEvent, @@ -46,10 +39,8 @@ export function shouldBubbleClipboardShortcut( ): boolean { const { isMac, isWindows, hasSelection } = options; - if (isMac) { - return event.metaKey; - } - + const onlyMeta = + event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey; const onlyCtrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey; const ctrlShiftOnly = @@ -57,6 +48,10 @@ export function shouldBubbleClipboardShortcut( const onlyShift = event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey; + if (isMac && onlyMeta) { + return event.code === "KeyV" || (hasSelection && event.code === "KeyC"); + } + if (isWindows) { if (event.code === "KeyV" && (onlyCtrl || ctrlShiftOnly)) return true; if (event.code === "KeyC" && ctrlShiftOnly) return true; From 94ab847e4217fb7857f90e79668d3826f7d1162f Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 23 Apr 2026 10:40:12 -0700 Subject: [PATCH 10/11] experiment(desktop): revert all renderer changes, keep only TERM_PROGRAM=kitty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggressive minimum — strip terminal-runtime.ts and clipboardShortcuts.ts back to main. The only net change from main becomes: env.TERM_PROGRAM = "kitty" (was "Superset") If claude-code's CSI-u parsing handles Cmd+C/V/Left/Right correctly with TERM_PROGRAM now in the allowlist, and shell fallback is acceptable for the rest, this is the true minimum fix. Things that may regress and be worth checking: - Cmd+Left / Cmd+Right / Cmd+Backspace in shell (no translator → xterm sends CSI-u → bash readline ignores → no cursor navigation) - Option+Left / Option+Right (same, word navigation won't work in shell) - Cmd+A in shell (no select-all handler → CSI-u to PTY) - Cmd+C in vim / less / htop (no broad bubble → CSI-u reaches PTY; if those TUIs don't speak kitty they'll show garbage) If any of those bite, we restore the specific piece(s). --- .../renderer/lib/terminal/terminal-runtime.ts | 155 +----------------- .../Terminal/clipboardShortcuts.test.ts | 48 ++++-- .../Terminal/clipboardShortcuts.ts | 31 ++-- 3 files changed, 62 insertions(+), 172 deletions(-) diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index c2056692791..afdecd5d701 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -4,10 +4,6 @@ import type { SearchAddon } from "@xterm/addon-search"; import { SerializeAddon } from "@xterm/addon-serialize"; import { Terminal as XTerm } from "@xterm/xterm"; import { resolveHotkeyFromEvent } from "renderer/hotkeys"; -import { - shouldBubbleClipboardShortcut, - shouldSelectAllShortcut, -} from "renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts"; import { DEFAULT_TERMINAL_SCROLLBACK } from "shared/constants"; import type { TerminalAppearance } from "./appearance"; import { loadAddons } from "./terminal-addons"; @@ -18,149 +14,12 @@ const DIMS_KEY_PREFIX = "terminal-dims:"; const DEFAULT_COLS = 120; const DEFAULT_ROWS = 32; -// xterm's _keyDown calls stopPropagation after processing, so any chord we -// want the host (react-hotkeys-hook, Electron menu accelerators) or the shell -// (Ctrl+A/E/U escape sequences for line edit) to see must short-circuit xterm -// before it runs. (VSCode pattern: terminalInstance.ts:1116-1175.) -// -// Kitty keyboard protocol is enabled, which means every Mac Cmd chord xterm -// sees gets CSI-u encoded and leaks into TUIs as a literal char. Ghostty -// sidesteps this by suppressing all super/Cmd chords on macOS before the -// encoder runs (ghostty/src/input/key_encode.zig:534-545). We do the same via -// shouldBubbleClipboardShortcut's Mac branch. -function createKeyEventHandler(terminal: XTerm) { - const platform = - typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : ""; - const isMac = platform.includes("mac"); - const isWindows = platform.includes("win"); - - return (event: KeyboardEvent): boolean => { - if (resolveHotkeyFromEvent(event) !== null) return false; - - const translation = translateLineEditChord(event, { isMac, isWindows }); - if (translation !== null) { - if (event.type === "keydown") { - event.preventDefault(); - terminal.input(translation, true); - } - return false; - } - - if (shouldSelectAllShortcut(event, isMac)) { - if (event.type === "keydown") { - event.preventDefault(); - terminal.selectAll(); - } - return false; - } - - if ( - shouldBubbleClipboardShortcut(event, { - isMac, - isWindows, - hasSelection: terminal.hasSelection(), - }) - ) { - return false; - } - - return true; - }; -} - -/** - * Translate Mac Cmd+/Option+ and Windows Ctrl+ arrow / backspace chords into - * the escape sequences shells expect. Returns the bytes to send, or null if - * this chord isn't a line-edit translation. - * - * Mirrors v1 helpers.ts:319-427. These translations only exist because xterm's - * default encoding (with kitty on) would send a CSI-u sequence that most - * shells don't map to line-edit commands. - */ -function translateLineEditChord( - event: KeyboardEvent, - options: { isMac: boolean; isWindows: boolean }, -): string | null { - const { isMac, isWindows } = options; - - if ( - isMac && - event.key === "Backspace" && - event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey - ) { - return "\x15\x1b[D"; - } - - if ( - isMac && - event.key === "ArrowLeft" && - event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey - ) { - return "\x01"; - } - - if ( - isMac && - event.key === "ArrowRight" && - event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey - ) { - return "\x05"; - } - - if ( - isMac && - event.key === "ArrowLeft" && - event.altKey && - !event.metaKey && - !event.ctrlKey && - !event.shiftKey - ) { - return "\x1bb"; - } - - if ( - isMac && - event.key === "ArrowRight" && - event.altKey && - !event.metaKey && - !event.ctrlKey && - !event.shiftKey - ) { - return "\x1bf"; - } - - if ( - isWindows && - event.key === "ArrowLeft" && - event.ctrlKey && - !event.metaKey && - !event.altKey && - !event.shiftKey - ) { - return "\x1bb"; - } - - if ( - isWindows && - event.key === "ArrowRight" && - event.ctrlKey && - !event.metaKey && - !event.altKey && - !event.shiftKey - ) { - return "\x1bf"; - } - - return null; +// xterm's _keyDown calls stopPropagation after processing, which kills the +// bubble to react-hotkeys-hook. Returning false from the custom handler makes +// xterm bail before that, so app hotkeys reach document. (VSCode pattern: +// terminalInstance.ts:1116-1175) +function isAppHotkey(event: KeyboardEvent): boolean { + return resolveHotkeyFromEvent(event) !== null; } export interface TerminalRuntime { @@ -291,7 +150,7 @@ export function createRuntime( wrapper.style.height = "100%"; terminal.open(wrapper); - terminal.attachCustomKeyEventHandler(createKeyEventHandler(terminal)); + terminal.attachCustomKeyEventHandler((event) => !isAppHotkey(event)); // Activate Unicode 11 widths (inside loadAddons) before restoring the buffer, // else CJK/emoji/ZWJ widths get baked wrong into the replay. (#3572) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.test.ts index 0fff3a9b226..ba7e5469b70 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.test.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.test.ts @@ -38,12 +38,6 @@ describe("shouldBubbleClipboardShortcut", () => { options: { isMac: true, isWindows: false, hasSelection: true }, expected: true, }, - { - name: "macOS Cmd+C without selection stays with the PTY", - event: makeEvent({ code: "KeyC", metaKey: true }), - options: { isMac: true, isWindows: false, hasSelection: false }, - expected: false, - }, { name: "windows Ctrl+V", event: makeEvent({ code: "KeyV", ctrlKey: true }), @@ -63,10 +57,10 @@ describe("shouldBubbleClipboardShortcut", () => { expected: true, }, { - name: "windows Ctrl+C without selection stays with PTY (SIGINT)", - event: makeEvent({ code: "KeyC", ctrlKey: true }), - options: { isMac: false, isWindows: true, hasSelection: false }, - expected: false, + name: "linux Ctrl+Shift+C with selection", + event: makeEvent({ code: "KeyC", ctrlKey: true, shiftKey: true }), + options: { isMac: false, isWindows: false, hasSelection: true }, + expected: true, }, { name: "linux Ctrl+Shift+V", @@ -81,10 +75,40 @@ describe("shouldBubbleClipboardShortcut", () => { expected: true, }, { - name: "linux Ctrl+Shift+C without selection still bubbles", + name: "macOS Cmd+C without selection", + event: makeEvent({ code: "KeyC", metaKey: true }), + options: { isMac: true, isWindows: false, hasSelection: false }, + expected: false, + }, + { + name: "windows Ctrl+C without selection", + event: makeEvent({ code: "KeyC", ctrlKey: true }), + options: { isMac: false, isWindows: true, hasSelection: false }, + expected: false, + }, + { + name: "linux Ctrl+Shift+C without selection", event: makeEvent({ code: "KeyC", ctrlKey: true, shiftKey: true }), options: { isMac: false, isWindows: false, hasSelection: false }, - expected: true, + expected: false, + }, + { + name: "linux Ctrl+Insert stays with the PTY", + event: makeEvent({ code: "Insert", ctrlKey: true }), + options: { isMac: false, isWindows: false, hasSelection: false }, + expected: false, + }, + { + name: "macOS does not inherit linux fallback chords", + event: makeEvent({ code: "KeyV", ctrlKey: true, shiftKey: true }), + options: { isMac: true, isWindows: false, hasSelection: false }, + expected: false, + }, + { + name: "macOS Shift+Insert stays with the PTY", + event: makeEvent({ code: "Insert", shiftKey: true }), + options: { isMac: true, isWindows: false, hasSelection: false }, + expected: false, }, ]; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.ts index bef7f956a2e..d819afc1252 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/clipboardShortcuts.ts @@ -28,10 +28,8 @@ export function shouldSelectAllShortcut( } /** - * EXPERIMENT: narrow Mac rule back to just Cmd+V + Cmd+C-with-selection (the - * original VS Code-style bubble). Test whether TERM_PROGRAM=kitty alone is - * enough — claude-code/codex should parse CSI-u Cmd+chords correctly with the - * TERM_PROGRAM fix, so maybe we don't need the broad Ghostty rule. + * Mirror VS Code terminal clipboard bindings so host copy/paste can run before + * xterm's kitty keyboard handler turns the chord into CSI-u input. */ export function shouldBubbleClipboardShortcut( event: ClipboardShortcutEvent, @@ -53,15 +51,24 @@ export function shouldBubbleClipboardShortcut( } if (isWindows) { - if (event.code === "KeyV" && (onlyCtrl || ctrlShiftOnly)) return true; - if (event.code === "KeyC" && ctrlShiftOnly) return true; - if (event.code === "KeyC" && onlyCtrl && hasSelection) return true; + if (event.code === "KeyV" && (onlyCtrl || ctrlShiftOnly)) { + return true; + } + + if (hasSelection && event.code === "KeyC" && (onlyCtrl || ctrlShiftOnly)) { + return true; + } + return false; } - return ( - (event.code === "KeyV" && ctrlShiftOnly) || - (event.code === "Insert" && onlyShift) || - (event.code === "KeyC" && ctrlShiftOnly) - ); + if (!isMac) { + return ( + (event.code === "KeyV" && ctrlShiftOnly) || + (event.code === "Insert" && onlyShift) || + (hasSelection && event.code === "KeyC" && ctrlShiftOnly) + ); + } + + return false; } From d55786dc0db73a4d783d5688ca03b95bf877e0a4 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 23 Apr 2026 10:42:03 -0700 Subject: [PATCH 11/11] chore(host-service): trim TERM_PROGRAM comment to essentials --- packages/host-service/src/terminal/env.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/host-service/src/terminal/env.ts b/packages/host-service/src/terminal/env.ts index f8221cc2135..247a035f904 100644 --- a/packages/host-service/src/terminal/env.ts +++ b/packages/host-service/src/terminal/env.ts @@ -144,14 +144,10 @@ export function buildV2TerminalEnv( Object.assign(env, getShellBootstrapEnv({ shell, baseEnv, supersetHomeDir })); env.TERM = "xterm-256color"; - // claude-code (and similar chat TUIs) gate kitty keyboard CSI-u parsing on - // TERM_PROGRAM matching a small allowlist — {ghostty, kitty, iTerm.app, - // WezTerm, WarpTerminal}. xterm.js v6 with `vtExtensions.kittyKeyboard` DOES - // emit the correct CSI-u bytes (Shift+Enter → \x1b[13;2u press + - // \x1b[13;2:3u release, same as Ghostty, verified via diagnostic trace), but - // claude-code's bundled parser ignores them unless TERM_PROGRAM is in the - // allowlist. Claim kitty — the protocol origin, with fewer version-gated - // branches downstream than claiming ghostty/iTerm. + // claude-code and similar chat TUIs only parse kitty CSI-u (e.g. Shift+Enter + // → \x1b[13;2u) when TERM_PROGRAM ∈ {ghostty, kitty, iTerm.app, WezTerm, + // WarpTerminal}. xterm.js already emits the right bytes — claim kitty so + // they're parsed instead of submitted as plain Enter. env.TERM_PROGRAM = "kitty"; env.TERM_PROGRAM_VERSION = hostServiceVersion; env.COLORTERM = "truecolor";