From 2d9e866840c6e4ce4946db08ebbf38f1c6e21529 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:50:06 -0700 Subject: [PATCH] fix: preserve terminal IME composition input --- .../terminal-ime-composition-tracker.ts | 50 ++ .../use-terminal-pane-lifecycle.ts | 22 + .../xterm-bypass-policy-non-mac.test.ts | 57 +- .../terminal-pane/xterm-bypass-policy.test.ts | 57 +- .../terminal-pane/xterm-bypass-policy.ts | 36 ++ .../e2e/chinese-ime-chat-input-repro.spec.ts | 541 ++++++++++++++++++ 6 files changed, 761 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts create mode 100644 tests/e2e/chinese-ime-chat-input-repro.spec.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts new file mode 100644 index 00000000000..0e3c9e4bbfb --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.ts @@ -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) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 076add963a0..6977f104d76 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -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()) const osc7DisposablesRef = useRef(new Map()) const mouseHideDisposablesRef = useRef(new Map()) + const imeCompositionDisposablesRef = useRef(new Map()) 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 ( diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts index 48f7caa6534..dbd19be8e54 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts @@ -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 { 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) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts index 48ee3bbd77f..c5a1e2310d9 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts @@ -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 { 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) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts index 3c4f6e7b419..79c0c3caa2b 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts @@ -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' diff --git a/tests/e2e/chinese-ime-chat-input-repro.spec.ts b/tests/e2e/chinese-ime-chat-input-repro.spec.ts new file mode 100644 index 00000000000..edb2fede1a3 --- /dev/null +++ b/tests/e2e/chinese-ime-chat-input-repro.spec.ts @@ -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 { + 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('.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 { + return page.evaluate(() => { + const targetWindow = window as unknown as { __orcaImeEventLog?: ImeEventLogEntry[] } + return targetWindow.__orcaImeEventLog ?? [] + }) +} + +async function readPromptState(page: Page): Promise { + 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 = {} +): Promise { + 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 { + await session.send('Input.imeSetComposition', { + text, + selectionStart, + selectionEnd + }) +} + +async function commitImeText(session: CDPSession, text: string): Promise { + await session.send('Input.insertText', { text }) +} + +async function dispatchImeProcessKey(session: CDPSession, code: string): Promise { + // 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 { + 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 { + 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 { + await expect + .poll(async () => pattern.test(stripTerminalControls(await getTerminalContent(page, 20_000))), { + timeout: 10_000, + message + }) + .toBe(true) +} + +async function dismissCodexPromptsIfPresent(page: Page): Promise { + 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 { + 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) + } + }) +})