fix terminal kitty recovery for codex (#3941)

This commit is contained in:
Jinjing 2026-05-30 14:41:09 -07:00 committed by GitHub
parent 60ff88fd6c
commit 65cab0f5da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 874 additions and 153 deletions

View File

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

View File

@ -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()

View File

@ -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)) {

View File

@ -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<void> {
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()
}
})
})

View File

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

View File

@ -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>): 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
)
})
})

View File

@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest'
import { shouldBypassXtermKeyboardEvent, type XtermBypassEvent } from './xterm-bypass-policy'
function event(overrides: Partial<XtermBypassEvent>): 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)
})
})

View File

@ -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)
})
})

View File

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

View File

@ -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
)
})
})

View File

@ -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) {

View File

@ -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<void> {
})
}
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<void> {
await page
.locator(
@ -180,6 +251,32 @@ async function enableKittyKeyboardReporting(page: Page, flags: number): Promise<
}, flags)
}
async function getKittyKeyboardFlags(page: Page): Promise<number | null> {
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 () => {