Merge remote-tracking branch 'origin/main' into fix/renderer-crash-white-screen

This commit is contained in:
Brennan Benson
2026-07-12 16:31:49 -07:00
9 changed files with 609 additions and 12 deletions
+44 -5
View File
@@ -1772,10 +1772,11 @@
"remote-runtime"
],
"coveredPlatforms": [
"macos"
"macos",
"linux"
],
"coveredProviders": [],
"coverageNotes": "Local macOS evidence on main@1282f5c2d, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy, and a local Electron/CDP live-PTY Sogou-style repro. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
"coverageNotes": "Local macOS and containerized Linux evidence, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy including the legacy orphaned-keyup fallback, and Electron/CDP live-PTY Sogou-style repros. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6699",
"https://github.com/stablyai/orca/pull/6682",
@@ -1783,9 +1784,10 @@
"https://github.com/stablyai/orca/pull/6999"
],
"invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.",
"oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary post-IME typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit candidate selectors submit only the committed Chinese text. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
"oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary and long-held letter-to-digit typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit selectors submit only the committed Chinese text, while the legacy orphaned-letter-keyup sequence sends no selector byte to the PTY. Real legacy IME commit preservation and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
"pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts"
],
"testFiles": [
@@ -1794,6 +1796,7 @@
"src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
"src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts",
"src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"tests/e2e/chinese-ime-chat-input-repro.spec.ts"
@@ -1834,6 +1837,14 @@
"fresh keydowns, other keys, modified keys, and expired keypresses are not guarded, and a fresh press drops a stale pending release"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
"assertions": [
"an orphaned plain-letter keyup arms exactly the next bare digit guard",
"ordinary, overlapping, shifted, and long-held letter keydowns keep following digits available",
"physical letter tracking survives cross-pane focus handoff and clears on renderer-window blur"
]
},
{
"file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts",
"assertions": [
@@ -1852,11 +1863,39 @@
"assertions": [
"Sogou-style Space candidate selection submits only the committed Chinese character",
"Sogou-style digit candidate selection submits only the committed Chinese phrase",
"Post-composition Sogou-style digit selection stays out of the PTY after compositionend"
"Post-composition Sogou-style digit selection stays out of the PTY after compositionend",
"Legacy orphaned-letter-keyup digit selection submits no selector byte to the PTY; real legacy candidate commit preservation remains a platform gap"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-11",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
"result": "passed",
"durationSeconds": 0.619,
"summary": "1 state test file and 10 tests passed on macOS, including orphan-keyup, shifted/modifier-changed releases, intervening-key cancellation, cross-pane focus handoff, window/terminal blur cleanup, and long-held-letter coverage; the complete 8-file slice also passed 158 tests."
},
{
"date": "2026-07-11",
"runner": "local",
"platform": "linux",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-linux-candidate-state.test.ts",
"result": "passed",
"durationSeconds": 0.747,
"summary": "1 state test file and 10 tests passed in Debian 12 arm64 Docker under Node 24, including orphan-keyup, shifted/modifier-changed releases, intervening-key cancellation, cross-pane focus handoff, window/terminal blur cleanup, and long-held-letter coverage; the complete 8-file slice also passed 158 tests."
},
{
"date": "2026-07-11",
"runner": "local",
"platform": "linux",
"command": "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts",
"result": "passed",
"durationSeconds": 30.3,
"summary": "Debian 12 arm64 Docker with Node 24 and Xvfb passed both live Electron/PTY IME scenarios; the orphaned-letter-keyup candidate digit submitted no selector byte, and the real-Codex opt-in scenario was skipped."
},
{
"date": "2026-07-07",
"runner": "local",
@@ -1890,7 +1929,7 @@
},
"performanceBudget": {
"required": true,
"evidence": "Terminal IME hot-path audit clean on 2026-07-07: candidate guard adds O(1) boolean checks plus a bounded Space/digit release map; composition listeners are disposed with pane lifecycle; no polling, IPC fan-out, subprocess work, or SSH/remote transport cost added."
"evidence": "Terminal IME hot-path audit clean on 2026-07-11: candidate guards add O(1) boolean checks plus bounded per-pane Space/digit state and one renderer-scoped currently pressed physical-letter set; ref-counted renderer and pane blur listeners are disposed with pane lifecycle; no per-pane keyboard-event fan-out, polling, IPC, subprocess work, or SSH/remote transport cost added."
},
"promotionCriteria": [
"Cover deterministic byte/cell oracles first.",
@@ -3,6 +3,7 @@ import {
armTerminalImePendingCandidateKeyRelease,
clearTerminalImePendingCandidateKeyRelease,
createTerminalImePendingCandidateKeyReleases,
isTerminalImeCandidateDigitKeyEvent,
isTerminalImeCandidateSelectionKeyEvent,
shouldApplyTerminalImePendingCandidateKeyRelease
} from './terminal-ime-candidate-key-release-guard'
@@ -19,6 +20,12 @@ describe('terminal IME candidate key release guard', () => {
expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: ' ', shiftKey: true }))).toBe(false)
})
it('recognizes unmodified digits but not Space as digit candidate selectors', () => {
expect(isTerminalImeCandidateDigitKeyEvent(event({ key: '2' }))).toBe(true)
expect(isTerminalImeCandidateDigitKeyEvent(event({ key: ' ' }))).toBe(false)
expect(isTerminalImeCandidateDigitKeyEvent(event({ key: '2', ctrlKey: true }))).toBe(false)
})
it('arms a pending release guard from a suppressed candidate keydown', () => {
const releases = createTerminalImePendingCandidateKeyReleases()
armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10)
@@ -22,6 +22,7 @@ const TERMINAL_IME_CANDIDATE_SELECTION_KEYS = new Set([
'8',
'9'
])
const TERMINAL_IME_CANDIDATE_DIGITS = new Set(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'])
function isTerminalImeCandidateSelectionKey(key: string): boolean {
return TERMINAL_IME_CANDIDATE_SELECTION_KEYS.has(key)
@@ -36,6 +37,13 @@ export function isTerminalImeCandidateSelectionKeyEvent(event: XtermBypassEvent)
return isTerminalImeCandidateSelectionKey(event.key)
}
/** Returns whether an event is an unmodified IME candidate digit selector. */
export function isTerminalImeCandidateDigitKeyEvent(event: XtermBypassEvent): boolean {
return (
isTerminalImeCandidateSelectionKeyEvent(event) && TERMINAL_IME_CANDIDATE_DIGITS.has(event.key)
)
}
export function createTerminalImePendingCandidateKeyReleases(): TerminalImePendingCandidateKeyReleases {
return new Map()
}
@@ -0,0 +1,241 @@
import { describe, expect, it, vi } from 'vitest'
import {
createTerminalImeLinuxCandidateState,
installTerminalImeLinuxCandidateState
} from './terminal-ime-linux-candidate-state'
import type { XtermBypassEvent } from './xterm-bypass-policy'
/** Creates a terminal keyboard event with default modifier state. */
function event(overrides: Partial<XtermBypassEvent>): XtermBypassEvent {
return {
type: 'keydown',
key: '',
code: '',
defaultPrevented: false,
metaKey: false,
ctrlKey: false,
altKey: false,
shiftKey: false,
...overrides
}
}
describe('createTerminalImeLinuxCandidateState', () => {
it('suppresses the next bare digit after an orphaned plain-letter keyup', () => {
let time = 100
const state = createTerminalImeLinuxCandidateState(() => time)
const orphanLetterKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA', keyCode: 65 })
const orphanClassification = state.classifyKeyboardEvent(orphanLetterKeyup)
expect(orphanClassification.candidateDigitGuardActive).toBe(false)
state.observeKeyboardEvent(orphanLetterKeyup, orphanClassification)
time += 10
const digitKeydown = event({ key: '1', code: 'Digit1', keyCode: 49 })
const digitKeydownClassification = state.classifyKeyboardEvent(digitKeydown)
expect(digitKeydownClassification.candidateDigitGuardActive).toBe(true)
state.observeKeyboardEvent(digitKeydown, digitKeydownClassification)
const digitKeyup = event({ type: 'keyup', key: '1', code: 'Digit1', keyCode: 49 })
const digitKeyupClassification = state.classifyKeyboardEvent(digitKeyup)
expect(digitKeyupClassification.candidateDigitGuardActive).toBe(false)
state.observeKeyboardEvent(digitKeyup, digitKeyupClassification)
time += 10
const secondDigit = event({ key: '2', code: 'Digit2', keyCode: 50 })
expect(state.classifyKeyboardEvent(secondDigit).candidateDigitGuardActive).toBe(false)
})
it('does not suppress normal letter->digit typing when the letter had a matching keydown', () => {
let time = 100
const state = createTerminalImeLinuxCandidateState(() => time)
const letterKeydown = event({ key: 'a', code: 'KeyA', keyCode: 65 })
const letterKeydownClassification = state.classifyKeyboardEvent(letterKeydown)
expect(letterKeydownClassification.candidateDigitGuardActive).toBe(false)
state.observeKeyboardEvent(letterKeydown, letterKeydownClassification)
time += 10
const letterKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA', keyCode: 65 })
const letterKeyupClassification = state.classifyKeyboardEvent(letterKeyup)
expect(letterKeyupClassification.candidateDigitGuardActive).toBe(false)
state.observeKeyboardEvent(letterKeyup, letterKeyupClassification)
time += 10
const digitKeydown = event({ key: '1', code: 'Digit1', keyCode: 49 })
expect(state.classifyKeyboardEvent(digitKeydown).candidateDigitGuardActive).toBe(false)
})
it('does not mistake a long-held letter release for an orphaned keyup', () => {
let time = 100
const state = createTerminalImeLinuxCandidateState(() => time)
const letterKeydown = event({ key: 'a', code: 'KeyA', keyCode: 65 })
const letterKeydownClassification = state.classifyKeyboardEvent(letterKeydown)
state.observeKeyboardEvent(letterKeydown, letterKeydownClassification)
time += 2_000
const letterKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA', keyCode: 65 })
const letterKeyupClassification = state.classifyKeyboardEvent(letterKeyup)
state.observeKeyboardEvent(letterKeyup, letterKeyupClassification)
time += 10
expect(
state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1', keyCode: 49 }))
.candidateDigitGuardActive
).toBe(false)
})
it('clears a pending physical key when modifiers change before release', () => {
let time = 100
const state = createTerminalImeLinuxCandidateState(() => time)
const letterKeydown = event({ key: 'a', code: 'KeyA' })
state.observeKeyboardEvent(letterKeydown, state.classifyKeyboardEvent(letterKeydown))
const modifiedKeyup = event({
type: 'keyup',
key: 'A',
code: 'KeyA',
shiftKey: true
})
state.observeKeyboardEvent(modifiedKeyup, state.classifyKeyboardEvent(modifiedKeyup))
time += 10
const orphanKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA' })
state.observeKeyboardEvent(orphanKeyup, state.classifyKeyboardEvent(orphanKeyup))
time += 10
expect(
state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })).candidateDigitGuardActive
).toBe(true)
})
it('does not mistake a shifted physical letter for an orphan after Shift releases first', () => {
let time = 100
const state = createTerminalImeLinuxCandidateState(() => time)
for (const keyboardEvent of [
event({ key: 'A', code: 'KeyA', shiftKey: true }),
event({ type: 'keyup', key: 'Shift', code: 'ShiftLeft' }),
event({ type: 'keyup', key: 'a', code: 'KeyA' })
]) {
state.observeKeyboardEvent(keyboardEvent, state.classifyKeyboardEvent(keyboardEvent))
time += 10
}
expect(
state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })).candidateDigitGuardActive
).toBe(false)
})
it('cancels the orphan guard when another non-digit keydown intervenes', () => {
let time = 100
const state = createTerminalImeLinuxCandidateState(() => time)
for (const keyboardEvent of [
event({ type: 'keyup', key: 'a', code: 'KeyA' }),
event({ key: 'b', code: 'KeyB' }),
event({ type: 'keyup', key: 'b', code: 'KeyB' })
]) {
state.observeKeyboardEvent(keyboardEvent, state.classifyKeyboardEvent(keyboardEvent))
time += 10
}
expect(
state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })).candidateDigitGuardActive
).toBe(false)
})
it('resets missed releases on blur and removes the listener on dispose', () => {
let time = 100
const terminalElement = new EventTarget()
const removeEventListener = vi.spyOn(terminalElement, 'removeEventListener')
const state = installTerminalImeLinuxCandidateState(terminalElement, () => time)
const letterKeydown = event({ key: 'a', code: 'KeyA' })
state.observeKeyboardEvent(letterKeydown, state.classifyKeyboardEvent(letterKeydown))
terminalElement.dispatchEvent(new Event('blur'))
time += 10
const orphanKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA' })
state.observeKeyboardEvent(orphanKeyup, state.classifyKeyboardEvent(orphanKeyup))
time += 10
expect(
state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })).candidateDigitGuardActive
).toBe(true)
state.dispose()
expect(removeEventListener).toHaveBeenCalledWith('blur', state.resetCandidateGuard, true)
})
it('shares pressed letters across pane focus handoffs and disposes the tracker once', () => {
let time = 100
const rendererWindow = new EventTarget()
const removeWindowListener = vi.spyOn(rendererWindow, 'removeEventListener')
const firstTerminal = new EventTarget()
const secondTerminal = new EventTarget()
const firstState = installTerminalImeLinuxCandidateState(
firstTerminal,
() => time,
rendererWindow
)
const secondState = installTerminalImeLinuxCandidateState(
secondTerminal,
() => time,
rendererWindow
)
const letterKeydown = event({ key: 'a', code: 'KeyA' })
firstState.observeKeyboardEvent(letterKeydown, firstState.classifyKeyboardEvent(letterKeydown))
firstTerminal.dispatchEvent(new Event('blur'))
const letterKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA' })
secondState.observeKeyboardEvent(letterKeyup, secondState.classifyKeyboardEvent(letterKeyup))
time += 10
expect(
secondState.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' }))
.candidateDigitGuardActive
).toBe(false)
firstState.dispose()
expect(removeWindowListener).not.toHaveBeenCalled()
secondState.dispose()
expect(removeWindowListener).toHaveBeenCalledWith('keyup', expect.any(Function))
})
it('clears renderer-wide pressed letters when the window blurs', () => {
let time = 100
const rendererWindow = new EventTarget()
const state = installTerminalImeLinuxCandidateState(
new EventTarget(),
() => time,
rendererWindow
)
const letterKeydown = event({ key: 'a', code: 'KeyA' })
state.observeKeyboardEvent(letterKeydown, state.classifyKeyboardEvent(letterKeydown))
rendererWindow.dispatchEvent(new Event('blur'))
const orphanKeyup = event({ type: 'keyup', key: 'a', code: 'KeyA' })
state.observeKeyboardEvent(orphanKeyup, state.classifyKeyboardEvent(orphanKeyup))
time += 10
expect(
state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1' })).candidateDigitGuardActive
).toBe(true)
state.dispose()
})
it('does not suppress a digit after overlapping ordinary letter key presses', () => {
let time = 100
const state = createTerminalImeLinuxCandidateState(() => time)
for (const keyboardEvent of [
event({ key: 'a', code: 'KeyA', keyCode: 65 }),
event({ key: 'b', code: 'KeyB', keyCode: 66 }),
event({ type: 'keyup', key: 'a', code: 'KeyA', keyCode: 65 }),
event({ type: 'keyup', key: 'b', code: 'KeyB', keyCode: 66 })
]) {
const classification = state.classifyKeyboardEvent(keyboardEvent)
state.observeKeyboardEvent(keyboardEvent, classification)
time += 10
}
expect(
state.classifyKeyboardEvent(event({ key: '1', code: 'Digit1', keyCode: 49 }))
.candidateDigitGuardActive
).toBe(false)
})
})
@@ -0,0 +1,203 @@
import type { XtermBypassEvent } from './xterm-bypass-policy'
type TerminalImeLinuxCandidateState = {
/** Classifies an event before the state observes it. */
classifyKeyboardEvent: (event: XtermBypassEvent) => { candidateDigitGuardActive: boolean }
/** Advances the state after the caller consumes an event classification. */
observeKeyboardEvent: (
event: XtermBypassEvent,
classification: { candidateDigitGuardActive: boolean }
) => void
/** Drops all candidate and physical-key state. */
reset: () => void
/** Drops only this pane's candidate window. */
resetCandidateGuard: () => void
}
type TerminalImeLinuxPhysicalKeyTracker = {
pressedCodes: Set<string>
dispose: () => void
}
const CANDIDATE_DIGIT_WINDOW_MS = 1500
const ASCII_LOWERCASE_LETTER = /^[a-z]$/
const ASCII_DIGIT = /^[0-9]$/
const PHYSICAL_ASCII_LETTER_CODE = /^Key[A-Z]$/
const physicalKeyTrackers = new WeakMap<
EventTarget,
TerminalImeLinuxPhysicalKeyTracker & { users: number }
>()
function acquirePhysicalKeyTracker(
eventTarget: EventTarget | null
): TerminalImeLinuxPhysicalKeyTracker {
if (!eventTarget) {
return { pressedCodes: new Set(), dispose: () => undefined }
}
const existing = physicalKeyTrackers.get(eventTarget)
if (existing) {
existing.users += 1
return {
pressedCodes: existing.pressedCodes,
dispose: () => releasePhysicalKeyTracker(eventTarget, existing)
}
}
const pressedCodes = new Set<string>()
const observeKeyboardEvent = (event: Event): void => {
const code = (event as Event & { code?: string }).code
if (!code || !PHYSICAL_ASCII_LETTER_CODE.test(code)) {
return
}
if (event.type === 'keydown') {
pressedCodes.add(code)
} else {
pressedCodes.delete(code)
}
}
const reset = (): void => pressedCodes.clear()
// Why: bubble-phase keyup cleanup runs after xterm's target handler, so the
// pane can still classify that release against the shared pressed-key set.
eventTarget.addEventListener('keydown', observeKeyboardEvent)
eventTarget.addEventListener('keyup', observeKeyboardEvent)
eventTarget.addEventListener('blur', reset)
const tracker = {
pressedCodes,
users: 1,
dispose: () => releasePhysicalKeyTracker(eventTarget, tracker),
observeKeyboardEvent,
reset
}
physicalKeyTrackers.set(eventTarget, tracker)
return tracker
}
function releasePhysicalKeyTracker(
eventTarget: EventTarget,
tracker: TerminalImeLinuxPhysicalKeyTracker & {
users: number
observeKeyboardEvent?: EventListener
reset?: EventListener
}
): void {
tracker.users -= 1
if (tracker.users > 0) {
return
}
if (tracker.observeKeyboardEvent && tracker.reset) {
eventTarget.removeEventListener('keydown', tracker.observeKeyboardEvent)
eventTarget.removeEventListener('keyup', tracker.observeKeyboardEvent)
eventTarget.removeEventListener('blur', tracker.reset)
}
tracker.pressedCodes.clear()
physicalKeyTrackers.delete(eventTarget)
}
/** Returns whether an event is an unmodified lowercase Latin letter. */
function isPlainAsciiLetterKey(event: XtermBypassEvent): boolean {
return (
ASCII_LOWERCASE_LETTER.test(event.key) &&
!event.ctrlKey &&
!event.altKey &&
!event.metaKey &&
!event.shiftKey
)
}
/** Returns whether an event is an unmodified ASCII digit. */
function isPlainAsciiDigitKey(event: XtermBypassEvent): boolean {
return (
ASCII_DIGIT.test(event.key) &&
!event.ctrlKey &&
!event.altKey &&
!event.metaKey &&
!event.shiftKey
)
}
/** Tracks legacy desktop Linux IME candidate-selection event sequences. */
export function createTerminalImeLinuxCandidateState(
now: () => number = () => Date.now(),
pendingPlainLetterKeydownsByCode: Set<string> = new Set()
): TerminalImeLinuxCandidateState {
let candidateDigitUntil = 0
const resetCandidateGuard = (): void => {
candidateDigitUntil = 0
}
const reset = (): void => {
pendingPlainLetterKeydownsByCode.clear()
resetCandidateGuard()
}
return {
reset,
resetCandidateGuard,
/** Classifies the current event before state is advanced for it. */
classifyKeyboardEvent: (event) => {
const at = now()
return {
candidateDigitGuardActive:
event.type === 'keydown' && isPlainAsciiDigitKey(event) && candidateDigitUntil > at
}
},
/** Records the current event after its classification is consumed. */
observeKeyboardEvent: (event, classification) => {
const at = now()
if (classification.candidateDigitGuardActive) {
candidateDigitUntil = 0
return
}
if (candidateDigitUntil <= at) {
candidateDigitUntil = 0
}
if (event.type === 'keydown') {
if (!isPlainAsciiDigitKey(event)) {
candidateDigitUntil = 0
}
const physicalCode = event.code
if (physicalCode && PHYSICAL_ASCII_LETTER_CODE.test(physicalCode)) {
pendingPlainLetterKeydownsByCode.add(physicalCode)
}
return
}
if (event.type === 'keyup') {
const matchingPlainLetterKeydown = event.code
? pendingPlainLetterKeydownsByCode.delete(event.code)
: false
if (isPlainAsciiLetterKey(event) && event.code) {
if (!matchingPlainLetterKeydown) {
// Why: some legacy Linux IME paths commit a single-letter preedit
// without composition/input events. The orphaned keyup is a narrow
// hint that the next bare digit belongs to the candidate picker.
candidateDigitUntil = at + CANDIDATE_DIGIT_WINDOW_MS
}
}
}
}
}
}
/** Installs Linux IME candidate state with focus-loss cleanup. */
export function installTerminalImeLinuxCandidateState(
terminalElement: EventTarget | null | undefined,
now: () => number = () => Date.now(),
rendererKeyboardEventTarget: EventTarget | null = typeof window === 'undefined'
? (terminalElement ?? null)
: window
): TerminalImeLinuxCandidateState & { dispose: () => void } {
const physicalKeyTracker = acquirePhysicalKeyTracker(rendererKeyboardEventTarget)
const state = createTerminalImeLinuxCandidateState(now, physicalKeyTracker.pressedCodes)
terminalElement?.addEventListener('blur', state.resetCandidateGuard, true)
return {
...state,
dispose: () => {
terminalElement?.removeEventListener('blur', state.resetCandidateGuard, true)
physicalKeyTracker.dispose()
}
}
}
@@ -66,6 +66,7 @@ import { parseOsc7 } from './parse-osc7'
import { guardParserHandler } from './terminal-parser-handler-guard'
import { resolveTerminalJisYenInput } from './terminal-jis-yen-input'
import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker'
import { installTerminalImeLinuxCandidateState } from './terminal-ime-linux-candidate-state'
import {
armTerminalImePendingCandidateKeyRelease,
clearTerminalImePendingCandidateKeyRelease,
@@ -495,6 +496,7 @@ export function getPreviousVisibleForTerminalPane(args: {
return args.previous.isVisible
}
/** Wires mounted terminal panes to renderer state and terminal event handling. */
export function useTerminalPaneLifecycle({
tabId,
worktreeId,
@@ -890,9 +892,17 @@ export function useTerminalPaneLifecycle({
!isMac &&
navigator.userAgent.includes('Linux') &&
!/Android|CrOS/.test(navigator.userAgent)
const linuxImeCandidateState = isLinux
? installTerminalImeLinuxCandidateState(pane.terminal.element)
: null
const macNativeTextInputSourceTracker = isMac ? getMacNativeTextInputSourceTracker() : null
const imeCompositionTracker = installTerminalImeCompositionTracker(pane.terminal.element)
imeCompositionDisposablesRef.current.set(pane.id, imeCompositionTracker)
imeCompositionDisposablesRef.current.set(pane.id, {
dispose: () => {
imeCompositionTracker.dispose()
linuxImeCandidateState?.dispose()
}
})
// Why: only known macOS native text paths need xterm keydown bypass.
// Source gates cover physical CJK/Vietnamese IME rewrites; synthetic
// Unicode key events are detected by missing physical key identity.
@@ -911,6 +921,13 @@ export function useTerminalPaneLifecycle({
}
imeNativeTextForwarderDisposablesRef.current.set(pane.id, imeNativeTextForwarder)
pane.terminal.attachCustomKeyEventHandler((e) => {
const linuxCandidateClassification = linuxImeCandidateState?.classifyKeyboardEvent(e) ?? {
candidateDigitGuardActive: false
}
/** Advances the fallback state after every terminal key event path. */
const observeLinuxCandidateEvent = (): void => {
linuxImeCandidateState?.observeKeyboardEvent(e, linuxCandidateClassification)
}
const now = Date.now()
const pendingCandidateReleaseGuardActive =
shouldApplyTerminalImePendingCandidateKeyRelease(
@@ -924,6 +941,8 @@ export function useTerminalPaneLifecycle({
imeCompositionTracker.isCandidateKeyGuardActive() ||
pendingCandidateReleaseGuardActive,
pendingCandidateKeyReleaseActive: pendingCandidateReleaseGuardActive,
linuxOrphanCandidateDigitGuardActive:
linuxCandidateClassification.candidateDigitGuardActive,
isMac,
isLinux
}
@@ -941,11 +960,13 @@ export function useTerminalPaneLifecycle({
now
)
}
observeLinuxCandidateEvent()
return false
}
clearTerminalImePendingCandidateKeyRelease(pendingTerminalImeCandidateKeyReleases, e)
if (pendingTerminalInterruptKeyup && shouldSuppressTerminalInterruptKeyup(e)) {
pendingTerminalInterruptKeyup = false
observeLinuxCandidateEvent()
return false
}
if (
@@ -965,11 +986,13 @@ export function useTerminalPaneLifecycle({
} else {
pendingTerminalInterruptKeyup = false
}
observeLinuxCandidateEvent()
return false
}
if (shouldSuppressTerminalModifierKeyboardEvent(e)) {
// Why: stale Kitty keyboard reporting can encode standalone
// modifier presses before Ctrl+C reaches the interrupt handler.
observeLinuxCandidateEvent()
return false
}
@@ -983,6 +1006,7 @@ export function useTerminalPaneLifecycle({
// Keep it on xterm's onData path so PTY input guards still run.
pane.terminal.input(jisYenInput.data)
}
observeLinuxCandidateEvent()
return false
}
@@ -998,13 +1022,16 @@ export function useTerminalPaneLifecycle({
if (imeNativeTextForwarder.claimKeyEvent(e)) {
// Why: bypass xterm's kitty encoder for native text keydowns so the
// committed glyph survives via the input event.
observeLinuxCandidateEvent()
return false
}
return !shouldBypassXtermKeyboardEvent(e, {
const shouldBypass = shouldBypassXtermKeyboardEvent(e, {
isMac,
hasSelection: pane.terminal.hasSelection()
})
observeLinuxCandidateEvent()
return !shouldBypass
})
const linkProviderDisposable = pane.terminal.registerLinkProvider(
@@ -166,6 +166,10 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => {
// Post-compositionend guard: the tracker is already inactive but the
// committing key's trailing press/release must still be absorbed.
const linuxPostCompositionGuard = { ...linuxIdle, candidateKeyGuardActive: true }
const linuxOrphanCandidateDigitGuard = {
...linuxIdle,
linuxOrphanCandidateDigitGuardActive: true
}
const windowsComposing = {
...windowsIdle,
compositionActive: true,
@@ -314,6 +318,21 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => {
}
})
it('guards only digits for the orphaned-keyup fallback', () => {
expect(
shouldSuppressTerminalImeKeyboardEvent(
event({ key: '2', code: 'Digit2' }),
linuxOrphanCandidateDigitGuard
)
).toBe(true)
expect(
shouldSuppressTerminalImeKeyboardEvent(
event({ key: ' ', code: 'Space' }),
linuxOrphanCandidateDigitGuard
)
).toBe(false)
})
it('does not treat modified chords such as Ctrl+Space (IME toggle) as candidate keys', () => {
expect(
shouldSuppressTerminalImeKeyboardEvent(
@@ -406,5 +425,20 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => {
)
).toBe(false)
})
it('prevents the default only for fallback digit keydowns', () => {
expect(
shouldPreventDefaultTerminalImeCandidateKey(
event({ key: '2', code: 'Digit2' }),
linuxOrphanCandidateDigitGuard
)
).toBe(true)
expect(
shouldPreventDefaultTerminalImeCandidateKey(
event({ key: ' ', code: 'Space' }),
linuxOrphanCandidateDigitGuard
)
).toBe(false)
})
})
})
@@ -1,5 +1,8 @@
import { keybindingMatchesInput } from '../../../../shared/keybindings'
import { isTerminalImeCandidateSelectionKeyEvent } from './terminal-ime-candidate-key-release-guard'
import {
isTerminalImeCandidateDigitKeyEvent,
isTerminalImeCandidateSelectionKeyEvent
} from './terminal-ime-candidate-key-release-guard'
// Why: when a CLI activates kitty progressive enhancement (CSI > N u), xterm's
// KittyKeyboard encoder turns every modifier chord — including plain Cmd+C —
@@ -42,6 +45,9 @@ export type XtermImeKeyboardOptions = {
candidateKeyGuardActive: boolean
/** True when the pending-release guard already matched this specific event. */
pendingCandidateKeyReleaseActive: boolean
/** True for the narrow Linux path where the IME emits an orphaned letter
* keyup but no composition/input events before its candidate digit. */
linuxOrphanCandidateDigitGuardActive?: boolean
// Required so no caller silently falls back to non-mac 229 suppression,
// which re-swallows the first key after a macOS IME input-source switch.
isMac: boolean
@@ -81,6 +87,7 @@ function isXtermHandledKeyEvent(type: string): boolean {
return type === 'keydown' || type === 'keyup'
}
/** Returns whether xterm must not process an IME-owned keyboard event. */
export function shouldSuppressTerminalImeKeyboardEvent(
event: XtermBypassEvent,
options: XtermImeKeyboardOptions
@@ -89,13 +96,17 @@ export function shouldSuppressTerminalImeKeyboardEvent(
compositionActive,
candidateKeyGuardActive,
pendingCandidateKeyReleaseActive,
linuxOrphanCandidateDigitGuardActive = false,
isMac,
isLinux
} = options
const suppressOrphanCandidateDigit =
isLinux && linuxOrphanCandidateDigitGuardActive && isTerminalImeCandidateDigitKeyEvent(event)
const suppressCandidateKey =
isLinux &&
(pendingCandidateKeyReleaseActive ||
(candidateKeyGuardActive && isTerminalImeCandidateSelectionKeyEvent(event)))
(candidateKeyGuardActive && isTerminalImeCandidateSelectionKeyEvent(event)) ||
suppressOrphanCandidateDigit)
if (event.type === 'keypress') {
// Why: a suppressed candidate keydown is not preventDefault-ed by xterm,
// so its native keypress still fires and _keyPress would forward the
@@ -121,6 +132,7 @@ export function shouldSuppressTerminalImeKeyboardEvent(
)
}
/** Returns whether a candidate keydown needs native default prevention. */
export function shouldPreventDefaultTerminalImeCandidateKey(
event: XtermBypassEvent,
options: XtermImeKeyboardOptions
@@ -132,8 +144,9 @@ export function shouldPreventDefaultTerminalImeCandidateKey(
return (
event.type === 'keydown' &&
options.isLinux &&
options.candidateKeyGuardActive &&
isTerminalImeCandidateSelectionKeyEvent(event)
((options.candidateKeyGuardActive && isTerminalImeCandidateSelectionKeyEvent(event)) ||
(options.linuxOrphanCandidateDigitGuardActive === true &&
isTerminalImeCandidateDigitKeyEvent(event)))
)
}
+26 -1
View File
@@ -367,6 +367,18 @@ async function dispatchCandidateSelectionKey(
})
}
async function dispatchOrphanLetterKeyup(session: CDPSession): Promise<void> {
// Why: legacy Sogou/fcitx can emit only the release after committing a
// single-letter preedit, leaving no composition or input event to classify.
await session.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key: 'a',
code: 'KeyA',
windowsVirtualKeyCode: 65,
nativeVirtualKeyCode: 65
})
}
async function dispatchSogouEmptyCompositionUpdate(page: Page): Promise<void> {
// Why: Sogou/fcitx emits empty compositionupdate data while its candidate
// popup is still open (#6765); Orca's tracker must not flip inactive on it.
@@ -654,11 +666,24 @@ test.describe('Chinese IME terminal chat input repro', () => {
})
.toBe('再见')
// Some legacy desktop Linux paths omit every composition/input event and
// expose only an orphaned Latin release before the candidate digit.
await dispatchOrphanLetterKeyup(session)
await dispatchCandidateSelectionKey(session, { key: '4', code: 'Digit4', keyCode: 52 })
await orcaPage.keyboard.press('Enter')
await expect
.poll(async () => (await readPromptState(orcaPage))?.submitted.at(-1) ?? null, {
timeout: 5_000,
message: 'orphan-keyup candidate digit leaked into the submitted terminal input'
})
.toBe('')
await attachImeEvidence(orcaPage, testInfo, 'sogou-after-orphan-keyup-digit-suppression')
const promptState = await readPromptState(orcaPage)
expect(
promptState?.submitted,
'candidate Space/digit selectors and pinyin preedit must not leak into the PTY'
).toEqual(['你', '你好', '再见'])
).toEqual(['你', '你好', '再见', ''])
} finally {
await attachImeEvidence(orcaPage, testInfo, 'sogou-final-ime-evidence').catch(() => undefined)
await session?.detach().catch(() => undefined)