fix: preserve terminal IME composition input

This commit is contained in:
Jinwoo Hong
2026-06-25 18:50:06 -07:00
parent 4e45df92ed
commit 2d9e866840
6 changed files with 761 additions and 2 deletions
@@ -0,0 +1,50 @@
import type { IDisposable } from '@xterm/xterm'
export type TerminalImeCompositionTracker = IDisposable & {
isActive: () => boolean
}
export function installTerminalImeCompositionTracker(
terminalElement: HTMLElement | null | undefined
): TerminalImeCompositionTracker {
let active = false
if (!terminalElement) {
return {
isActive: () => active,
dispose: () => undefined
}
}
const markActive = (): void => {
active = true
}
const updateComposition = (event: Event): void => {
active = !(event instanceof CompositionEvent) || event.data !== ''
}
const handleInput = (event: Event): void => {
if (event instanceof InputEvent && event.inputType === 'insertCompositionText') {
return
}
active = false
}
const markInactive = (): void => {
active = false
}
terminalElement.addEventListener('compositionstart', markActive, true)
terminalElement.addEventListener('compositionupdate', updateComposition, true)
terminalElement.addEventListener('compositionend', markInactive, true)
terminalElement.addEventListener('input', handleInput, true)
terminalElement.addEventListener('blur', markInactive, true)
return {
isActive: () => active,
dispose: () => {
terminalElement.removeEventListener('compositionstart', markActive, true)
terminalElement.removeEventListener('compositionupdate', updateComposition, true)
terminalElement.removeEventListener('compositionend', markInactive, true)
terminalElement.removeEventListener('input', handleInput, true)
terminalElement.removeEventListener('blur', markInactive, true)
}
}
}
@@ -54,9 +54,11 @@ import { handleOsc52ClipboardRequest } from './osc52-clipboard'
import { showOsc52ClipboardBlockedToast } from './osc52-clipboard-blocked-toast'
import { parseOsc7 } from './parse-osc7'
import { resolveTerminalJisYenInput } from './terminal-jis-yen-input'
import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker'
import {
shouldBypassXtermKeyboardEvent,
shouldHandleTerminalInterruptKeyboardEvent,
shouldSuppressTerminalImeKeyboardEvent,
shouldSuppressTerminalInterruptKeyup,
shouldSuppressTerminalModifierKeyboardEvent,
TERMINAL_INTERRUPT_INPUT
@@ -429,6 +431,7 @@ export function useTerminalPaneLifecycle({
const osc52DisposablesRef = useRef(new Map<number, IDisposable>())
const osc7DisposablesRef = useRef(new Map<number, IDisposable>())
const mouseHideDisposablesRef = useRef(new Map<number, IDisposable>())
const imeCompositionDisposablesRef = useRef(new Map<number, IDisposable>())
const applyAppearance = (manager: PaneManager): void => {
const currentSettings = settingsRef.current
@@ -494,6 +497,7 @@ export function useTerminalPaneLifecycle({
const selectionDisposables = selectionDisposablesRef.current
const selectionCaptureTimers = selectionCaptureTimersRef.current
const mouseHideDisposables = mouseHideDisposablesRef.current
const imeCompositionDisposables = imeCompositionDisposablesRef.current
const worktreePath =
useAppStore
.getState()
@@ -684,8 +688,17 @@ 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 imeCompositionTracker = installTerminalImeCompositionTracker(pane.terminal.element)
imeCompositionDisposablesRef.current.set(pane.id, imeCompositionTracker)
pane.terminal.attachCustomKeyEventHandler((e) => {
const isMac = navigator.userAgent.includes('Mac')
if (
shouldSuppressTerminalImeKeyboardEvent(e, {
compositionActive: imeCompositionTracker.isActive()
})
) {
return false
}
if (pendingTerminalInterruptKeyup && shouldSuppressTerminalInterruptKeyup(e)) {
pendingTerminalInterruptKeyup = false
return false
@@ -918,6 +931,11 @@ export function useTerminalPaneLifecycle({
selectionDisposable.dispose()
selectionDisposablesRef.current.delete(paneId)
}
const imeCompositionDisposable = imeCompositionDisposablesRef.current.get(paneId)
if (imeCompositionDisposable) {
imeCompositionDisposable.dispose()
imeCompositionDisposablesRef.current.delete(paneId)
}
const selectionCaptureTimer = selectionCaptureTimersRef.current.get(paneId)
if (selectionCaptureTimer !== undefined) {
window.clearTimeout(selectionCaptureTimer)
@@ -1398,6 +1416,10 @@ export function useTerminalPaneLifecycle({
disposable.dispose()
}
mouseHideDisposables.clear()
for (const disposable of imeCompositionDisposables.values()) {
disposable.dispose()
}
imeCompositionDisposables.clear()
for (const transport of paneTransports.values()) {
const ptyId = transport.getPtyId()
if (
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { shouldBypassXtermKeyboardEvent, type XtermBypassEvent } from './xterm-bypass-policy'
import {
shouldBypassXtermKeyboardEvent,
shouldSuppressTerminalImeKeyboardEvent,
type XtermBypassEvent
} from './xterm-bypass-policy'
function event(overrides: Partial<XtermBypassEvent>): XtermBypassEvent {
return {
@@ -155,3 +159,54 @@ describe('shouldBypassXtermKeyboardEvent — Windows/Linux', () => {
).toBe(false)
})
})
describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => {
it('suppresses keyboard events while Chromium reports active IME composition', () => {
expect(
shouldSuppressTerminalImeKeyboardEvent(
event({ key: 'Backspace', code: 'Backspace', isComposing: true })
)
).toBe(true)
})
it('suppresses Windows IME Process keys', () => {
expect(
shouldSuppressTerminalImeKeyboardEvent(event({ key: 'Process', code: 'KeyN', keyCode: 229 }))
).toBe(true)
})
it('does not suppress ordinary Backspace outside IME composition', () => {
expect(
shouldSuppressTerminalImeKeyboardEvent(event({ key: 'Backspace', code: 'Backspace' }))
).toBe(false)
})
it('suppresses IME-owned editing keys while composition is active', () => {
expect(
shouldSuppressTerminalImeKeyboardEvent(event({ key: 'Backspace', code: 'Backspace' }), {
compositionActive: true
})
).toBe(true)
expect(
shouldSuppressTerminalImeKeyboardEvent(event({ key: 'ArrowDown', code: 'ArrowDown' }), {
compositionActive: true
})
).toBe(true)
})
it('does not suppress ordinary text keys solely because composition is active', () => {
expect(
shouldSuppressTerminalImeKeyboardEvent(event({ key: 'a', code: 'KeyA' }), {
compositionActive: true
})
).toBe(false)
})
it('does not suppress keypress events because they carry committed text', () => {
expect(
shouldSuppressTerminalImeKeyboardEvent(
event({ type: 'keypress', key: '中', code: '', isComposing: true })
)
).toBe(false)
})
})
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { shouldBypassXtermKeyboardEvent, type XtermBypassEvent } from './xterm-bypass-policy'
import {
shouldBypassXtermKeyboardEvent,
shouldSuppressTerminalImeKeyboardEvent,
type XtermBypassEvent
} from './xterm-bypass-policy'
function event(overrides: Partial<XtermBypassEvent>): XtermBypassEvent {
return {
@@ -158,3 +162,54 @@ describe('shouldBypassXtermKeyboardEvent — macOS', () => {
).toBe(false)
})
})
describe('shouldSuppressTerminalImeKeyboardEvent — macOS', () => {
it('suppresses keyboard events while Chromium reports active IME composition', () => {
expect(
shouldSuppressTerminalImeKeyboardEvent(
event({ key: 'Backspace', code: 'Backspace', isComposing: true })
)
).toBe(true)
})
it('suppresses Windows IME Process keys', () => {
expect(
shouldSuppressTerminalImeKeyboardEvent(event({ key: 'Process', code: 'KeyN', keyCode: 229 }))
).toBe(true)
})
it('does not suppress ordinary Backspace outside IME composition', () => {
expect(
shouldSuppressTerminalImeKeyboardEvent(event({ key: 'Backspace', code: 'Backspace' }))
).toBe(false)
})
it('suppresses IME-owned editing keys while composition is active', () => {
expect(
shouldSuppressTerminalImeKeyboardEvent(event({ key: 'Backspace', code: 'Backspace' }), {
compositionActive: true
})
).toBe(true)
expect(
shouldSuppressTerminalImeKeyboardEvent(event({ key: 'ArrowDown', code: 'ArrowDown' }), {
compositionActive: true
})
).toBe(true)
})
it('does not suppress ordinary text keys solely because composition is active', () => {
expect(
shouldSuppressTerminalImeKeyboardEvent(event({ key: 'a', code: 'KeyA' }), {
compositionActive: true
})
).toBe(false)
})
it('does not suppress keypress events because they carry committed text', () => {
expect(
shouldSuppressTerminalImeKeyboardEvent(
event({ type: 'keypress', key: '中', code: '', isComposing: true })
)
).toBe(false)
})
})
@@ -17,6 +17,7 @@ export type XtermBypassEvent = {
key: string
code?: string
keyCode?: number
isComposing?: boolean
defaultPrevented?: boolean
metaKey: boolean
ctrlKey: boolean
@@ -32,8 +33,26 @@ export type XtermBypassOptions = {
hasSelection: boolean
}
export type XtermImeKeyboardOptions = {
compositionActive: boolean
}
export const TERMINAL_INTERRUPT_INPUT = '\x03'
const TERMINAL_MODIFIER_KEYS = new Set(['Alt', 'AltGraph', 'Control', 'Meta', 'Shift'])
const TERMINAL_IME_OWNED_KEYS = new Set([
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'ArrowUp',
'Backspace',
'Delete',
'End',
'Enter',
'Escape',
'Home',
'PageDown',
'PageUp'
])
function isSingleNonAsciiPrintableText(key: string): boolean {
const chars = Array.from(key)
@@ -48,6 +67,23 @@ function isXtermHandledKeyEvent(type: string): boolean {
return type === 'keydown' || type === 'keyup'
}
export function shouldSuppressTerminalImeKeyboardEvent(
event: XtermBypassEvent,
options: XtermImeKeyboardOptions = { compositionActive: false }
): boolean {
if (!isXtermHandledKeyEvent(event.type)) {
return false
}
// Why: IMEs own Process-key / composing keystrokes. Letting xterm translate
// Backspace/Enter/etc. into PTY bytes makes TUIs delete committed CJK text
// while the user is only editing the preedit candidate.
return (
event.isComposing === true ||
event.keyCode === 229 ||
(options.compositionActive && TERMINAL_IME_OWNED_KEYS.has(event.key))
)
}
function isTerminalInterruptCKey(event: XtermBypassEvent): boolean {
const normalizedKey = event.key.toLowerCase()
const logicalKeyAvailable = normalizedKey !== '' && normalizedKey !== 'unidentified'
@@ -0,0 +1,541 @@
import { randomUUID } from 'node:crypto'
import { rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { CDPSession, Page, TestInfo } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
focusActiveTerminalInput,
getTerminalContent,
sendToTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager,
waitForTerminalOutput
} from './helpers/terminal'
type ImeEventLogEntry = {
type: string
at: number
data: string | null
inputType: string | null
key: string | null
code: string | null
keyCode: number | null
isComposing: boolean | null
value: string
selectionStart: number | null
selectionEnd: number | null
textareaRect: { left: number; top: number; width: number; height: number }
cursorX: number | null
cursorY: number | null
}
type TerminalPromptState = {
model: string
cursor: number
submitted: string[]
}
const PROMPT = '› '
function stripTerminalControls(value: string): string {
let output = ''
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code === 0x1b) {
const next = value[index + 1]
if (next === ']') {
index += 2
while (index < value.length) {
const current = value.charCodeAt(index)
if (current === 0x07) {
break
}
if (current === 0x1b && value[index + 1] === '\\') {
index += 1
break
}
index += 1
}
continue
}
if (next === '[') {
index += 2
while (index < value.length && value.charCodeAt(index) < 0x40) {
index += 1
}
continue
}
continue
}
if ((code >= 0 && code <= 0x08) || (code >= 0x0b && code <= 0x1f) || code === 0x7f) {
continue
}
output += value[index]
}
return output
}
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
function terminalImeHarnessScript(runId: string): string {
return `
const readline = require('node:readline')
const runId = ${JSON.stringify(runId)}
let model = ''
let cursor = 0
const submitted = []
function charWidth(ch) {
const code = ch.codePointAt(0) || 0
return code >= 0x1100 ? 2 : 1
}
function displayColumns(value) {
return Array.from(value).reduce((total, ch) => total + charWidth(ch), 0)
}
function emitState(reason) {
process.stdout.write('\\r\\x1b[2K${PROMPT}' + model)
const suffixColumns = displayColumns(Array.from(model).slice(cursor).join(''))
if (suffixColumns > 0) {
process.stdout.write('\\x1b[' + suffixColumns + 'D')
}
process.stdout.write('\\x1b]1337;OrcaImeState=' + Buffer.from(JSON.stringify({
reason,
model,
cursor,
submitted
})).toString('base64') + '\\x07')
}
function insertText(text) {
const chars = Array.from(model)
chars.splice(cursor, 0, ...Array.from(text))
model = chars.join('')
cursor += Array.from(text).length
}
function removeBeforeCursor() {
if (cursor <= 0) return
const chars = Array.from(model)
chars.splice(cursor - 1, 1)
model = chars.join('')
cursor -= 1
}
function handleData(data) {
let index = 0
while (index < data.length) {
if (data.startsWith('\\x1b[D', index)) {
cursor = Math.max(0, cursor - 1)
index += 3
continue
}
if (data.startsWith('\\x1b[C', index)) {
cursor = Math.min(Array.from(model).length, cursor + 1)
index += 3
continue
}
const ch = data[index]
if (ch === '\\u0003') {
process.exit(0)
}
if (ch === '\\r' || ch === '\\n') {
submitted.push(model)
process.stdout.write('\\r\\x1b[2K[SUBMITTED_JSON_' + runId + ']' + JSON.stringify(model) + '\\n')
model = ''
cursor = 0
index += 1
emitState('submit')
continue
}
if (ch === '\\u007f' || ch === '\\b') {
removeBeforeCursor()
index += 1
continue
}
insertText(ch)
index += ch.length
}
emitState('input')
}
readline.emitKeypressEvents(process.stdin)
if (process.stdin.isTTY) process.stdin.setRawMode(true)
process.stdin.setEncoding('utf8')
process.stdout.write('IME_HARNESS_READY_' + runId + '\\n')
emitState('ready')
process.stdin.on('data', handleData)
`
}
async function installImeEventProbe(page: Page): Promise<void> {
await page.evaluate(() => {
const targetWindow = window as unknown as { __orcaImeEventLog?: ImeEventLogEntry[] }
targetWindow.__orcaImeEventLog = []
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
const textarea = pane?.container.querySelector<HTMLTextAreaElement>('.xterm-helper-textarea')
if (!pane || !textarea) {
throw new Error('No active terminal helper textarea')
}
const record = (event: Event): void => {
const input = event instanceof InputEvent ? event : null
const composition = event instanceof CompositionEvent ? event : null
const keyboard = event instanceof KeyboardEvent ? event : null
const rect = textarea.getBoundingClientRect()
targetWindow.__orcaImeEventLog!.push({
type: event.type,
at: performance.now(),
data: input?.data ?? composition?.data ?? null,
inputType: input?.inputType ?? null,
key: keyboard?.key ?? null,
code: keyboard?.code ?? null,
keyCode: keyboard?.keyCode ?? null,
isComposing: keyboard?.isComposing ?? input?.isComposing ?? null,
value: textarea.value,
selectionStart: textarea.selectionStart,
selectionEnd: textarea.selectionEnd,
textareaRect: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height
},
cursorX: pane.terminal.buffer.active.cursorX,
cursorY: pane.terminal.buffer.active.cursorY
})
}
for (const type of [
'compositionstart',
'compositionupdate',
'compositionend',
'beforeinput',
'input',
'keydown',
'keyup'
]) {
textarea.addEventListener(type, record, true)
}
})
}
async function readImeEventLog(page: Page): Promise<ImeEventLogEntry[]> {
return page.evaluate(() => {
const targetWindow = window as unknown as { __orcaImeEventLog?: ImeEventLogEntry[] }
return targetWindow.__orcaImeEventLog ?? []
})
}
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)]
const submitted = matches
.map((match) => {
try {
return JSON.parse(match[1] ?? '""') as string
} catch {
return null
}
})
.filter((value): value is string => value !== null)
const promptIndex = content.lastIndexOf(PROMPT)
const liveLine =
promptIndex >= 0 ? (content.slice(promptIndex + PROMPT.length).split(/\r?\n/)[0] ?? '') : ''
return {
model: liveLine.trimEnd(),
cursor: Array.from(liveLine.trimEnd()).length,
submitted
}
}
async function attachImeEvidence(
page: Page,
testInfo: TestInfo,
name: string,
extra: Record<string, unknown> = {}
): Promise<void> {
const evidence = {
...extra,
terminal: await getTerminalContent(page, 20_000),
promptState: await readPromptState(page),
imeEvents: await readImeEventLog(page)
}
await testInfo.attach(`${name}.json`, {
body: `${JSON.stringify(evidence, null, 2)}\n`,
contentType: 'application/json'
})
}
async function setImeComposition(
session: CDPSession,
text: string,
selectionStart = Array.from(text).length,
selectionEnd = Array.from(text).length
): Promise<void> {
await session.send('Input.imeSetComposition', {
text,
selectionStart,
selectionEnd
})
}
async function commitImeText(session: CDPSession, text: string): Promise<void> {
await session.send('Input.insertText', { text })
}
async function dispatchImeProcessKey(session: CDPSession, code: string): Promise<void> {
// Why: Windows IMEs report pre-edit keystrokes as VK_PROCESSKEY (229).
// This catches regressions where xterm treats the physical Pinyin key as a
// normal Latin character before Chromium delivers the composition update.
await session.send('Input.dispatchKeyEvent', {
type: 'rawKeyDown',
key: 'Process',
code,
windowsVirtualKeyCode: 229,
nativeVirtualKeyCode: 229,
text: '',
unmodifiedText: ''
})
await session.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key: 'Process',
code,
windowsVirtualKeyCode: 229,
nativeVirtualKeyCode: 229,
text: '',
unmodifiedText: ''
})
}
async function composeAndCommitChineseText(
session: CDPSession,
page: Page,
preeditFrames: string[],
committedText: string
): Promise<void> {
await focusActiveTerminalInput(page)
for (const frame of preeditFrames) {
await setImeComposition(session, frame)
await page.waitForTimeout(80)
}
await commitImeText(session, committedText)
await page.waitForTimeout(150)
}
async function waitForLivePrompt(page: Page, expected: string): Promise<void> {
await expect
.poll(async () => (await readPromptState(page))?.model ?? '', {
timeout: 5_000,
message: `live prompt did not become ${expected}`
})
.toBe(expected)
}
async function waitForCleanTerminalText(
page: Page,
pattern: RegExp,
message: string
): Promise<void> {
await expect
.poll(async () => pattern.test(stripTerminalControls(await getTerminalContent(page, 20_000))), {
timeout: 10_000,
message
})
.toBe(true)
}
async function dismissCodexPromptsIfPresent(page: Page): Promise<void> {
const deadline = Date.now() + 20_000
while (Date.now() < deadline) {
const content = stripTerminalControls(await getTerminalContent(page, 20_000))
if (CODEX_READY_RE.test(content) && !CODEX_TRUST_PROMPT_RE.test(content)) {
return
}
if (CODEX_TRUST_PROMPT_RE.test(content)) {
await page.keyboard.press('Enter')
await page.waitForTimeout(300)
continue
}
if (CODEX_UPDATE_PROMPT_RE.test(content)) {
await page.keyboard.type('3')
await page.keyboard.press('Enter')
await page.waitForTimeout(300)
continue
}
await page.waitForTimeout(250)
}
}
async function launchCodexTui(page: Page, ptyId: string): Promise<void> {
await sendToTerminal(
page,
ptyId,
'codex --dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust\r'
)
await dismissCodexPromptsIfPresent(page)
await waitForCleanTerminalText(page, CODEX_READY_RE, 'Codex TUI did not become ready')
await focusActiveTerminalInput(page)
}
test.describe('Chinese IME terminal chat input repro', () => {
test('keeps composed Chinese text, cursor movement, and Backspace stable in the agent input surface', async ({
orcaPage,
testRepoPath
}, testInfo) => {
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-chinese-ime-harness-${runId}.cjs`)
writeFileSync(scriptPath, terminalImeHarnessScript(runId))
const session = await orcaPage.context().newCDPSession(orcaPage)
try {
await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
await waitForTerminalOutput(orcaPage, `IME_HARNESS_READY_${runId}`, 10_000, 20_000)
await focusActiveTerminalInput(orcaPage)
await installImeEventProbe(orcaPage)
await dispatchImeProcessKey(session, 'KeyN')
await composeAndCommitChineseText(session, orcaPage, ['n', 'ni', '你', '你好'], '你好')
await waitForLivePrompt(orcaPage, '你好')
await attachImeEvidence(orcaPage, testInfo, 'after-compose-hello')
await orcaPage.keyboard.press('Enter')
await expect
.poll(async () => (await readPromptState(orcaPage))?.submitted.at(-1) ?? null, {
timeout: 5_000,
message: 'first submitted prompt did not match the composed Chinese text'
})
.toBe('你好')
await commitImeText(session, '一二三四五六七八九十')
await waitForLivePrompt(orcaPage, '一二三四五六七八九十')
for (let index = 0; index < 5; index += 1) {
await orcaPage.keyboard.press('ArrowLeft')
}
await dispatchImeProcessKey(session, 'KeyZ')
await composeAndCommitChineseText(session, orcaPage, ['z', 'zh', '中'], '中')
await waitForLivePrompt(orcaPage, '一二三四五中六七八九十')
await attachImeEvidence(orcaPage, testInfo, 'after-middle-insert')
await setImeComposition(session, 'x')
await orcaPage.keyboard.press('Backspace')
await waitForLivePrompt(orcaPage, '一二三四五中六七八九十')
await setImeComposition(session, '')
await commitImeText(session, '')
await orcaPage.keyboard.press('Backspace')
await waitForLivePrompt(orcaPage, '一二三四五六七八九十')
await attachImeEvidence(orcaPage, testInfo, 'after-single-backspace')
await orcaPage.keyboard.press('Enter')
await expect
.poll(async () => (await readPromptState(orcaPage))?.submitted.at(-1) ?? null, {
timeout: 5_000,
message: 'second submitted prompt did not match the visible Chinese text'
})
.toBe('一二三四五六七八九十')
const log = await readImeEventLog(orcaPage)
expect(
log.some((entry) => entry.type === 'compositionstart'),
'CDP IME path should exercise Chromium/xterm composition events'
).toBe(true)
expect(
log.some((entry) => entry.type === 'keydown' && entry.key === 'Process'),
'Windows-style IME process keys should be observable in the event trace'
).toBe(true)
expect(
log.filter((entry) => entry.type === 'keydown' && entry.key === 'Backspace').length,
'Backspace should be observable for both the composition and single-delete assertions'
).toBe(2)
} finally {
await attachImeEvidence(orcaPage, testInfo, 'final-ime-evidence').catch(() => undefined)
await session.detach().catch(() => undefined)
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) => {
test.skip(
process.env.ORCA_E2E_REAL_CODEX_IME !== '1',
'Set ORCA_E2E_REAL_CODEX_IME=1 to exercise the locally installed Codex TUI'
)
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const ptyId = await waitForActivePanePtyId(orcaPage)
const session = await orcaPage.context().newCDPSession(orcaPage)
try {
await launchCodexTui(orcaPage, ptyId)
await installImeEventProbe(orcaPage)
await dispatchImeProcessKey(session, 'KeyN')
await composeAndCommitChineseText(session, orcaPage, ['n', 'ni', '你', '你好'], '你好')
await waitForCleanTerminalText(orcaPage, /你好/, 'Codex input did not show composed Chinese')
await attachImeEvidence(orcaPage, testInfo, 'codex-after-compose-hello', {
cleanTerminal: stripTerminalControls(await getTerminalContent(orcaPage, 20_000))
})
await dispatchImeProcessKey(session, 'KeyZ')
await composeAndCommitChineseText(session, orcaPage, ['z', 'zh', '中'], '中')
await waitForCleanTerminalText(
orcaPage,
/你好中/,
'Codex input did not keep previously composed text before middle-edit checks'
)
await orcaPage.keyboard.press('ArrowLeft')
await setImeComposition(session, 'x')
await orcaPage.keyboard.press('Backspace')
await waitForCleanTerminalText(
orcaPage,
/你好中/,
'Backspace during Codex composition removed committed Chinese text'
)
await setImeComposition(session, '')
await commitImeText(session, '')
await attachImeEvidence(orcaPage, testInfo, 'codex-after-composition-backspace', {
cleanTerminal: stripTerminalControls(await getTerminalContent(orcaPage, 20_000))
})
const cleanTerminal = stripTerminalControls(await getTerminalContent(orcaPage, 20_000))
expect(
cleanTerminal,
'Codex should keep committed Chinese text when Backspace cancels an IME preedit'
).toContain('你好中')
expect(cleanTerminal).not.toMatch(/\bn(?:i)?你好/)
expect(cleanTerminal).not.toMatch(/\bz(?:h)?中/)
} finally {
await attachImeEvidence(orcaPage, testInfo, 'codex-final-ime-evidence', {
cleanTerminal: stripTerminalControls(await getTerminalContent(orcaPage, 20_000))
}).catch(() => undefined)
await session.detach().catch(() => undefined)
await sendToTerminal(orcaPage, ptyId, '\x03/quit\r').catch(() => undefined)
}
})
})