fix: terminal IME candidate selection and text commit on Linux (#7634)
* fix terminal IME candidate selection and text commit on Linux Sogou Pinyin and fcitx on Linux failed in Orca's terminal because bare 229 keydowns were swallowed, and empty composition updates prematurely deactivated tracking. This led to dropped Chinese text or leaked Space/digit candidate-selection keys reaching the PTY. - Allow bare 229 keydowns to bypass suppression on Linux so xterm can diff and commit text. - Prevent empty compositionupdate events from prematurely deactivating the composition tracker. - Suppress and preventDefault candidate-selection keys (Space and digits) during active composition and a brief post-composition window. - Add comprehensive unit tests and an Electron CDP-driven E2E repro. * fix: register IME gate command as direct spec-file invocation The reliability-gate checker rejects --grep title selectors and requires every evidenceRun command to match a gate command. Drop the --grep from the e2e gate command and its evidence run, and remove the stale 3-file evidence run superseded by the full 7-file run. Co-authored-by: Orca <help@stably.ai> * Guard overlapping and post-composition Linux IME candidate keys - Track pending candidate key releases in a Map instead of a single slot to support overlapping selector key events without stranding. - Apply the candidate selection guard to post-composition key releases that arrive after compositionend, preventing digits/Space from leaking into the PTY. - Restrict the Linux/Sogou candidate selection guard to Linux to prevent interference on macOS and Windows. - Exclude Shift+Space from candidate selection key checks. * Guard held-key IME candidate repeats and scope policy to desktop Linux - Keep auto-repeat keydowns for a candidate key suppressed past the 250ms guard window until its corresponding keyup event is received. - Clear stale pending releases on fresh non-repeat keydowns to avoid guarding the wrong key events. - Exclude Android and ChromeOS user agents from desktop Linux-specific IME candidate key suppression behaviors. - Ensure the composition tracker is activated unconditionally on compositionupdate events. * Clean up IME reference and extract shared test event fixture - Remove the obsolete Linux Sogou Pinyin IME reference document. - Extract the fully-defaulted XtermBypassEvent helper into a shared fixture file to keep the policy test suites in sync. - Add a test verifying that Shift+Space (fcitx full-/half-width toggle) is not suppressed as an IME candidate key. --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
46a67cb2eb
commit
544bca5202
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-07-03",
|
||||
"updatedAt": "2026-07-07",
|
||||
"policy": {
|
||||
"maturityLevels": [
|
||||
"experimental",
|
||||
|
|
@ -1273,7 +1273,7 @@
|
|||
"macos"
|
||||
],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Local macOS evidence on main@1282f5c2d; identical files to the pending stack. Real OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
|
||||
"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.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/6699",
|
||||
"https://github.com/stablyai/orca/pull/6682",
|
||||
|
|
@ -1281,14 +1281,20 @@
|
|||
"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, and paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures. 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 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.",
|
||||
"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"
|
||||
"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 run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"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-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",
|
||||
"tests/e2e/chinese-ime-chat-input-repro.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
|
|
@ -1309,34 +1315,80 @@
|
|||
"assertions": [
|
||||
"paste/runtime forwarding avoids duplicate terminal payloads"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
|
||||
"assertions": [
|
||||
"empty Sogou-style compositionupdate keeps composition active",
|
||||
"stale composition state expires so editing keys cannot stay suppressed indefinitely",
|
||||
"post-composition candidate guard is armed only by Sogou-style empty updates and clears after real typing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
|
||||
"assertions": [
|
||||
"suppressed candidate keydown arms only the matching keypress/keyup release guard",
|
||||
"held-key repeat keydowns stay guarded until their keyup, even past expiry",
|
||||
"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/xterm-bypass-policy-non-mac.test.ts",
|
||||
"assertions": [
|
||||
"standalone Linux 229 keydowns reach xterm while Windows 229 keydowns stay suppressed",
|
||||
"candidate Space/digit selectors are suppressed only while candidate guards are active"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
||||
"assertions": [
|
||||
"macOS standalone Process key behavior and composition-owned key suppression stay intact"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/chinese-ime-chat-input-repro.spec.ts",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-03",
|
||||
"date": "2026-07-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "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",
|
||||
"command": "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",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.9,
|
||||
"summary": "3 test file(s) passed, 63 tests passed on main@1282f5c2d in a clean checkout."
|
||||
"durationSeconds": 0.7,
|
||||
"summary": "7 test file(s) passed, 144 tests passed on the Linux/Sogou candidate-key branch (includes held-key repeat guard coverage)."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 72.0,
|
||||
"summary": "Electron/CDP IME repro passed: Sogou candidate selection, post-composition candidate selection, and existing Chinese IME harness passed; real Codex IME test was skipped behind ORCA_E2E_REAL_CODEX_IME."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 30,
|
||||
"scope": "renderer unit plus platform soak"
|
||||
"p95Seconds": 90,
|
||||
"scope": "renderer unit plus focused Electron/CDP IME repro"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "Focused renderer input tests are now registered; needs soak history and platform IME evidence before promotion."
|
||||
"evidence": "Focused renderer input tests and the Electron/CDP Sogou-style repro are now registered; needs soak history and true platform IME evidence before promotion."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Focused tests cover existing native-text, input-source, and paste/runtime forwarding contracts. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix."
|
||||
"evidence": "Focused tests cover existing native-text, input-source, paste/runtime forwarding, Linux/Sogou candidate selector, post-composition guard contracts, and live-PTY Sogou-style candidate commits. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": false,
|
||||
"evidence": "Required if input forwarding adds polling, repeated event listeners, or renderer hot-path work."
|
||||
"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."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Cover deterministic byte/cell oracles first.",
|
||||
|
|
@ -1344,7 +1396,7 @@
|
|||
"Pair with Windows ConPTY keyboard reset for Windows standard-key behavior."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Current command is a renderer-unit slice; real OS IME automation may need manual or soak evidence.",
|
||||
"Current commands include renderer-unit coverage and a CDP-driven Electron repro; real OS IME automation may need manual or soak evidence.",
|
||||
"Backspace/Enter during composition, JIS yen, Arabic/RTL, paste edge cases, and Windows ConPTY post-agent key reset still need representative gate coverage."
|
||||
],
|
||||
"demotionRule": "Cannot promote if success is based only on DOM text without PTY byte/cell evidence."
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
armTerminalImePendingCandidateKeyRelease,
|
||||
clearTerminalImePendingCandidateKeyRelease,
|
||||
createTerminalImePendingCandidateKeyReleases,
|
||||
isTerminalImeCandidateSelectionKeyEvent,
|
||||
shouldApplyTerminalImePendingCandidateKeyRelease
|
||||
} from './terminal-ime-candidate-key-release-guard'
|
||||
import { TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS } from './terminal-ime-composition-tracker'
|
||||
import { event } from './xterm-bypass-event-fixture'
|
||||
|
||||
describe('terminal IME candidate key release guard', () => {
|
||||
it('recognizes only unmodified Space and digit candidate selectors', () => {
|
||||
expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: ' ' }))).toBe(true)
|
||||
expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: '2' }))).toBe(true)
|
||||
expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: 'a' }))).toBe(false)
|
||||
expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: ' ', ctrlKey: true }))).toBe(false)
|
||||
// Shift+Space is fcitx's full-/half-width toggle, not a candidate selector.
|
||||
expect(isTerminalImeCandidateSelectionKeyEvent(event({ key: ' ', shiftKey: true }))).toBe(false)
|
||||
})
|
||||
|
||||
it('arms a pending release guard from a suppressed candidate keydown', () => {
|
||||
const releases = createTerminalImePendingCandidateKeyReleases()
|
||||
armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10)
|
||||
expect(releases.get('2')).toBe(10 + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS)
|
||||
})
|
||||
|
||||
it('does not arm from keyup, non-candidate, or Shift-modified keys', () => {
|
||||
const releases = createTerminalImePendingCandidateKeyReleases()
|
||||
armTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '2' }), 10)
|
||||
armTerminalImePendingCandidateKeyRelease(releases, event({ key: 'a' }), 10)
|
||||
armTerminalImePendingCandidateKeyRelease(releases, event({ key: ' ', shiftKey: true }), 10)
|
||||
expect(releases.size).toBe(0)
|
||||
})
|
||||
|
||||
it('guards the matching keypress and keyup after insertText clears the tracker', () => {
|
||||
const releases = createTerminalImePendingCandidateKeyReleases()
|
||||
armTerminalImePendingCandidateKeyRelease(releases, event({ key: ' ' }), 10)
|
||||
expect(
|
||||
shouldApplyTerminalImePendingCandidateKeyRelease(
|
||||
event({ type: 'keypress', key: ' ' }),
|
||||
releases,
|
||||
20
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldApplyTerminalImePendingCandidateKeyRelease(
|
||||
event({ type: 'keyup', key: ' ' }),
|
||||
releases,
|
||||
20
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not guard fresh keydowns, other keys, modified keypresses, or expired keypresses', () => {
|
||||
const releases = createTerminalImePendingCandidateKeyReleases()
|
||||
armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10)
|
||||
expect(shouldApplyTerminalImePendingCandidateKeyRelease(event({ key: '2' }), releases, 20)).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
shouldApplyTerminalImePendingCandidateKeyRelease(event({ type: 'keyup', key: '3' }), releases, 20)
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldApplyTerminalImePendingCandidateKeyRelease(
|
||||
event({ type: 'keypress', key: '2', ctrlKey: true }),
|
||||
releases,
|
||||
20
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldApplyTerminalImePendingCandidateKeyRelease(
|
||||
event({ type: 'keypress', key: '2' }),
|
||||
releases,
|
||||
10 + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS + 1
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldApplyTerminalImePendingCandidateKeyRelease(
|
||||
event({ type: 'keyup', key: '2' }),
|
||||
releases,
|
||||
10 + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS + 1
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('guards held-key repeat keydowns while the release is pending, even past expiry', () => {
|
||||
const releases = createTerminalImePendingCandidateKeyReleases()
|
||||
armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10)
|
||||
// Linux auto-repeat delay (~500ms) outlives the 250ms guard window; the
|
||||
// held selector's repeats must stay suppressed until its keyup.
|
||||
expect(
|
||||
shouldApplyTerminalImePendingCandidateKeyRelease(
|
||||
event({ key: '2', repeat: true }),
|
||||
releases,
|
||||
10 + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS + 500
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not guard repeat keydowns without a pending release for that key', () => {
|
||||
const releases = createTerminalImePendingCandidateKeyReleases()
|
||||
armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10)
|
||||
expect(
|
||||
shouldApplyTerminalImePendingCandidateKeyRelease(
|
||||
event({ key: '3', repeat: true }),
|
||||
releases,
|
||||
20
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldApplyTerminalImePendingCandidateKeyRelease(
|
||||
event({ key: '2', repeat: true, ctrlKey: true }),
|
||||
releases,
|
||||
20
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('clears a stale pending release on a fresh non-repeat keydown but not on repeats', () => {
|
||||
const releases = createTerminalImePendingCandidateKeyReleases()
|
||||
armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10)
|
||||
clearTerminalImePendingCandidateKeyRelease(releases, event({ key: '2', repeat: true }))
|
||||
expect(releases.has('2')).toBe(true)
|
||||
// A new physical press means the prior keyup was missed (focus change
|
||||
// mid-hold); its stale entry must not guard the new press's repeats.
|
||||
clearTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }))
|
||||
expect(releases.has('2')).toBe(false)
|
||||
})
|
||||
|
||||
it('guards a pending matching keyup even if modifier state changed after keydown', () => {
|
||||
const releases = createTerminalImePendingCandidateKeyReleases()
|
||||
armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10)
|
||||
expect(
|
||||
shouldApplyTerminalImePendingCandidateKeyRelease(
|
||||
event({ type: 'keyup', key: '2', shiftKey: true }),
|
||||
releases,
|
||||
10 + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS + 1
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('clears on the matching keyup', () => {
|
||||
const releases = createTerminalImePendingCandidateKeyReleases()
|
||||
armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10)
|
||||
clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keypress', key: '2' }))
|
||||
expect(releases.has('2')).toBe(true)
|
||||
clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '3' }))
|
||||
expect(releases.has('2')).toBe(true)
|
||||
clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '2' }))
|
||||
expect(releases.has('2')).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks overlapping candidate keys independently so each keyup clears its own guard', () => {
|
||||
const releases = createTerminalImePendingCandidateKeyReleases()
|
||||
// Second candidate keydown arrives before the first key's keyup.
|
||||
armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10)
|
||||
armTerminalImePendingCandidateKeyRelease(releases, event({ key: '3' }), 12)
|
||||
expect(
|
||||
shouldApplyTerminalImePendingCandidateKeyRelease(event({ type: 'keyup', key: '2' }), releases, 20)
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldApplyTerminalImePendingCandidateKeyRelease(event({ type: 'keyup', key: '3' }), releases, 20)
|
||||
).toBe(true)
|
||||
// The first key's keyup no longer strands the second key's pending release.
|
||||
clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '2' }))
|
||||
expect(releases.has('3')).toBe(true)
|
||||
clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '3' }))
|
||||
expect(releases.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import type { XtermBypassEvent } from './xterm-bypass-policy'
|
||||
import { TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS } from './terminal-ime-composition-tracker'
|
||||
|
||||
// Why: candidate keys can overlap (a second selector keydown before the first
|
||||
// keyup), so pending releases are tracked per key rather than in one slot;
|
||||
// otherwise an overwritten key's keyup can no longer clear its own guard.
|
||||
export type TerminalImePendingCandidateKeyReleases = Map<string, number>
|
||||
|
||||
// Why: Sogou/fcitx can deliver candidate-selection keys as plain key events
|
||||
// (#7543: digit selection inserts only the digit). While the IME owns them,
|
||||
// they must not reach xterm's encoders or Chromium's default text insertion.
|
||||
const TERMINAL_IME_CANDIDATE_SELECTION_KEYS = 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)
|
||||
}
|
||||
|
||||
export function isTerminalImeCandidateSelectionKeyEvent(event: XtermBypassEvent): boolean {
|
||||
// Modified chords are never candidate selectors: Ctrl/Meta/Alt are IME
|
||||
// toggles, and Shift+Space is fcitx's full-/half-width width toggle.
|
||||
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) {
|
||||
return false
|
||||
}
|
||||
return isTerminalImeCandidateSelectionKey(event.key)
|
||||
}
|
||||
|
||||
export function createTerminalImePendingCandidateKeyReleases(): TerminalImePendingCandidateKeyReleases {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
export function armTerminalImePendingCandidateKeyRelease(
|
||||
releases: TerminalImePendingCandidateKeyReleases,
|
||||
event: XtermBypassEvent,
|
||||
now: number
|
||||
): void {
|
||||
if (event.type !== 'keydown' || !isTerminalImeCandidateSelectionKeyEvent(event)) {
|
||||
return
|
||||
}
|
||||
releases.set(event.key, now + TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS)
|
||||
}
|
||||
|
||||
export function shouldApplyTerminalImePendingCandidateKeyRelease(
|
||||
event: XtermBypassEvent,
|
||||
releases: TerminalImePendingCandidateKeyReleases,
|
||||
now: number
|
||||
): boolean {
|
||||
if (event.type === 'keydown') {
|
||||
// Why: key auto-repeat outlives the 250ms window (Linux repeat delay is
|
||||
// ~500ms), so a held selector's repeats stay owned by its pending release;
|
||||
// a fresh press means the prior keyup was missed and the entry is stale.
|
||||
return (
|
||||
event.repeat === true &&
|
||||
isTerminalImeCandidateSelectionKeyEvent(event) &&
|
||||
releases.has(event.key)
|
||||
)
|
||||
}
|
||||
if (event.type === 'keyup') {
|
||||
// Why: keyup modifier flags can reflect keys pressed after the original
|
||||
// selector keydown, so release suppression matches only the pending key.
|
||||
return isTerminalImeCandidateSelectionKey(event.key) && releases.has(event.key)
|
||||
}
|
||||
if (!isTerminalImeCandidateSelectionKeyEvent(event)) {
|
||||
return false
|
||||
}
|
||||
const expiresAt = releases.get(event.key)
|
||||
if (expiresAt === undefined) {
|
||||
return false
|
||||
}
|
||||
return now <= expiresAt
|
||||
}
|
||||
|
||||
export function clearTerminalImePendingCandidateKeyRelease(
|
||||
releases: TerminalImePendingCandidateKeyReleases,
|
||||
event: XtermBypassEvent
|
||||
): void {
|
||||
// Why: a non-repeat keydown is a new physical press, so any surviving entry
|
||||
// for that key lost its keyup (focus change mid-hold) and must not guard the
|
||||
// new press's repeats. Callers clear before arming.
|
||||
if (event.type === 'keyup' || (event.type === 'keydown' && event.repeat !== true)) {
|
||||
releases.delete(event.key)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
installTerminalImeCompositionTracker,
|
||||
TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS,
|
||||
TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS,
|
||||
type TerminalImeCompositionTracker
|
||||
} from './terminal-ime-composition-tracker'
|
||||
|
||||
type TrackerHarness = {
|
||||
tracker: TerminalImeCompositionTracker
|
||||
element: HTMLElement
|
||||
advance: (ms: number) => void
|
||||
composition: (type: 'compositionstart' | 'compositionupdate' | 'compositionend', data: string) => void
|
||||
input: (inputType: string) => void
|
||||
blur: () => void
|
||||
}
|
||||
|
||||
function installTracker(): TrackerHarness {
|
||||
let now = 0
|
||||
const element = document.createElement('div')
|
||||
const tracker = installTerminalImeCompositionTracker(element, { now: () => now })
|
||||
return {
|
||||
tracker,
|
||||
element,
|
||||
advance: (ms) => {
|
||||
now += ms
|
||||
},
|
||||
composition: (type, data) => {
|
||||
const event = new CompositionEvent(type, { bubbles: true })
|
||||
Object.defineProperty(event, 'data', { value: data })
|
||||
element.dispatchEvent(event)
|
||||
},
|
||||
input: (inputType) => {
|
||||
element.dispatchEvent(new InputEvent('input', { inputType, bubbles: true }))
|
||||
},
|
||||
blur: () => {
|
||||
element.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('installTerminalImeCompositionTracker', () => {
|
||||
it('activates on compositionstart', () => {
|
||||
const harness = installTracker()
|
||||
harness.composition('compositionstart', '')
|
||||
expect(harness.tracker.isActive()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps composition active through Sogou-style empty compositionupdate', () => {
|
||||
const harness = installTracker()
|
||||
harness.composition('compositionstart', '')
|
||||
harness.composition('compositionupdate', 'ni')
|
||||
harness.composition('compositionupdate', '')
|
||||
expect(harness.tracker.isActive()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears on compositionend', () => {
|
||||
const harness = installTracker()
|
||||
harness.composition('compositionstart', '')
|
||||
harness.composition('compositionupdate', '你')
|
||||
harness.composition('compositionend', '你')
|
||||
expect(harness.tracker.isActive()).toBe(false)
|
||||
})
|
||||
|
||||
it('still clears on non-composition input', () => {
|
||||
const harness = installTracker()
|
||||
harness.composition('compositionstart', '')
|
||||
harness.input('insertText')
|
||||
expect(harness.tracker.isActive()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not clear on insertCompositionText input', () => {
|
||||
const harness = installTracker()
|
||||
harness.composition('compositionstart', '')
|
||||
harness.input('insertCompositionText')
|
||||
expect(harness.tracker.isActive()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears on blur', () => {
|
||||
const harness = installTracker()
|
||||
harness.composition('compositionstart', '')
|
||||
harness.blur()
|
||||
expect(harness.tracker.isActive()).toBe(false)
|
||||
expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false)
|
||||
})
|
||||
|
||||
describe('candidate key guard', () => {
|
||||
it('is active while a composition is live', () => {
|
||||
const harness = installTracker()
|
||||
expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false)
|
||||
harness.composition('compositionstart', '')
|
||||
expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true)
|
||||
})
|
||||
|
||||
it('stays active through empty compositionupdate events', () => {
|
||||
const harness = installTracker()
|
||||
harness.composition('compositionstart', '')
|
||||
harness.composition('compositionupdate', '')
|
||||
expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true)
|
||||
})
|
||||
|
||||
it('expires after prolonged composition-event silence so stale IME state cannot keep terminal keys dead', () => {
|
||||
const harness = installTracker()
|
||||
harness.composition('compositionstart', '')
|
||||
harness.advance(TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS)
|
||||
expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true)
|
||||
expect(harness.tracker.isActive()).toBe(true)
|
||||
harness.advance(1)
|
||||
expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false)
|
||||
expect(harness.tracker.isActive()).toBe(false)
|
||||
})
|
||||
|
||||
it('refreshes the expiry window on composition activity', () => {
|
||||
const harness = installTracker()
|
||||
harness.composition('compositionstart', '')
|
||||
harness.advance(TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS)
|
||||
harness.composition('compositionupdate', '')
|
||||
harness.advance(TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS)
|
||||
expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not arm the post-composition window without Sogou-style empty updates', () => {
|
||||
const harness = installTracker()
|
||||
harness.composition('compositionstart', '')
|
||||
harness.composition('compositionupdate', '你')
|
||||
harness.composition('compositionend', '你')
|
||||
expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false)
|
||||
})
|
||||
|
||||
it('absorbs the Sogou-style committing key after compositionend, then expires', () => {
|
||||
const harness = installTracker()
|
||||
harness.composition('compositionstart', '')
|
||||
harness.composition('compositionupdate', '你')
|
||||
harness.composition('compositionupdate', '')
|
||||
harness.composition('compositionend', '你')
|
||||
expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true)
|
||||
harness.advance(TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS)
|
||||
expect(harness.tracker.isCandidateKeyGuardActive()).toBe(true)
|
||||
harness.advance(1)
|
||||
expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false)
|
||||
})
|
||||
|
||||
it('drops the post-composition window once ordinary typing resumes', () => {
|
||||
const harness = installTracker()
|
||||
harness.composition('compositionstart', '')
|
||||
harness.composition('compositionupdate', '')
|
||||
harness.composition('compositionend', '你')
|
||||
harness.input('insertText')
|
||||
expect(harness.tracker.isCandidateKeyGuardActive()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('handles a missing terminal element', () => {
|
||||
const tracker = installTerminalImeCompositionTracker(null)
|
||||
expect(tracker.isActive()).toBe(false)
|
||||
expect(tracker.isCandidateKeyGuardActive()).toBe(false)
|
||||
expect(() => tracker.dispose()).not.toThrow()
|
||||
})
|
||||
|
||||
it('stops tracking after dispose', () => {
|
||||
const harness = installTracker()
|
||||
harness.tracker.dispose()
|
||||
harness.composition('compositionstart', '')
|
||||
expect(harness.tracker.isActive()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -2,47 +2,112 @@ import type { IDisposable } from '@xterm/xterm'
|
|||
|
||||
export type TerminalImeCompositionTracker = IDisposable & {
|
||||
isActive: () => boolean
|
||||
/** True while candidate-selection keys (Space/digits) should be treated as
|
||||
* IME-owned: during a live composition, and briefly after compositionend to
|
||||
* absorb the committing key's trailing press/release. */
|
||||
isCandidateKeyGuardActive: () => boolean
|
||||
}
|
||||
|
||||
// Why: suppressed candidate keys are preventDefault-ed and fire no input
|
||||
// event, so a stale tracker (missed compositionend) has no natural unstick
|
||||
// path. Expire the guard so Space/digits cannot stay dead indefinitely.
|
||||
export const TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS = 10_000
|
||||
// Why: Sogou/fcitx can deliver the committing Space/digit as plain keydown and
|
||||
// keyup after compositionend; a narrow window absorbs those trailing events
|
||||
// without making the keys globally unavailable after IME use.
|
||||
export const TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS = 250
|
||||
|
||||
export function installTerminalImeCompositionTracker(
|
||||
terminalElement: HTMLElement | null | undefined
|
||||
terminalElement: HTMLElement | null | undefined,
|
||||
options?: { now?: () => number }
|
||||
): TerminalImeCompositionTracker {
|
||||
const now = options?.now ?? ((): number => Date.now())
|
||||
let active = false
|
||||
let lastCompositionEventAt: number | null = null
|
||||
let compositionEndedAt: number | null = null
|
||||
let sawEmptyCompositionUpdate = false
|
||||
|
||||
const isActiveAt = (at: number): boolean =>
|
||||
active &&
|
||||
(lastCompositionEventAt === null ||
|
||||
at - lastCompositionEventAt <= TERMINAL_IME_CANDIDATE_GUARD_STALE_COMPOSITION_EXPIRY_MS)
|
||||
|
||||
const isCandidateKeyGuardActive = (): boolean => {
|
||||
const at = now()
|
||||
if (isActiveAt(at)) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
compositionEndedAt !== null &&
|
||||
at - compositionEndedAt <= TERMINAL_IME_CANDIDATE_GUARD_POST_COMPOSITION_MS
|
||||
)
|
||||
}
|
||||
|
||||
if (!terminalElement) {
|
||||
return {
|
||||
isActive: () => active,
|
||||
isCandidateKeyGuardActive,
|
||||
dispose: () => undefined
|
||||
}
|
||||
}
|
||||
|
||||
const markActive = (): void => {
|
||||
active = true
|
||||
lastCompositionEventAt = now()
|
||||
compositionEndedAt = null
|
||||
sawEmptyCompositionUpdate = false
|
||||
}
|
||||
const updateComposition = (event: Event): void => {
|
||||
active = !(event instanceof CompositionEvent) || event.data !== ''
|
||||
lastCompositionEventAt = now()
|
||||
// Why: Sogou/fcitx can emit empty compositionupdate data while its
|
||||
// candidate popup is still open — empty data must not deactivate.
|
||||
// compositionend, non-composition input, and blur own deactivation.
|
||||
if (!(event instanceof CompositionEvent)) {
|
||||
return
|
||||
}
|
||||
if (event.data === '') {
|
||||
sawEmptyCompositionUpdate = true
|
||||
return
|
||||
}
|
||||
active = true
|
||||
}
|
||||
const handleCompositionEnd = (): void => {
|
||||
active = false
|
||||
// Why: only Sogou/fcitx-style empty updates prove a trailing plain
|
||||
// Space/digit is likely IME-owned; broad post-end guards drop real typing.
|
||||
compositionEndedAt = sawEmptyCompositionUpdate ? now() : null
|
||||
sawEmptyCompositionUpdate = false
|
||||
}
|
||||
const handleInput = (event: Event): void => {
|
||||
if (event instanceof InputEvent && event.inputType === 'insertCompositionText') {
|
||||
return
|
||||
}
|
||||
active = false
|
||||
// Why: real non-composition input means ordinary typing resumed; keeping
|
||||
// the post-end window would swallow a legitimate Space/digit.
|
||||
compositionEndedAt = null
|
||||
sawEmptyCompositionUpdate = false
|
||||
}
|
||||
const markInactive = (): void => {
|
||||
active = false
|
||||
lastCompositionEventAt = null
|
||||
compositionEndedAt = null
|
||||
sawEmptyCompositionUpdate = false
|
||||
}
|
||||
|
||||
terminalElement.addEventListener('compositionstart', markActive, true)
|
||||
terminalElement.addEventListener('compositionupdate', updateComposition, true)
|
||||
terminalElement.addEventListener('compositionend', markInactive, true)
|
||||
terminalElement.addEventListener('compositionend', handleCompositionEnd, true)
|
||||
terminalElement.addEventListener('input', handleInput, true)
|
||||
terminalElement.addEventListener('blur', markInactive, true)
|
||||
|
||||
return {
|
||||
isActive: () => active,
|
||||
isActive: () => isActiveAt(now()),
|
||||
isCandidateKeyGuardActive,
|
||||
dispose: () => {
|
||||
terminalElement.removeEventListener('compositionstart', markActive, true)
|
||||
terminalElement.removeEventListener('compositionupdate', updateComposition, true)
|
||||
terminalElement.removeEventListener('compositionend', markInactive, true)
|
||||
terminalElement.removeEventListener('compositionend', handleCompositionEnd, true)
|
||||
terminalElement.removeEventListener('input', handleInput, true)
|
||||
terminalElement.removeEventListener('blur', markInactive, true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,12 @@ import { showOsc52ClipboardBlockedToast } from './osc52-clipboard-blocked-toast'
|
|||
import { parseOsc7 } from './parse-osc7'
|
||||
import { resolveTerminalJisYenInput } from './terminal-jis-yen-input'
|
||||
import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker'
|
||||
import {
|
||||
armTerminalImePendingCandidateKeyRelease,
|
||||
clearTerminalImePendingCandidateKeyRelease,
|
||||
createTerminalImePendingCandidateKeyReleases,
|
||||
shouldApplyTerminalImePendingCandidateKeyRelease
|
||||
} from './terminal-ime-candidate-key-release-guard'
|
||||
import {
|
||||
DISABLED_MAC_NATIVE_TEXT_INPUT_SOURCE_FEATURES,
|
||||
getMacNativeTextInputSourceTracker
|
||||
|
|
@ -72,6 +78,7 @@ import { installTerminalImeNativeTextForwarder } from './terminal-ime-native-tex
|
|||
import {
|
||||
shouldBypassXtermKeyboardEvent,
|
||||
shouldHandleTerminalInterruptKeyboardEvent,
|
||||
shouldPreventDefaultTerminalImeCandidateKey,
|
||||
shouldSuppressTerminalImeKeyboardEvent,
|
||||
shouldSuppressTerminalInterruptKeyup,
|
||||
shouldSuppressTerminalModifierKeyboardEvent,
|
||||
|
|
@ -842,7 +849,16 @@ export function useTerminalPaneLifecycle({
|
|||
// encoder runs, letting the browser and Electron paths fire normally.
|
||||
// See xterm-bypass-policy.ts for the rule derivation.
|
||||
let pendingTerminalInterruptKeyup = false
|
||||
const pendingTerminalImeCandidateKeyReleases =
|
||||
createTerminalImePendingCandidateKeyReleases()
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
// Why: Android/ChromeOS UAs also contain "Linux"; keep the Sogou/fcitx
|
||||
// candidate-key policy scoped to desktop Linux so paired web clients on
|
||||
// those platforms keep their previous IME behavior.
|
||||
const isLinux =
|
||||
!isMac &&
|
||||
navigator.userAgent.includes('Linux') &&
|
||||
!/Android|CrOS/.test(navigator.userAgent)
|
||||
const macNativeTextInputSourceTracker = isMac ? getMacNativeTextInputSourceTracker() : null
|
||||
const imeCompositionTracker = installTerminalImeCompositionTracker(pane.terminal.element)
|
||||
imeCompositionDisposablesRef.current.set(pane.id, imeCompositionTracker)
|
||||
|
|
@ -864,14 +880,38 @@ export function useTerminalPaneLifecycle({
|
|||
}
|
||||
imeNativeTextForwarderDisposablesRef.current.set(pane.id, imeNativeTextForwarder)
|
||||
pane.terminal.attachCustomKeyEventHandler((e) => {
|
||||
if (
|
||||
shouldSuppressTerminalImeKeyboardEvent(e, {
|
||||
compositionActive: imeCompositionTracker.isActive(),
|
||||
isMac
|
||||
})
|
||||
) {
|
||||
const now = Date.now()
|
||||
const pendingCandidateReleaseGuardActive = shouldApplyTerminalImePendingCandidateKeyRelease(
|
||||
e,
|
||||
pendingTerminalImeCandidateKeyReleases,
|
||||
now
|
||||
)
|
||||
const imeKeyboardOptions = {
|
||||
compositionActive: imeCompositionTracker.isActive(),
|
||||
candidateKeyGuardActive:
|
||||
imeCompositionTracker.isCandidateKeyGuardActive() ||
|
||||
pendingCandidateReleaseGuardActive,
|
||||
pendingCandidateKeyReleaseActive: pendingCandidateReleaseGuardActive,
|
||||
isMac,
|
||||
isLinux
|
||||
}
|
||||
if (shouldSuppressTerminalImeKeyboardEvent(e, imeKeyboardOptions)) {
|
||||
// Why: clear before arm — a fresh keydown drops any stale pending
|
||||
// release for its key before the new press arms its own.
|
||||
clearTerminalImePendingCandidateKeyRelease(pendingTerminalImeCandidateKeyReleases, e)
|
||||
if (shouldPreventDefaultTerminalImeCandidateKey(e, imeKeyboardOptions)) {
|
||||
// Why: without preventDefault the suppressed candidate keydown
|
||||
// still fires a keypress and mutates the helper textarea.
|
||||
e.preventDefault()
|
||||
armTerminalImePendingCandidateKeyRelease(
|
||||
pendingTerminalImeCandidateKeyReleases,
|
||||
e,
|
||||
now
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
clearTerminalImePendingCandidateKeyRelease(pendingTerminalImeCandidateKeyReleases, e)
|
||||
if (pendingTerminalInterruptKeyup && shouldSuppressTerminalInterruptKeyup(e)) {
|
||||
pendingTerminalInterruptKeyup = false
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import type { XtermBypassEvent } from './xterm-bypass-policy'
|
||||
|
||||
// Shared test fixture: builds a fully-defaulted XtermBypassEvent so the bypass
|
||||
// and IME candidate-guard suites all stay in sync when the event shape changes.
|
||||
export function event(overrides: Partial<XtermBypassEvent>): XtermBypassEvent {
|
||||
return {
|
||||
type: 'keydown',
|
||||
key: '',
|
||||
code: '',
|
||||
defaultPrevented: false,
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +1,10 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
shouldBypassXtermKeyboardEvent,
|
||||
shouldSuppressTerminalImeKeyboardEvent,
|
||||
type XtermBypassEvent
|
||||
shouldPreventDefaultTerminalImeCandidateKey,
|
||||
shouldSuppressTerminalImeKeyboardEvent
|
||||
} 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
|
||||
}
|
||||
}
|
||||
import { event } from './xterm-bypass-event-fixture'
|
||||
|
||||
describe('shouldBypassXtermKeyboardEvent — Windows/Linux', () => {
|
||||
const withSel = { isMac: false, hasSelection: true }
|
||||
|
|
@ -161,60 +148,256 @@ describe('shouldBypassXtermKeyboardEvent — Windows/Linux', () => {
|
|||
})
|
||||
|
||||
describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => {
|
||||
const idle = { isMac: false, compositionActive: false }
|
||||
const composing = { isMac: false, compositionActive: true }
|
||||
const windowsIdle = {
|
||||
isMac: false,
|
||||
isLinux: false,
|
||||
compositionActive: false,
|
||||
candidateKeyGuardActive: false,
|
||||
pendingCandidateKeyReleaseActive: false
|
||||
}
|
||||
const linuxIdle = {
|
||||
isMac: false,
|
||||
isLinux: true,
|
||||
compositionActive: false,
|
||||
candidateKeyGuardActive: false,
|
||||
pendingCandidateKeyReleaseActive: false
|
||||
}
|
||||
const linuxComposing = { ...linuxIdle, compositionActive: true, candidateKeyGuardActive: true }
|
||||
// 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 windowsComposing = { ...windowsIdle, compositionActive: true, candidateKeyGuardActive: true }
|
||||
|
||||
it('suppresses keyboard events while Chromium reports active IME composition', () => {
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ key: 'Backspace', code: 'Backspace', isComposing: true }),
|
||||
idle
|
||||
)
|
||||
).toBe(true)
|
||||
for (const options of [windowsIdle, linuxIdle]) {
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ key: 'Backspace', code: 'Backspace', isComposing: true }),
|
||||
options
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('suppresses Windows IME Process keys', () => {
|
||||
// Why: Windows preedit can hit the textarea before compositionstart;
|
||||
// letting the 229 keydown through would flush it via xterm's textarea diff.
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ key: 'Process', code: 'KeyN', keyCode: 229 }),
|
||||
idle
|
||||
windowsIdle
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not suppress ordinary Backspace outside IME composition', () => {
|
||||
it('lets standalone Linux 229 keydowns reach xterm so its CompositionHelper can diff text', () => {
|
||||
// Why: Sogou/fcitx candidate commits can ride a bare 229 keydown outside a
|
||||
// composition session; xterm must see it to schedule its textarea diff.
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(event({ key: 'Backspace', code: 'Backspace' }), idle)
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ key: 'Process', code: 'KeyN', keyCode: 229 }),
|
||||
linuxIdle
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('suppresses Linux 229 keydowns while the composition tracker is active', () => {
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ key: 'Process', code: 'KeyN', keyCode: 229 }),
|
||||
linuxComposing
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('suppresses 229 / Process keyups so kitty release reporting cannot leak', () => {
|
||||
for (const options of [windowsIdle, linuxIdle]) {
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ type: 'keyup', key: 'Process', code: 'KeyN', keyCode: 229 }),
|
||||
options
|
||||
)
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not suppress ordinary Backspace outside IME composition', () => {
|
||||
for (const options of [windowsIdle, linuxIdle]) {
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ key: 'Backspace', code: 'Backspace' }),
|
||||
options
|
||||
)
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('suppresses IME-owned editing keys while composition is active', () => {
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ key: 'Backspace', code: 'Backspace' }),
|
||||
composing
|
||||
linuxComposing
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ key: 'ArrowDown', code: 'ArrowDown' }),
|
||||
composing
|
||||
linuxComposing
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not suppress ordinary text keys solely because composition is active', () => {
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(event({ key: 'a', code: 'KeyA' }), composing)
|
||||
shouldSuppressTerminalImeKeyboardEvent(event({ key: 'a', code: 'KeyA' }), linuxComposing)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not suppress keypress events because they carry committed text', () => {
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ type: 'keypress', key: '中', code: '', isComposing: true }),
|
||||
idle
|
||||
)
|
||||
).toBe(false)
|
||||
for (const options of [windowsIdle, linuxIdle]) {
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ type: 'keypress', key: '中', code: '', isComposing: true }),
|
||||
options
|
||||
)
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
describe('candidate-selection keys (Sogou Space/digit commit)', () => {
|
||||
it('suppresses Space and digit keydowns and keyups while the candidate guard is active', () => {
|
||||
for (const options of [linuxComposing, linuxPostCompositionGuard]) {
|
||||
for (const key of [' ', '0', '2', '9']) {
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(event({ key, code: '' }), options)
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(event({ type: 'keyup', key, code: '' }), options)
|
||||
).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('suppresses the follow-on candidate keypress so _keyPress cannot forward the selector', () => {
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ type: 'keypress', key: ' ', code: 'Space' }),
|
||||
linuxPostCompositionGuard
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ type: 'keypress', key: '2', code: 'Digit2' }),
|
||||
linuxComposing
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves Space and digits alone once the guard has expired', () => {
|
||||
for (const type of ['keydown', 'keyup', 'keypress']) {
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(event({ type, key: ' ', code: 'Space' }), linuxIdle)
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ type, key: '2', code: 'Digit2' }),
|
||||
linuxIdle
|
||||
)
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not treat modified chords such as Ctrl+Space (IME toggle) as candidate keys', () => {
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ key: ' ', code: 'Space', ctrlKey: true }),
|
||||
linuxPostCompositionGuard
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not treat Shift+Space (fcitx full-/half-width toggle) as a candidate key', () => {
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ key: ' ', code: 'Space', shiftKey: true }),
|
||||
linuxPostCompositionGuard
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves ordinary letters unsuppressed while the guard is active', () => {
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ key: 'a', code: 'KeyA' }),
|
||||
linuxPostCompositionGuard
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not apply the Linux/Sogou candidate guard to Windows', () => {
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ key: ' ', code: 'Space' }),
|
||||
windowsComposing
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ type: 'keypress', key: '2', code: 'Digit2' }),
|
||||
windowsComposing
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('suppresses a pending Linux candidate release even if modifier state changed after keydown', () => {
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ type: 'keyup', key: '2', code: 'Digit2', shiftKey: true }),
|
||||
{
|
||||
...linuxIdle,
|
||||
candidateKeyGuardActive: true,
|
||||
pendingCandidateKeyReleaseActive: true
|
||||
}
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldPreventDefaultTerminalImeCandidateKey', () => {
|
||||
it('prevents the default on candidate keydowns while the guard is active', () => {
|
||||
expect(
|
||||
shouldPreventDefaultTerminalImeCandidateKey(
|
||||
event({ key: ' ', code: 'Space' }),
|
||||
linuxComposing
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldPreventDefaultTerminalImeCandidateKey(
|
||||
event({ key: '2', code: 'Digit2' }),
|
||||
linuxPostCompositionGuard
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not prevent the default for keyups, expired guards, or non-candidate keys', () => {
|
||||
expect(
|
||||
shouldPreventDefaultTerminalImeCandidateKey(
|
||||
event({ type: 'keyup', key: ' ', code: 'Space' }),
|
||||
linuxComposing
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldPreventDefaultTerminalImeCandidateKey(event({ key: ' ', code: 'Space' }), linuxIdle)
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldPreventDefaultTerminalImeCandidateKey(event({ key: 'a', code: 'KeyA' }), linuxComposing)
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldPreventDefaultTerminalImeCandidateKey(
|
||||
event({ key: ' ', code: 'Space' }),
|
||||
windowsComposing
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,23 +1,10 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
shouldBypassXtermKeyboardEvent,
|
||||
shouldSuppressTerminalImeKeyboardEvent,
|
||||
type XtermBypassEvent
|
||||
shouldPreventDefaultTerminalImeCandidateKey,
|
||||
shouldSuppressTerminalImeKeyboardEvent
|
||||
} 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
|
||||
}
|
||||
}
|
||||
import { event } from './xterm-bypass-event-fixture'
|
||||
|
||||
describe('shouldBypassXtermKeyboardEvent — macOS', () => {
|
||||
const opts = { isMac: true, hasSelection: true }
|
||||
|
|
@ -164,8 +151,20 @@ describe('shouldBypassXtermKeyboardEvent — macOS', () => {
|
|||
})
|
||||
|
||||
describe('shouldSuppressTerminalImeKeyboardEvent — macOS', () => {
|
||||
const idle = { isMac: true, compositionActive: false }
|
||||
const composing = { isMac: true, compositionActive: true }
|
||||
const idle = {
|
||||
isMac: true,
|
||||
isLinux: false,
|
||||
compositionActive: false,
|
||||
candidateKeyGuardActive: false,
|
||||
pendingCandidateKeyReleaseActive: false
|
||||
}
|
||||
const composing = {
|
||||
isMac: true,
|
||||
isLinux: false,
|
||||
compositionActive: true,
|
||||
candidateKeyGuardActive: true,
|
||||
pendingCandidateKeyReleaseActive: false
|
||||
}
|
||||
|
||||
it('suppresses keyboard events while Chromium reports active IME composition', () => {
|
||||
expect(
|
||||
|
|
@ -238,4 +237,19 @@ describe('shouldSuppressTerminalImeKeyboardEvent — macOS', () => {
|
|||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not apply the Linux/Sogou candidate guard to macOS', () => {
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(event({ key: ' ', code: 'Space' }), composing)
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldSuppressTerminalImeKeyboardEvent(
|
||||
event({ type: 'keypress', key: '2', code: 'Digit2' }),
|
||||
composing
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
shouldPreventDefaultTerminalImeCandidateKey(event({ key: ' ', code: 'Space' }), composing)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { keybindingMatchesInput } from '../../../../shared/keybindings'
|
||||
import { 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 —
|
||||
|
|
@ -18,6 +19,7 @@ export type XtermBypassEvent = {
|
|||
code?: string
|
||||
keyCode?: number
|
||||
isComposing?: boolean
|
||||
repeat?: boolean
|
||||
defaultPrevented?: boolean
|
||||
metaKey: boolean
|
||||
ctrlKey: boolean
|
||||
|
|
@ -35,9 +37,18 @@ export type XtermBypassOptions = {
|
|||
|
||||
export type XtermImeKeyboardOptions = {
|
||||
compositionActive: boolean
|
||||
/** True while Linux/Sogou candidate-selection keys (Space/digits) are
|
||||
* IME-owned: live composition plus a short post-compositionend window. */
|
||||
candidateKeyGuardActive: boolean
|
||||
/** True when the pending-release guard already matched this specific event. */
|
||||
pendingCandidateKeyReleaseActive: 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
|
||||
// Required Linux/Windows split: Linux passes standalone 229 keydowns like
|
||||
// macOS; the Windows-only suppression guards its preedit-diff race (preedit
|
||||
// can hit the textarea before compositionstart and be flushed by the diff).
|
||||
isLinux: boolean
|
||||
}
|
||||
|
||||
export const TERMINAL_INTERRUPT_INPUT = '\x03'
|
||||
|
|
@ -74,18 +85,55 @@ export function shouldSuppressTerminalImeKeyboardEvent(
|
|||
event: XtermBypassEvent,
|
||||
options: XtermImeKeyboardOptions
|
||||
): boolean {
|
||||
const {
|
||||
compositionActive,
|
||||
candidateKeyGuardActive,
|
||||
pendingCandidateKeyReleaseActive,
|
||||
isMac,
|
||||
isLinux
|
||||
} = options
|
||||
const suppressCandidateKey =
|
||||
isLinux &&
|
||||
(pendingCandidateKeyReleaseActive ||
|
||||
(candidateKeyGuardActive && isTerminalImeCandidateSelectionKeyEvent(event)))
|
||||
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
|
||||
// literal Space/digit to the PTY.
|
||||
return suppressCandidateKey
|
||||
}
|
||||
if (!isXtermHandledKeyEvent(event.type)) {
|
||||
return false
|
||||
}
|
||||
const { compositionActive, isMac } = options
|
||||
// Why: IMEs own Process-key / composing keystrokes — letting xterm translate
|
||||
// them corrupts committed CJK text. Bare macOS keydown 229 is exempt: it must
|
||||
// reach xterm's CompositionHelper or the first key after an input-source
|
||||
// switch is swallowed.
|
||||
// them corrupts committed CJK text. Bare macOS/Linux keydown 229 is exempt:
|
||||
// it must reach xterm's CompositionHelper so it can schedule its textarea
|
||||
// diff (macOS: first key after an input-source switch; Linux: Sogou/fcitx
|
||||
// candidate commits outside a composition session). Windows keeps full
|
||||
// suppression until verified against its preedit-diff race.
|
||||
const passesStandalone229Keydown = isMac || isLinux
|
||||
return (
|
||||
event.isComposing === true ||
|
||||
(event.keyCode === 229 && (event.type !== 'keydown' || compositionActive || !isMac)) ||
|
||||
(compositionActive && TERMINAL_IME_OWNED_KEYS.has(event.key))
|
||||
(event.keyCode === 229 &&
|
||||
(event.type !== 'keydown' || compositionActive || !passesStandalone229Keydown)) ||
|
||||
(compositionActive && TERMINAL_IME_OWNED_KEYS.has(event.key)) ||
|
||||
suppressCandidateKey
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldPreventDefaultTerminalImeCandidateKey(
|
||||
event: XtermBypassEvent,
|
||||
options: XtermImeKeyboardOptions
|
||||
): boolean {
|
||||
// Why: returning false from attachCustomKeyEventHandler does not
|
||||
// preventDefault — the candidate keydown would still fire a keypress and
|
||||
// write into the helper textarea, where a later 229 diff could flush the
|
||||
// leaked selector to the PTY.
|
||||
return (
|
||||
event.type === 'keydown' &&
|
||||
options.isLinux &&
|
||||
options.candidateKeyGuardActive &&
|
||||
isTerminalImeCandidateSelectionKeyEvent(event)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ function stripTerminalControls(value: string): string {
|
|||
const CODEX_READY_RE = /Ask Codex|OpenAI/i
|
||||
const CODEX_TRUST_PROMPT_RE = /Do you trust|trust this folder|Trust this/i
|
||||
const CODEX_UPDATE_PROMPT_RE = /update available|install update|Skip for now/i
|
||||
const LINUX_IME_POLICY_USER_AGENT =
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/146 Safari/537.36'
|
||||
|
||||
function terminalImeHarnessScript(runId: string): string {
|
||||
return `
|
||||
|
|
@ -229,6 +231,9 @@ async function installImeEventProbe(page: Page): Promise<void> {
|
|||
]) {
|
||||
textarea.addEventListener(type, record, true)
|
||||
}
|
||||
// Why: the Linux/Sogou post-composition guard lives on the terminal
|
||||
// element; record terminal-targeted compositionend without doubling key logs.
|
||||
pane.terminal.element?.addEventListener('compositionend', record, true)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -239,6 +244,19 @@ async function readImeEventLog(page: Page): Promise<ImeEventLogEntry[]> {
|
|||
})
|
||||
}
|
||||
|
||||
async function reloadWithLinuxImePolicy(page: Page): Promise<void> {
|
||||
await page.addInitScript((userAgent) => {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
get: () => userAgent,
|
||||
configurable: true
|
||||
})
|
||||
}, LINUX_IME_POLICY_USER_AGENT)
|
||||
// Why: the Sogou repro validates Linux-gated terminal IME policy on macOS
|
||||
// runners; reload before the terminal pane mounts so lifecycle code sees it.
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 })
|
||||
}
|
||||
|
||||
async function readPromptState(page: Page): Promise<TerminalPromptState | null> {
|
||||
const content = stripTerminalControls(await getTerminalContent(page, 20_000))
|
||||
const matches = [...content.matchAll(/\[SUBMITTED_JSON_[^\]]+\]("[\s\S]*?")/g)]
|
||||
|
|
@ -320,6 +338,64 @@ async function dispatchImeProcessKey(session: CDPSession, code: string): Promise
|
|||
})
|
||||
}
|
||||
|
||||
async function dispatchCandidateSelectionKey(
|
||||
session: CDPSession,
|
||||
candidate: { key: string; code: string; keyCode: number },
|
||||
commitBetweenKeys?: () => Promise<void>
|
||||
): Promise<void> {
|
||||
// Why: #7543 proves Sogou can forward candidate selectors as plain key
|
||||
// events, not Process/229. `text` is set so keyDown produces the natural
|
||||
// keypress — rawKeyDown generates none and would prove nothing about leaks.
|
||||
await session.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyDown',
|
||||
key: candidate.key,
|
||||
code: candidate.code,
|
||||
windowsVirtualKeyCode: candidate.keyCode,
|
||||
nativeVirtualKeyCode: candidate.keyCode,
|
||||
text: candidate.key,
|
||||
unmodifiedText: candidate.key
|
||||
})
|
||||
// Why: committing between keyDown and keyUp keeps xterm's _keyDownSeen set,
|
||||
// matching the trace shape where an insertText commit is actually at risk.
|
||||
await commitBetweenKeys?.()
|
||||
await session.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
key: candidate.key,
|
||||
code: candidate.code,
|
||||
windowsVirtualKeyCode: candidate.keyCode,
|
||||
nativeVirtualKeyCode: candidate.keyCode
|
||||
})
|
||||
}
|
||||
|
||||
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.
|
||||
await page.evaluate(() => {
|
||||
const active = document.activeElement
|
||||
if (!(active instanceof HTMLTextAreaElement)) {
|
||||
throw new Error('xterm helper textarea is not focused')
|
||||
}
|
||||
active.dispatchEvent(new CompositionEvent('compositionupdate', { data: '', bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
async function dispatchSogouPostCompositionEnd(page: Page, data: string): Promise<void> {
|
||||
// Why: some Sogou/fcitx traces deliver the plain selector key after
|
||||
// compositionend; target the terminal element so Orca's tracker sees the end
|
||||
// without making xterm finalize a synthetic preedit string.
|
||||
await page.evaluate((data) => {
|
||||
const active = document.activeElement
|
||||
if (!(active instanceof HTMLTextAreaElement)) {
|
||||
throw new Error('xterm helper textarea is not focused')
|
||||
}
|
||||
const terminalElement = active.closest('.xterm')
|
||||
if (!(terminalElement instanceof HTMLElement)) {
|
||||
throw new Error('xterm terminal element was not found')
|
||||
}
|
||||
terminalElement.dispatchEvent(new CompositionEvent('compositionend', { data, bubbles: false }))
|
||||
}, data)
|
||||
}
|
||||
|
||||
async function composeAndCommitChineseText(
|
||||
session: CDPSession,
|
||||
page: Page,
|
||||
|
|
@ -473,6 +549,124 @@ test.describe('Chinese IME terminal chat input repro', () => {
|
|||
}
|
||||
})
|
||||
|
||||
test('keeps Sogou-style candidate selection keys out of the PTY while committing Chinese text', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
await reloadWithLinuxImePolicy(orcaPage)
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
const runId = randomUUID()
|
||||
const scriptPath = path.join(testRepoPath, `.orca-sogou-ime-harness-${runId}.cjs`)
|
||||
let session: CDPSession | null = null
|
||||
let harnessStarted = false
|
||||
|
||||
try {
|
||||
// Why: create the session/harness inside the try so a mid-setup throw
|
||||
// still hits finally and removes the harness script.
|
||||
writeFileSync(scriptPath, terminalImeHarnessScript(runId))
|
||||
session = await orcaPage.context().newCDPSession(orcaPage)
|
||||
await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
|
||||
harnessStarted = true
|
||||
await waitForTerminalOutput(orcaPage, `IME_HARNESS_READY_${runId}`, 10_000, 20_000)
|
||||
await focusActiveTerminalInput(orcaPage)
|
||||
await installImeEventProbe(orcaPage)
|
||||
|
||||
// Space selects the first candidate. Sogou keeps emitting empty
|
||||
// compositionupdate frames while the popup is open, and the plain Space
|
||||
// press arrives around the commit rather than as a Process/229 key.
|
||||
await setImeComposition(session, 'n')
|
||||
await orcaPage.waitForTimeout(80)
|
||||
await setImeComposition(session, 'ni')
|
||||
await orcaPage.waitForTimeout(80)
|
||||
await dispatchSogouEmptyCompositionUpdate(orcaPage)
|
||||
await dispatchCandidateSelectionKey(session, { key: ' ', code: 'Space', keyCode: 32 }, () =>
|
||||
commitImeText(session, '你')
|
||||
)
|
||||
await waitForLivePrompt(orcaPage, '你')
|
||||
await attachImeEvidence(orcaPage, testInfo, 'sogou-after-space-commit')
|
||||
await orcaPage.keyboard.press('Enter')
|
||||
await expect
|
||||
.poll(async () => (await readPromptState(orcaPage))?.submitted.at(-1) ?? null, {
|
||||
timeout: 5_000,
|
||||
message: 'space-selected candidate did not submit the committed Chinese character'
|
||||
})
|
||||
.toBe('你')
|
||||
|
||||
// Digit selects a non-first candidate for a word/phrase commit — the
|
||||
// #7543 shape where only the number used to reach the TUI.
|
||||
await setImeComposition(session, 'nihao')
|
||||
await orcaPage.waitForTimeout(80)
|
||||
await dispatchSogouEmptyCompositionUpdate(orcaPage)
|
||||
await dispatchCandidateSelectionKey(session, { key: '2', code: 'Digit2', keyCode: 50 }, () =>
|
||||
commitImeText(session, '你好')
|
||||
)
|
||||
await waitForLivePrompt(orcaPage, '你好')
|
||||
await attachImeEvidence(orcaPage, testInfo, 'sogou-after-digit-commit')
|
||||
await orcaPage.keyboard.press('Enter')
|
||||
await expect
|
||||
.poll(async () => (await readPromptState(orcaPage))?.submitted.at(-1) ?? null, {
|
||||
timeout: 5_000,
|
||||
message: 'digit-selected candidate did not submit the committed Chinese phrase'
|
||||
})
|
||||
.toBe('你好')
|
||||
|
||||
// Post-composition traces can deliver the selector after compositionend;
|
||||
// the short post-end guard must still keep that plain digit out of the PTY.
|
||||
const postCompositionLogStart = (await readImeEventLog(orcaPage)).length
|
||||
await setImeComposition(session, 'zaijian')
|
||||
await orcaPage.waitForTimeout(80)
|
||||
await dispatchSogouEmptyCompositionUpdate(orcaPage)
|
||||
await dispatchSogouPostCompositionEnd(orcaPage, '再见')
|
||||
await dispatchCandidateSelectionKey(session, { key: '3', code: 'Digit3', keyCode: 51 }, () =>
|
||||
commitImeText(session, '再见')
|
||||
)
|
||||
await waitForLivePrompt(orcaPage, '再见')
|
||||
const postCompositionLog = await readImeEventLog(orcaPage)
|
||||
const postCompositionEndIndex = postCompositionLog.findIndex(
|
||||
(entry, index) =>
|
||||
index >= postCompositionLogStart && entry.type === 'compositionend' && entry.data === '再见'
|
||||
)
|
||||
const postCompositionSelectorIndex = postCompositionLog.findIndex(
|
||||
(entry, index) =>
|
||||
index > postCompositionEndIndex && entry.type === 'keydown' && entry.key === '3'
|
||||
)
|
||||
expect(
|
||||
postCompositionEndIndex,
|
||||
'Sogou post-composition repro must dispatch compositionend before the plain selector'
|
||||
).toBeGreaterThanOrEqual(postCompositionLogStart)
|
||||
expect(
|
||||
postCompositionSelectorIndex,
|
||||
'plain digit selector must arrive after compositionend in the post-composition repro'
|
||||
).toBeGreaterThan(postCompositionEndIndex)
|
||||
await attachImeEvidence(orcaPage, testInfo, 'sogou-after-post-composition-digit-commit')
|
||||
await orcaPage.keyboard.press('Enter')
|
||||
await expect
|
||||
.poll(async () => (await readPromptState(orcaPage))?.submitted.at(-1) ?? null, {
|
||||
timeout: 5_000,
|
||||
message: 'post-composition digit-selected candidate did not submit cleanly'
|
||||
})
|
||||
.toBe('再见')
|
||||
|
||||
const promptState = await readPromptState(orcaPage)
|
||||
expect(
|
||||
promptState?.submitted,
|
||||
'candidate Space/digit selectors and pinyin preedit must not leak into the PTY'
|
||||
).toEqual(['你', '你好', '再见'])
|
||||
} finally {
|
||||
await attachImeEvidence(orcaPage, testInfo, 'sogou-final-ime-evidence').catch(() => undefined)
|
||||
await session?.detach().catch(() => undefined)
|
||||
if (harnessStarted) {
|
||||
await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined)
|
||||
}
|
||||
rmSync(scriptPath, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps composed Chinese text stable in the real Codex TUI input @real-codex-ime', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue