From 65cab0f5da1dae46eaa6bd92a562afa26d956297 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 30 May 2026 14:41:09 -0700 Subject: [PATCH] fix terminal kitty recovery for codex (#3941) --- .../terminal-pane/layout-serialization.ts | 15 +- .../terminal-pane/pty-connection.test.ts | 60 +++++ .../terminal-pane/pty-connection.ts | 22 +- .../terminal-replay-cursor-state.test.ts | 89 ++++++- .../use-terminal-pane-lifecycle.ts | 39 ++- .../xterm-bypass-policy-interrupt.test.ts | 146 +++++++++++ .../xterm-bypass-policy-non-mac.test.ts | 157 ++++++++++++ .../terminal-pane/xterm-bypass-policy.test.ts | 141 ----------- .../terminal-pane/xterm-bypass-policy.ts | 53 ++++ .../terminal-complex-script.test.ts | 13 + .../pane-manager/terminal-complex-script.ts | 63 ++++- tests/e2e/terminal-shortcuts.spec.ts | 229 ++++++++++++++++++ 12 files changed, 874 insertions(+), 153 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts create mode 100644 src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index e5b653a57..a064880e7 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -42,13 +42,20 @@ export const EMPTY_LAYOUT: TerminalLayoutSnapshot = { // 1000/1002/1003/1006 — mouse reporting variants // 1004 — focus event reporting (the actual bug source) // 2004 — bracketed paste +// <99u/=0u — Kitty keyboard flags pushed by TUIs such as Codex export const RESET_TERMINAL_CURSOR_STYLE = '\x1b[0 q' +export const RESET_KITTY_KEYBOARD_PROTOCOL = '\x1b[<99u\x1b[=0u' -export const POST_REPLAY_MODE_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1004l\x1b[?1006l\x1b[?2004l` +export const POST_REPLAY_MODE_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1004l\x1b[?1006l\x1b[?2004l` + +// Why: hidden-output recovery replays a snapshot of the same live renderer +// session. Keep cursor/focus cleanup, but preserve Kitty keyboard flags that +// the still-running foreground TUI may rely on. +export const POST_REPLAY_LIVE_SNAPSHOT_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h\x1b[?1004l` // Why: daemon snapshot restore reattaches to a live session, so we avoid the // full POST_REPLAY_MODE_RESET bundle there — a still-running TUI may still -// rely on mouse or bracketed-paste modes. Three exceptions are safe to reset: +// rely on mouse or bracketed-paste modes. Four exceptions are safe to reset: // // 0 q — DECSCUSR cursor style/blink reset: raw replay can contain a stale // steady cursor override, while SerializeAddon does not preserve an @@ -64,7 +71,9 @@ export const POST_REPLAY_MODE_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h\x // 1004 — focus event reporting: preserving `?1004h` makes restored shells // ring BEL on pane focus/blur (shells like zsh treat `\e[I`/`\e[O` // as unbound key input). -export const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x1b[?25h\x1b[?1004l` +// <99u/=0u — Kitty keyboard mode is renderer-side xterm state; stale copies +// can make the next Ctrl+C encode as CSI-u after reattach. +export const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h\x1b[?1004l` // Cross-platform monospace fallback chain ensures the terminal always has a // usable font regardless of OS. macOS-only fonts like SF Mono and Menlo are diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 1449bfe36..b48dd9ab7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -2,6 +2,7 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + POST_REPLAY_LIVE_SNAPSHOT_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, RESET_TERMINAL_CURSOR_STYLE @@ -2742,6 +2743,14 @@ describe('connectPanePty', () => { 'hidden while paused\r\n', expect.any(Function) ) + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_LIVE_SNAPSHOT_RESET, + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + POST_REPLAY_REATTACH_RESET, + expect.any(Function) + ) binding.dispose() }) @@ -3387,6 +3396,57 @@ describe('connectPanePty', () => { ) }) + it('marks panes for DOM rendering when background SGR is split across PTY chunks', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[48') + expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() + + capturedDataCallback.current?.(';2;52;52;52m codex block \x1b[0m\r\n') + + expect(manager.markPaneHasComplexScriptOutput).toHaveBeenCalledWith(1) + }) + + it('keeps renderer-risk scan state across more than two split PTY chunks', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.('\x1b[4') + capturedDataCallback.current?.('8;2;52') + expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() + + capturedDataCallback.current?.(';52;52m codex block \x1b[0m\r\n') + + expect(manager.markPaneHasComplexScriptOutput).toHaveBeenCalledWith(1) + }) + it('keeps panes on WebGL for terminal UI drawing glyphs', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index a6cf07294..2dafe7ec9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -20,6 +20,7 @@ import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' import { isPaneReplaying, replayIntoTerminal } from './replay-guard' import { terminalOutputPrefersDomRenderer } from '@/lib/pane-manager/terminal-complex-script' import { + POST_REPLAY_LIVE_SNAPSHOT_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, RESET_TERMINAL_CURSOR_STYLE @@ -81,6 +82,7 @@ const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000 const HIDDEN_OUTPUT_RESTORE_PENDING_CHARS = 512 * 1024 const HIDDEN_STARTUP_RENDERER_QUERY_WINDOW_MS = 10_000 const STARTUP_COMMAND_EXTENSION_RE = /\.(?:exe|cmd|bat|ps1)$/i +const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256 // Why: this is only shown if renderer backlog overflowed and main-owned // terminal state is unavailable, so the user has an explicit loss signal. const HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING = @@ -1386,6 +1388,19 @@ export function connectPanePty( pendingSpawnByPaneKey.set(pendingSpawnKey, trackedPromise) } + let rendererRiskScanTail = '' + + function terminalOutputChunkPrefersDomRenderer(data: string): boolean { + if (!data) { + return false + } + // Why: PTY chunk boundaries can split ASCII SGR sequences; keep a small + // tail so Codex background-color redraws still trigger the DOM fallback. + const scanData = rendererRiskScanTail ? `${rendererRiskScanTail}${data}` : data + rendererRiskScanTail = scanData.slice(-TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS) + return terminalOutputPrefersDomRenderer(scanData) + } + // The replay path uses the guard so xterm auto-replies to embedded query // sequences don't leak into the shell. xterm.write() buffers internally // regardless of DOM visibility and the guard stays engaged via the @@ -1394,7 +1409,7 @@ export function connectPanePty( // Why: drain any queued background bytes BEFORE the replay paint, so the // scheduler's deferred drain cannot land older bytes on top of the replay. flushTerminalOutput(pane.terminal) - if (terminalOutputPrefersDomRenderer(data)) { + if (terminalOutputChunkPrefersDomRenderer(data)) { manager.markPaneHasComplexScriptOutput(pane.id) } replayIntoTerminal(pane, deps.replayingPanesRef, data) @@ -1406,6 +1421,7 @@ export function connectPanePty( // disconnect. Clear first to prevent duplication on SSH reconnect. writeReplayData('\x1b[2J\x1b[3J\x1b[H') writeReplayData(data) + writeReplayData(POST_REPLAY_REATTACH_RESET) } type PendingHiddenOutputRestoreChunk = { @@ -1522,7 +1538,7 @@ export function connectPanePty( // Why: hidden tab output is coalesced by the scheduler. Run per-byte // renderer checks at the xterm write boundary so background PTY bursts // do not spend foreground event-loop time scanning bytes we will delay. - if (terminalOutputPrefersDomRenderer(chunk)) { + if (terminalOutputChunkPrefersDomRenderer(chunk)) { manager.markPaneHasComplexScriptOutput(pane.id) } recordTerminalOutput(pane.terminal) @@ -1748,7 +1764,7 @@ export function connectPanePty( } writeReplayData('\x1b[2J\x1b[3J\x1b[H') writeReplayData(snapshot.data) - writeReplayData(POST_REPLAY_REATTACH_RESET) + writeReplayData(POST_REPLAY_LIVE_SNAPSHOT_RESET) recordTerminalOutput(pane.terminal) const currentPtyId = transport.getPtyId() if (currentPtyId && !getFitOverrideForPty(currentPtyId)) { diff --git a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts index 7f132f46f..37a3f2ab6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-replay-cursor-state.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' import { Terminal } from '@xterm/headless' -import { POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET } from './layout-serialization' +import { + POST_REPLAY_LIVE_SNAPSHOT_RESET, + POST_REPLAY_MODE_RESET, + POST_REPLAY_REATTACH_RESET, + RESET_KITTY_KEYBOARD_PROTOCOL +} from './layout-serialization' const OLD_REATTACH_RESET_WITHOUT_CURSOR_STYLE = '\x1b[?25h\x1b[?1004l' @@ -9,13 +14,23 @@ type DecPrivateCursorState = { cursorBlink?: boolean } +type KittyKeyboardState = { + flags: number + mainFlags: number + altFlags: number + mainStack: number[] + altStack: number[] +} + type XtermWithCoreService = Terminal & { _core?: { coreService?: { decPrivateModes?: DecPrivateCursorState + kittyKeyboard?: KittyKeyboardState } _coreService?: { decPrivateModes?: DecPrivateCursorState + kittyKeyboard?: KittyKeyboardState } } } @@ -26,11 +41,32 @@ function readDecPrivateCursorState(term: Terminal): DecPrivateCursorState { return cursorState ? { ...cursorState } : {} } +function readKittyKeyboardState(term: Terminal): KittyKeyboardState | null { + const core = (term as XtermWithCoreService)._core + const keyboardState = core?.coreService?.kittyKeyboard ?? core?._coreService?.kittyKeyboard + return keyboardState + ? { + flags: keyboardState.flags, + mainFlags: keyboardState.mainFlags, + altFlags: keyboardState.altFlags, + mainStack: [...keyboardState.mainStack], + altStack: [...keyboardState.altStack] + } + : null +} + function writeTerminal(term: Terminal, data: string): Promise { return new Promise((resolve) => term.write(data, resolve)) } -describe('terminal replay cursor state reset', () => { +describe('terminal replay state reset', () => { + it('includes Kitty keyboard protocol reset in replay reset bundles', () => { + expect(RESET_KITTY_KEYBOARD_PROTOCOL).toBe('\x1b[<99u\x1b[=0u') + expect(POST_REPLAY_MODE_RESET).toContain(RESET_KITTY_KEYBOARD_PROTOCOL) + expect(POST_REPLAY_REATTACH_RESET).toContain(RESET_KITTY_KEYBOARD_PROTOCOL) + expect(POST_REPLAY_LIVE_SNAPSHOT_RESET).not.toContain(RESET_KITTY_KEYBOARD_PROTOCOL) + }) + it('clears stale DECSCUSR cursor overrides after live reattach replay', async () => { const term = new Terminal({ cols: 80, @@ -86,4 +122,53 @@ describe('terminal replay cursor state reset', () => { term.dispose() } }) + + it('clears active-buffer Kitty keyboard state after live reattach replay', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true, + vtExtensions: { kittyKeyboard: true } + }) + + try { + await writeTerminal(term, '\x1b[=31u\x1b[>15u') + expect(readKittyKeyboardState(term)).toMatchObject({ + flags: 15, + mainStack: [31] + }) + + await writeTerminal(term, POST_REPLAY_REATTACH_RESET) + // Why: after renderer reattach, the next Ctrl+C must not inherit a stale + // Kitty CSI-u encoder state from the replayed TUI snapshot. + expect(readKittyKeyboardState(term)).toMatchObject({ + flags: 0, + mainFlags: 0, + mainStack: [] + }) + } finally { + term.dispose() + } + }) + + it('preserves active-buffer Kitty keyboard state after hidden-output snapshot replay', async () => { + const term = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true, + vtExtensions: { kittyKeyboard: true } + }) + + try { + await writeTerminal(term, '\x1b[=31u\x1b[>15u') + await writeTerminal(term, POST_REPLAY_LIVE_SNAPSHOT_RESET) + + expect(readKittyKeyboardState(term)).toMatchObject({ + flags: 15, + mainStack: [31] + }) + } finally { + term.dispose() + } + }) }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 2c333897e..bf3ee902a 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -25,6 +25,7 @@ import { resolveTerminalFontWeights } from '../../../../shared/terminal-fonts' import { buildFontFamily, normalizeTerminalLayoutSnapshot, + RESET_KITTY_KEYBOARD_PROTOCOL, replayTerminalLayout, restoreScrollbackBuffers } from './layout-serialization' @@ -39,7 +40,13 @@ import { handleOsc52ClipboardRequest } from './osc52-clipboard' import { showOsc52ClipboardBlockedToast } from './osc52-clipboard-blocked-toast' import { parseOsc7 } from './parse-osc7' import { resolveTerminalJisYenInput } from './terminal-jis-yen-input' -import { shouldBypassXtermKeyboardEvent } from './xterm-bypass-policy' +import { + shouldBypassXtermKeyboardEvent, + shouldHandleTerminalInterruptKeyboardEvent, + shouldSuppressTerminalInterruptKeyup, + shouldSuppressTerminalModifierKeyboardEvent, + TERMINAL_INTERRUPT_INPUT +} from './xterm-bypass-policy' import type { PaneCwdMap } from './resolve-split-cwd' import { installMouseHideWhileTyping } from './mouse-hide-while-typing' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' @@ -542,8 +549,38 @@ export function useTerminalPaneLifecycle({ // bypassed press. Returning false here short-circuits xterm before the // encoder runs, letting the browser and Electron paths fire normally. // See xterm-bypass-policy.ts for the rule derivation. + let pendingTerminalInterruptKeyup = false pane.terminal.attachCustomKeyEventHandler((e) => { const isMac = navigator.userAgent.includes('Mac') + if (pendingTerminalInterruptKeyup && shouldSuppressTerminalInterruptKeyup(e)) { + pendingTerminalInterruptKeyup = false + return false + } + if ( + shouldHandleTerminalInterruptKeyboardEvent(e, { + isMac, + hasSelection: pane.terminal.hasSelection() + }) + ) { + if (e.type === 'keydown') { + // Why: xterm's kitty encoder can turn plain Ctrl+C into CSI-u; + // ETX must stay transport-agnostic through the existing onData path. + pendingTerminalInterruptKeyup = true + pane.terminal.input(TERMINAL_INTERRUPT_INPUT) + // Why: CLIs such as Codex can die on SIGINT before restoring + // xterm's renderer-side Kitty flags, leaving the shell corrupted. + pane.terminal.write(RESET_KITTY_KEYBOARD_PROTOCOL) + } else { + pendingTerminalInterruptKeyup = false + } + return false + } + if (shouldSuppressTerminalModifierKeyboardEvent(e)) { + // Why: stale Kitty keyboard reporting can encode standalone + // modifier presses before Ctrl+C reaches the interrupt handler. + return false + } + const jisYenInput = resolveTerminalJisYenInput(e, { enabled: settingsRef.current?.terminalJISYenToBackslash === true, isMac diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts new file mode 100644 index 000000000..4223ccf13 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-interrupt.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' +import { + shouldHandleTerminalInterruptKeyboardEvent, + shouldSuppressTerminalInterruptKeyup, + shouldSuppressTerminalModifierKeyboardEvent, + TERMINAL_INTERRUPT_INPUT, + type XtermBypassEvent +} from './xterm-bypass-policy' + +function event(overrides: Partial): XtermBypassEvent { + return { + type: 'keydown', + key: '', + code: '', + defaultPrevented: false, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...overrides + } +} + +describe('shouldHandleTerminalInterruptKeyboardEvent', () => { + it('exports the ETX byte used for terminal interrupts', () => { + expect(TERMINAL_INTERRUPT_INPUT).toBe('\x03') + }) + + it('handles macOS Ctrl+C as terminal interrupt even with a selection', () => { + expect( + shouldHandleTerminalInterruptKeyboardEvent(event({ key: 'c', code: 'KeyC', ctrlKey: true }), { + isMac: true, + hasSelection: true + }) + ).toBe(true) + }) + + it('does not handle macOS Cmd+C so host copy can bypass xterm', () => { + expect( + shouldHandleTerminalInterruptKeyboardEvent(event({ key: 'c', code: 'KeyC', metaKey: true }), { + isMac: true, + hasSelection: true + }) + ).toBe(false) + }) + + it('handles non-Mac Ctrl+C only when there is no selection', () => { + expect( + shouldHandleTerminalInterruptKeyboardEvent(event({ key: 'c', code: 'KeyC', ctrlKey: true }), { + isMac: false, + hasSelection: false + }) + ).toBe(true) + expect( + shouldHandleTerminalInterruptKeyboardEvent(event({ key: 'c', code: 'KeyC', ctrlKey: true }), { + isMac: false, + hasSelection: true + }) + ).toBe(false) + }) + + it('handles matching Ctrl+C keyup so kitty release sequences do not leak', () => { + expect( + shouldHandleTerminalInterruptKeyboardEvent( + event({ type: 'keyup', key: 'c', code: 'KeyC', ctrlKey: true }), + { isMac: false, hasSelection: false } + ) + ).toBe(true) + }) + + it('suppresses handled Ctrl+C keyup even after Ctrl was released first', () => { + expect( + shouldSuppressTerminalInterruptKeyup(event({ type: 'keyup', key: 'c', code: 'KeyC' })) + ).toBe(true) + expect( + shouldSuppressTerminalInterruptKeyup( + event({ type: 'keyup', key: 'j', code: 'KeyC', keyCode: 67 }) + ) + ).toBe(false) + }) + + it('handles Ctrl+C by physical key metadata when the logical key is unavailable', () => { + expect( + shouldHandleTerminalInterruptKeyboardEvent(event({ key: '', code: 'KeyC', ctrlKey: true }), { + isMac: false, + hasSelection: false + }) + ).toBe(true) + expect( + shouldHandleTerminalInterruptKeyboardEvent( + event({ key: 'Unidentified', keyCode: 67, ctrlKey: true }), + { isMac: true, hasSelection: false } + ) + ).toBe(true) + }) + + it('does not handle physical KeyC when the logical key is a different letter', () => { + expect( + shouldHandleTerminalInterruptKeyboardEvent(event({ key: 'j', code: 'KeyC', ctrlKey: true }), { + isMac: false, + hasSelection: false + }) + ).toBe(false) + }) + + it('does not handle modified Ctrl+C chords', () => { + expect( + shouldHandleTerminalInterruptKeyboardEvent( + event({ key: 'C', code: 'KeyC', ctrlKey: true, shiftKey: true }), + { isMac: false, hasSelection: false } + ) + ).toBe(false) + expect( + shouldHandleTerminalInterruptKeyboardEvent( + event({ key: 'c', code: 'KeyC', ctrlKey: true, altKey: true }), + { isMac: true, hasSelection: false } + ) + ).toBe(false) + }) +}) + +describe('shouldSuppressTerminalModifierKeyboardEvent', () => { + it('suppresses standalone modifier events before Kitty can encode them', () => { + expect( + shouldSuppressTerminalModifierKeyboardEvent( + event({ type: 'keydown', key: 'Control', code: 'ControlLeft', ctrlKey: true }) + ) + ).toBe(true) + expect( + shouldSuppressTerminalModifierKeyboardEvent( + event({ type: 'keyup', key: 'Meta', code: 'MetaLeft', metaKey: false }) + ) + ).toBe(true) + }) + + it('does not suppress non-modifier keyboard input', () => { + expect( + shouldSuppressTerminalModifierKeyboardEvent( + event({ type: 'keydown', key: 'c', code: 'KeyC', ctrlKey: true }) + ) + ).toBe(false) + expect(shouldSuppressTerminalModifierKeyboardEvent(event({ type: 'keypress', key: 'c' }))).toBe( + false + ) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts new file mode 100644 index 000000000..48f7caa65 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { shouldBypassXtermKeyboardEvent, type XtermBypassEvent } from './xterm-bypass-policy' + +function event(overrides: Partial): XtermBypassEvent { + return { + type: 'keydown', + key: '', + code: '', + defaultPrevented: false, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...overrides + } +} + +describe('shouldBypassXtermKeyboardEvent — Windows/Linux', () => { + const withSel = { isMac: false, hasSelection: true } + const noSel = { isMac: false, hasSelection: false } + + it('bubbles Ctrl+Shift+C (standard terminal copy on Linux/Windows)', () => { + expect( + shouldBypassXtermKeyboardEvent( + event({ key: 'C', code: 'KeyC', ctrlKey: true, shiftKey: true }), + noSel + ) + ).toBe(true) + }) + + it('matches Ctrl+Shift+C by produced logical key rather than physical key', () => { + expect( + shouldBypassXtermKeyboardEvent( + event({ key: 'C', code: 'KeyJ', ctrlKey: true, shiftKey: true }), + noSel + ) + ).toBe(true) + expect( + shouldBypassXtermKeyboardEvent( + event({ key: 'J', code: 'KeyC', ctrlKey: true, shiftKey: true }), + noSel + ) + ).toBe(false) + }) + + it('bubbles Ctrl+C only when there is a selection (otherwise SIGINT)', () => { + // Why: bare Ctrl+C without a selection must reach the shell as SIGINT. + // With a selection, terminals like Windows Terminal copy instead. + expect( + shouldBypassXtermKeyboardEvent(event({ key: 'c', code: 'KeyC', ctrlKey: true }), withSel) + ).toBe(true) + expect( + shouldBypassXtermKeyboardEvent(event({ key: 'c', code: 'KeyC', ctrlKey: true }), noSel) + ).toBe(false) + }) + + it('matches Ctrl+C with selection by produced logical key rather than physical key', () => { + expect( + shouldBypassXtermKeyboardEvent(event({ key: 'c', code: 'KeyJ', ctrlKey: true }), withSel) + ).toBe(true) + expect( + shouldBypassXtermKeyboardEvent(event({ key: 'j', code: 'KeyC', ctrlKey: true }), withSel) + ).toBe(false) + expect( + shouldBypassXtermKeyboardEvent(event({ key: 'c', code: 'KeyJ', ctrlKey: true }), noSel) + ).toBe(false) + }) + + it('bubbles Ctrl+V and Ctrl+Shift+V for paste', () => { + expect( + shouldBypassXtermKeyboardEvent(event({ key: 'v', code: 'KeyV', ctrlKey: true }), noSel) + ).toBe(true) + expect( + shouldBypassXtermKeyboardEvent( + event({ key: 'V', code: 'KeyV', ctrlKey: true, shiftKey: true }), + noSel + ) + ).toBe(true) + }) + + it('matches paste by produced logical key rather than physical key', () => { + expect( + shouldBypassXtermKeyboardEvent(event({ key: 'v', code: 'KeyK', ctrlKey: true }), noSel) + ).toBe(true) + expect( + shouldBypassXtermKeyboardEvent(event({ key: 'k', code: 'KeyV', ctrlKey: true }), noSel) + ).toBe(false) + }) + + it('bubbles Shift+Insert (X11/Linux paste convention)', () => { + expect( + shouldBypassXtermKeyboardEvent( + event({ key: 'Insert', code: 'Insert', shiftKey: true }), + noSel + ) + ).toBe(true) + }) + + it('does not bubble plain Ctrl letter chords — shell shortcuts must reach PTY', () => { + // Ctrl+A, Ctrl+E, Ctrl+U, Ctrl+R, Ctrl+L — all readline-critical. + for (const keyCode of ['a', 'e', 'u', 'r', 'l']) { + expect( + shouldBypassXtermKeyboardEvent( + event({ key: keyCode, code: `Key${keyCode.toUpperCase()}`, ctrlKey: true }), + noSel + ) + ).toBe(false) + } + }) + + it('bubbles already-handled Ctrl app shortcuts so kitty does not also write to shell', () => { + expect( + shouldBypassXtermKeyboardEvent( + event({ key: 'b', code: 'KeyB', defaultPrevented: true, ctrlKey: true }), + noSel + ) + ).toBe(true) + expect( + shouldBypassXtermKeyboardEvent( + event({ + key: 'ArrowLeft', + code: 'ArrowLeft', + defaultPrevented: true, + ctrlKey: true, + altKey: true + }), + noSel + ) + ).toBe(true) + }) + + it('does not bubble plain letters', () => { + expect(shouldBypassXtermKeyboardEvent(event({ key: 'c', code: 'KeyC' }), noSel)).toBe(false) + }) + + it('bubbles Shift+non-ASCII printable text so the active keyboard layout wins', () => { + expect( + shouldBypassXtermKeyboardEvent(event({ key: 'Ф', code: 'KeyA', shiftKey: true }), noSel) + ).toBe(true) + expect( + shouldBypassXtermKeyboardEvent( + event({ type: 'keyup', key: 'Ф', code: 'KeyA', shiftKey: true }), + noSel + ) + ).toBe(true) + }) + + it('does not bubble unshifted non-ASCII printable text', () => { + expect(shouldBypassXtermKeyboardEvent(event({ key: 'ф', code: 'KeyA' }), noSel)).toBe(false) + }) + + it('does not bubble Cmd chords on non-Mac (Super+C has no clipboard meaning there)', () => { + expect( + shouldBypassXtermKeyboardEvent(event({ key: 'c', code: 'KeyC', metaKey: true }), noSel) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts index 889861aed..5066aa564 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts @@ -155,144 +155,3 @@ describe('shouldBypassXtermKeyboardEvent — macOS', () => { ).toBe(false) }) }) - -describe('shouldBypassXtermKeyboardEvent — Windows/Linux', () => { - const withSel = { isMac: false, hasSelection: true } - const noSel = { isMac: false, hasSelection: false } - - it('bubbles Ctrl+Shift+C (standard terminal copy on Linux/Windows)', () => { - expect( - shouldBypassXtermKeyboardEvent( - event({ key: 'C', code: 'KeyC', ctrlKey: true, shiftKey: true }), - noSel - ) - ).toBe(true) - }) - - it('matches Ctrl+Shift+C by produced logical key rather than physical key', () => { - expect( - shouldBypassXtermKeyboardEvent( - event({ key: 'C', code: 'KeyJ', ctrlKey: true, shiftKey: true }), - noSel - ) - ).toBe(true) - expect( - shouldBypassXtermKeyboardEvent( - event({ key: 'J', code: 'KeyC', ctrlKey: true, shiftKey: true }), - noSel - ) - ).toBe(false) - }) - - it('bubbles Ctrl+C only when there is a selection (otherwise SIGINT)', () => { - // Why: bare Ctrl+C without a selection must reach the shell as SIGINT. - // With a selection, terminals like Windows Terminal copy instead. - expect( - shouldBypassXtermKeyboardEvent(event({ key: 'c', code: 'KeyC', ctrlKey: true }), withSel) - ).toBe(true) - expect( - shouldBypassXtermKeyboardEvent(event({ key: 'c', code: 'KeyC', ctrlKey: true }), noSel) - ).toBe(false) - }) - - it('matches Ctrl+C with selection by produced logical key rather than physical key', () => { - expect( - shouldBypassXtermKeyboardEvent(event({ key: 'c', code: 'KeyJ', ctrlKey: true }), withSel) - ).toBe(true) - expect( - shouldBypassXtermKeyboardEvent(event({ key: 'j', code: 'KeyC', ctrlKey: true }), withSel) - ).toBe(false) - expect( - shouldBypassXtermKeyboardEvent(event({ key: 'c', code: 'KeyJ', ctrlKey: true }), noSel) - ).toBe(false) - }) - - it('bubbles Ctrl+V and Ctrl+Shift+V for paste', () => { - expect( - shouldBypassXtermKeyboardEvent(event({ key: 'v', code: 'KeyV', ctrlKey: true }), noSel) - ).toBe(true) - expect( - shouldBypassXtermKeyboardEvent( - event({ key: 'V', code: 'KeyV', ctrlKey: true, shiftKey: true }), - noSel - ) - ).toBe(true) - }) - - it('matches paste by produced logical key rather than physical key', () => { - expect( - shouldBypassXtermKeyboardEvent(event({ key: 'v', code: 'KeyK', ctrlKey: true }), noSel) - ).toBe(true) - expect( - shouldBypassXtermKeyboardEvent(event({ key: 'k', code: 'KeyV', ctrlKey: true }), noSel) - ).toBe(false) - }) - - it('bubbles Shift+Insert (X11/Linux paste convention)', () => { - expect( - shouldBypassXtermKeyboardEvent( - event({ key: 'Insert', code: 'Insert', shiftKey: true }), - noSel - ) - ).toBe(true) - }) - - it('does not bubble plain Ctrl letter chords — shell shortcuts must reach PTY', () => { - // Ctrl+A, Ctrl+E, Ctrl+U, Ctrl+R, Ctrl+L — all readline-critical. - for (const keyCode of ['a', 'e', 'u', 'r', 'l']) { - expect( - shouldBypassXtermKeyboardEvent( - event({ key: keyCode, code: `Key${keyCode.toUpperCase()}`, ctrlKey: true }), - noSel - ) - ).toBe(false) - } - }) - - it('bubbles already-handled Ctrl app shortcuts so kitty does not also write to shell', () => { - expect( - shouldBypassXtermKeyboardEvent( - event({ key: 'b', code: 'KeyB', defaultPrevented: true, ctrlKey: true }), - noSel - ) - ).toBe(true) - expect( - shouldBypassXtermKeyboardEvent( - event({ - key: 'ArrowLeft', - code: 'ArrowLeft', - defaultPrevented: true, - ctrlKey: true, - altKey: true - }), - noSel - ) - ).toBe(true) - }) - - it('does not bubble plain letters', () => { - expect(shouldBypassXtermKeyboardEvent(event({ key: 'c', code: 'KeyC' }), noSel)).toBe(false) - }) - - it('bubbles Shift+non-ASCII printable text so the active keyboard layout wins', () => { - expect( - shouldBypassXtermKeyboardEvent(event({ key: 'Ф', code: 'KeyA', shiftKey: true }), noSel) - ).toBe(true) - expect( - shouldBypassXtermKeyboardEvent( - event({ type: 'keyup', key: 'Ф', code: 'KeyA', shiftKey: true }), - noSel - ) - ).toBe(true) - }) - - it('does not bubble unshifted non-ASCII printable text', () => { - expect(shouldBypassXtermKeyboardEvent(event({ key: 'ф', code: 'KeyA' }), noSel)).toBe(false) - }) - - it('does not bubble Cmd chords on non-Mac (Super+C has no clipboard meaning there)', () => { - expect( - shouldBypassXtermKeyboardEvent(event({ key: 'c', code: 'KeyC', metaKey: true }), noSel) - ).toBe(false) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts index 1e04b3147..6e8d77cca 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts @@ -16,6 +16,7 @@ export type XtermBypassEvent = { type: string key: string code?: string + keyCode?: number defaultPrevented?: boolean metaKey: boolean ctrlKey: boolean @@ -31,6 +32,9 @@ export type XtermBypassOptions = { hasSelection: boolean } +export const TERMINAL_INTERRUPT_INPUT = '\x03' +const TERMINAL_MODIFIER_KEYS = new Set(['Alt', 'AltGraph', 'Control', 'Meta', 'Shift']) + function isSingleNonAsciiPrintableText(key: string): boolean { const chars = Array.from(key) if (chars.length !== 1) { @@ -44,6 +48,22 @@ function isXtermHandledKeyEvent(type: string): boolean { return type === 'keydown' || type === 'keyup' } +function isTerminalInterruptCKey(event: XtermBypassEvent): boolean { + const normalizedKey = event.key.toLowerCase() + const logicalKeyAvailable = normalizedKey !== '' && normalizedKey !== 'unidentified' + return logicalKeyAvailable ? normalizedKey === 'c' : event.code === 'KeyC' || event.keyCode === 67 +} + +function isPlainCtrlC(event: XtermBypassEvent): boolean { + return ( + isTerminalInterruptCKey(event) && + event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey + ) +} + function matchesClipboardBinding( binding: string, event: XtermBypassEvent, @@ -52,6 +72,39 @@ function matchesClipboardBinding( return keybindingMatchesInput(binding, event, platform) } +/** + * Decide whether plain Ctrl+C should bypass xterm's kitty CSI-u encoder and + * be sent as ETX through Terminal.input() instead. + */ +export function shouldHandleTerminalInterruptKeyboardEvent( + event: XtermBypassEvent, + options: XtermBypassOptions +): boolean { + if (!isXtermHandledKeyEvent(event.type) || !isPlainCtrlC(event)) { + return false + } + + if (options.isMac) { + return true + } + + return !options.hasSelection +} + +export function shouldSuppressTerminalInterruptKeyup(event: XtermBypassEvent): boolean { + return ( + event.type === 'keyup' && + isTerminalInterruptCKey(event) && + !event.metaKey && + !event.altKey && + !event.shiftKey + ) +} + +export function shouldSuppressTerminalModifierKeyboardEvent(event: XtermBypassEvent): boolean { + return isXtermHandledKeyEvent(event.type) && TERMINAL_MODIFIER_KEYS.has(event.key) +} + /** * Decide whether a chord should bypass xterm's key handlers so the native * browser pipeline (Chromium `copy` event, Electron menu accelerators) or diff --git a/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts b/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts index 0384d2213..22553a1fa 100644 --- a/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts @@ -49,8 +49,21 @@ describe('terminalOutputPrefersDomRenderer', () => { expect(terminalOutputPrefersDomRenderer(low)).toBe(true) }) + it('detects ASCII ANSI background SGR output before the non-ASCII fast path', () => { + expect(terminalOutputPrefersDomRenderer('\x1b[48;2;12;34;56m codex input \x1b[0m')).toBe(true) + expect(terminalOutputPrefersDomRenderer('\x1b[48:2::12:34:56m codex input \x1b[0m')).toBe(true) + expect(terminalOutputPrefersDomRenderer('\x1b[44m selected block \x1b[0m')).toBe(true) + expect(terminalOutputPrefersDomRenderer('\x1b[104m bright selected block \x1b[0m')).toBe(true) + }) + it('does not disable WebGL for ordinary terminal output or ANSI controls alone', () => { expect(terminalOutputPrefersDomRenderer('abc 123 ✓')).toBe(false) expect(terminalOutputPrefersDomRenderer('\x1b[32mplain green\x1b[0m')).toBe(false) + expect(terminalOutputPrefersDomRenderer('\x1b[38;2;48;34;56m foreground only\x1b[0m')).toBe( + false + ) + expect(terminalOutputPrefersDomRenderer('\x1b[38:2::48:34:56m foreground only\x1b[0m')).toBe( + false + ) }) }) diff --git a/src/renderer/src/lib/pane-manager/terminal-complex-script.ts b/src/renderer/src/lib/pane-manager/terminal-complex-script.ts index 8c13da6cd..ca9cadad0 100644 --- a/src/renderer/src/lib/pane-manager/terminal-complex-script.ts +++ b/src/renderer/src/lib/pane-manager/terminal-complex-script.ts @@ -1,7 +1,10 @@ -// Why: xterm WebGL renders from a glyph atlas; actual complex text is safer -// through the browser text path. Terminal UI drawing glyphs stay on WebGL -// because xterm's custom-glyph renderer is built for those ranges. +// Why: xterm WebGL renders from a glyph atlas; complex text and background +// repaint-heavy output are safer through the browser renderer path. Terminal +// UI drawing glyphs stay on WebGL because xterm's custom-glyph renderer is +// built for those ranges. const EMOJI_PRESENTATION_PATTERN = /\p{Emoji_Presentation}/u +const ESCAPE_CHARACTER = String.fromCharCode(0x1b) +const SGR_SEQUENCE_PATTERN = new RegExp(`${ESCAPE_CHARACTER}\\[([0-9:;]*)m`, 'g') function isInRange(value: number, start: number, end: number): boolean { return value >= start && value <= end @@ -34,7 +37,61 @@ function isRendererRiskCodePoint(value: number): boolean { ) } +function sgrParamCode(param: string | undefined): number | null { + if (!param) { + return null + } + const [head] = param.split(':') + const value = Number.parseInt(head ?? '', 10) + return Number.isFinite(value) ? value : null +} + +function sgrSequenceSetsBackground(params: string): boolean { + const parts = params.split(';') + for (let i = 0; i < parts.length; i += 1) { + const value = sgrParamCode(parts[i]) + if (value === null) { + continue + } + if (isInRange(value, 40, 47) || isInRange(value, 100, 107)) { + return true + } + if (value === 48) { + return true + } + if (value === 38 && !parts[i]?.includes(':')) { + const mode = sgrParamCode(parts[i + 1]) + if (mode === 5) { + i += 2 + } else if (mode === 2) { + i += 4 + } else { + i += 1 + } + } + } + return false +} + +function containsBackgroundSgr(data: string): boolean { + SGR_SEQUENCE_PATTERN.lastIndex = 0 + for ( + let match = SGR_SEQUENCE_PATTERN.exec(data); + match; + match = SGR_SEQUENCE_PATTERN.exec(data) + ) { + if (sgrSequenceSetsBackground(match[1] ?? '')) { + return true + } + } + return false +} + export function terminalOutputPrefersDomRenderer(data: string): boolean { + if (containsBackgroundSgr(data)) { + return true + } + let hasNonAscii = false for (let i = 0; i < data.length; i += 1) { if (data.charCodeAt(i) > 0x7f) { diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts index ba5972f1a..5660eaefb 100644 --- a/tests/e2e/terminal-shortcuts.spec.ts +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -38,6 +38,7 @@ async function installMainProcessPtyWriteSpy(app: ElectronApplication): Promise< const g = globalThis as unknown as { __ptyWriteLog?: { id: string; data: string }[] __ptyWriteSpyInstalled?: boolean + __ptyWriteAcceptedSpyInstalled?: boolean } if (g.__ptyWriteSpyInstalled) { return @@ -47,6 +48,22 @@ async function installMainProcessPtyWriteSpy(app: ElectronApplication): Promise< ipcMain.prependListener('pty:write', (_event: unknown, args: { id: string; data: string }) => { g.__ptyWriteLog!.push({ id: args.id, data: args.data }) }) + const invokeHandlers = ( + ipcMain as unknown as { + _invokeHandlers?: Map< + string, + (event: unknown, args: { id: string; data: string }) => unknown + > + } + )._invokeHandlers + const writeAcceptedHandler = invokeHandlers?.get('pty:writeAccepted') + if (writeAcceptedHandler && !g.__ptyWriteAcceptedSpyInstalled) { + g.__ptyWriteAcceptedSpyInstalled = true + invokeHandlers?.set('pty:writeAccepted', (event, args) => { + g.__ptyWriteLog!.push({ id: args.id, data: args.data }) + return writeAcceptedHandler(event, args) + }) + } }) } @@ -76,6 +93,60 @@ async function focusActiveTerminal(page: Page): Promise { }) } +async function dispatchCtrlCToActiveTerminalTextarea( + page: Page, + options: { keyupCtrlKey?: boolean } = {} +): Promise<{ + keydownDefaultPrevented: boolean + keyupDefaultPrevented: boolean +}> { + return page.evaluate((dispatchOptions) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const textarea = pane?.container.querySelector( + '.xterm-helper-textarea' + ) as HTMLTextAreaElement | null + if (!pane || !textarea) { + throw new Error('No active terminal textarea for Ctrl+C dispatch') + } + pane.terminal.clearSelection() + pane.terminal.focus() + textarea.focus() + + const createEvent = (type: 'keydown' | 'keyup', ctrlKey: boolean): KeyboardEvent => { + const event = new KeyboardEvent(type, { + key: 'c', + code: 'KeyC', + ctrlKey, + bubbles: true, + cancelable: true + }) + Object.defineProperty(event, 'keyCode', { get: () => 67 }) + Object.defineProperty(event, 'which', { get: () => 67 }) + return event + } + + // Why: Electron headless consumes real Ctrl+C before xterm in automation; + // synthetic DOM events still exercise Orca's installed xterm boundary. + const keydown = createEvent('keydown', true) + textarea.dispatchEvent(keydown) + const keyup = createEvent('keyup', dispatchOptions.keyupCtrlKey !== false) + textarea.dispatchEvent(keyup) + return { + keydownDefaultPrevented: keydown.defaultPrevented, + keyupDefaultPrevented: keyup.defaultPrevented + } + }, options) +} + async function focusFloatingTerminal(page: Page): Promise { await page .locator( @@ -180,6 +251,32 @@ async function enableKittyKeyboardReporting(page: Page, flags: number): Promise< }, flags) } +async function getKittyKeyboardFlags(page: Page): Promise { + return page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const terminal = pane?.terminal as + | { + core?: { coreService?: { kittyKeyboard?: { flags?: number } } } + _core?: { coreService?: { kittyKeyboard?: { flags?: number } } } + } + | undefined + return ( + terminal?.core?.coreService?.kittyKeyboard?.flags ?? + terminal?._core?.coreService?.kittyKeyboard?.flags ?? + null + ) + }) +} + async function pressShiftedRussianLayoutKey(page: Page): Promise<{ keydownDefaultPrevented: boolean keypressSent: boolean @@ -373,6 +470,138 @@ test.describe('Terminal Shortcuts', () => { ) }) + test('plain Ctrl+C sends ETX under kitty keyboard reporting', async ({ + orcaPage, + electronApp + }) => { + await installMainProcessPtyWriteSpy(electronApp) + await waitForActivePanePtyId(orcaPage) + await enableKittyKeyboardReporting(orcaPage, 31) + await clearPtyWriteLog(electronApp) + await focusActiveTerminal(orcaPage) + await orcaPage.keyboard.down('Control') + await orcaPage.keyboard.up('Control') + expect((await getPtyWrites(electronApp)).join('')).toBe('') + await clearPtyWriteLog(electronApp) + + expect(await dispatchCtrlCToActiveTerminalTextarea(orcaPage, { keyupCtrlKey: false })).toEqual({ + keydownDefaultPrevented: false, + keyupDefaultPrevented: false + }) + + await expect + .poll(async () => (await getPtyWrites(electronApp)).some((write) => write.includes('\x03')), { + timeout: 5_000, + message: 'Ctrl+C did not reach the PTY as ETX' + }) + .toBe(true) + const writes = (await getPtyWrites(electronApp)).join('') + expect(writes).not.toContain('\x1b[99;5u') + expect(writes).not.toContain('\x1b[99') + + await expect + .poll(async () => await getKittyKeyboardFlags(orcaPage), { + timeout: 5_000, + message: 'Ctrl+C did not clear stale Kitty keyboard flags' + }) + .toBe(0) + + await clearPtyWriteLog(electronApp) + await focusActiveTerminal(orcaPage) + await orcaPage.keyboard.type('x') + await expect + .poll(async () => (await getPtyWrites(electronApp)).some((write) => write === 'x'), { + timeout: 5_000, + message: 'Post-interrupt keyboard input stayed in Kitty CSI-u mode' + }) + .toBe(true) + const postInterruptWrites = (await getPtyWrites(electronApp)).join('') + expect(postInterruptWrites).not.toContain('\x1b[') + await orcaPage.keyboard.press('Backspace') + }) + + test('@headful Codex-like background output falls back to DOM rendering in auto mode', async ({ + orcaPage + }) => { + const hasPane = await orcaPage.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + manager?.setTerminalGpuAcceleration('auto') + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return Boolean(pane) + }) + test.skip(!hasPane, 'No active terminal pane for renderer validation') + const webglActive = await orcaPage + .waitForFunction( + () => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return Boolean(pane?.webglAddon) + }, + null, + { timeout: 5_000 } + ) + .then(() => true) + .catch(() => false) + test.skip(!webglActive, 'WebGL was not active in this headful environment') + + const ptyId = await waitForActivePanePtyId(orcaPage) + const marker = `CODEX_BG_${Date.now()}` + await execInTerminal(orcaPage, ptyId, `printf '\\033[48;2;52;52;52m ${marker} \\033[0m\\n'`) + await waitForTerminalOutput(orcaPage, marker) + + await expect + .poll( + () => + orcaPage.evaluate((expectedMarker) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const terminalText = pane?.terminal.buffer.active + .translateBufferLineToString(pane.terminal.buffer.active.cursorY, true) + .trim() + const visibleText = pane?.container.textContent ?? '' + return { + markerVisible: + visibleText.includes(expectedMarker) || terminalText === expectedMarker, + hasComplexScriptOutput: pane?.hasComplexScriptOutput === true, + hasWebgl: Boolean(pane?.webglAddon) + } + }, marker), + { + timeout: 5_000, + message: 'Background SGR output did not switch auto mode to DOM rendering' + } + ) + .toEqual({ + markerVisible: true, + hasComplexScriptOutput: true, + hasWebgl: false + }) + }) + test('floating terminal owns tab switch shortcuts while focused', async ({ orcaPage }) => { const scenario = await seedFloatingTerminalTabSwitchScenario(orcaPage) await orcaPage.evaluate(async () => {