fix terminal kitty recovery for codex (#3941)
This commit is contained in:
parent
60ff88fd6c
commit
65cab0f5da
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue