fix(terminal): reconcile cross-platform IME composition lifecycle (#11293)

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: JeongUk Park <jeongph.dev@gmail.com>
This commit is contained in:
OrcaWin 2026-07-29 16:12:20 -07:00 committed by GitHub
parent 6c3b2cfb39
commit fe6f929c6e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 6034 additions and 197 deletions

99
.github/workflows/terminal-ime-e2e.yml vendored Normal file
View File

@ -0,0 +1,99 @@
name: Terminal IME E2E
on:
pull_request:
paths:
- '.github/workflows/terminal-ime-e2e.yml'
- 'config/patches/@xterm__xterm@6.1.0-beta.287.patch'
- 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'
- 'config/scripts/terminal-ime-e2e-workflow.test.mjs'
- 'package.json'
- 'pnpm-lock.yaml'
- 'src/renderer/src/components/terminal-pane/keyboard-handlers.ts'
- 'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx'
- 'src/renderer/src/components/terminal-pane/pty-connection.ts'
- 'src/renderer/src/components/terminal-pane/pty-connection.test.ts'
- 'src/renderer/src/components/terminal-pane/terminal-ime-*'
- 'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts'
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts'
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts'
- 'tests/e2e/chinese-ime-chat-input-repro.spec.ts'
- 'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts'
- 'tests/e2e/terminal-ibus-hangul-native.spec.ts'
- 'tests/e2e/terminal-ime-*.ts'
workflow_dispatch:
schedule:
- cron: '30 9 * * *'
permissions:
contents: read
jobs:
linux-x11:
name: Linux X11 terminal IME
runs-on: ubuntu-22.04
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install native build and IME tools
run: >-
sudo apt-get update &&
sudo apt-get install -y
build-essential
dbus-x11
dconf-gsettings-backend
ibus
ibus-hangul
libglib2.0-bin
python3
xdotool
xfwm4
xvfb
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Use external node-gyp to avoid pnpm bundled copy
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Electron app for E2E
run: pnpm exec electron-vite build --mode e2e
- name: Run deterministic terminal IME boundary tests
run: >-
xvfb-run --auto-servernum
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1
pnpm run test:e2e --
tests/e2e/terminal-ime-exact-byte.spec.ts
--workers=1
- name: Run native IBus Hangul exact-byte tests
env:
SKIP_BUILD: '1'
run: pnpm run test:e2e:terminal-ime-native
- name: Upload terminal IME evidence
if: always()
uses: actions/upload-artifact@v7
with:
name: terminal-ime-evidence
path: test-results/
retention-days: 7
if-no-files-found: ignore

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,306 @@
import { spawn, spawnSync } from 'node:child_process'
import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const projectDir = path.resolve(import.meta.dirname, '../..')
const scriptPath = import.meta.filename
const insideSessionFlag = '--inside-session'
const processStopTimeoutMs = 5_000
const processKillTimeoutMs = 1_000
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
function waitForExit(child) {
return new Promise((resolve, reject) => {
child.once('error', reject)
child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)))
})
}
function processGroupMembers(processGroupId) {
const result = spawnSync('ps', ['-o', 'pid=,ppid=,pgid=,comm=', '-g', String(processGroupId)], {
encoding: 'utf8'
})
if (result.status !== 0) {
return []
}
return result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
}
async function stopOwnedProcessGroup(processGroupId) {
let members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
console.error(
`[terminal-ime] stopping owned process group ${processGroupId}: ${members.join('; ')}`
)
try {
process.kill(-processGroupId, 'SIGTERM')
} catch (error) {
if (error?.code !== 'ESRCH') {
throw error
}
}
const deadline = Date.now() + processStopTimeoutMs
while (Date.now() < deadline) {
members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
await delay(100)
}
try {
process.kill(-processGroupId, 'SIGKILL')
} catch (error) {
if (error?.code !== 'ESRCH') {
throw error
}
}
const killDeadline = Date.now() + processKillTimeoutMs
do {
members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
await delay(100)
} while (Date.now() < killDeadline)
return members
}
function commandOutput(command, args) {
const result = spawnSync(command, args, { encoding: 'utf8' })
return result.status === 0 ? result.stdout.trim() : result.stderr.trim()
}
function configureHangulEngine() {
for (const [key, value] of [
['initial-input-mode', 'hangul'],
['hangul-keyboard', '2']
]) {
const result = spawnSync(
'gsettings',
['set', 'org.freedesktop.ibus.engine.hangul', key, value],
{ encoding: 'utf8' }
)
if (result.status !== 0) {
throw new Error(`Failed to configure IBus Hangul ${key}: ${result.stderr.trim()}`)
}
}
}
async function waitForHangulEngine(ibusProcess) {
const deadline = Date.now() + 15_000
while (Date.now() < deadline) {
if (ibusProcess.exitCode !== null) {
throw new Error(`ibus-daemon exited early with code ${ibusProcess.exitCode}`)
}
const result = spawnSync('ibus', ['engine', 'hangul'], { stdio: 'pipe' })
if (result.status === 0) {
return
}
await delay(100)
}
throw new Error('Timed out while selecting the IBus Hangul engine')
}
async function runInsideSession(evidenceDir) {
const ibusLogPath = path.join(evidenceDir, 'ibus-daemon.log')
const ibusLogFd = openSync(ibusLogPath, 'w')
const windowManagerLogPath = path.join(evidenceDir, 'xfwm4.log')
const windowManagerLogFd = openSync(windowManagerLogPath, 'w')
const evidence = {
display: process.env.DISPLAY ?? null,
ibusDaemonPid: null,
ibusGroupBeforeCleanup: [],
ibusGroupAfterCleanup: [],
playwrightPid: null,
windowManagerPid: null,
windowManagerGroupAfterCleanup: []
}
let ibusProcess
let windowManagerProcess
let testExitCode = 1
try {
configureHangulEngine()
windowManagerProcess = spawn('xfwm4', ['--compositor=off'], {
detached: true,
env: process.env,
stdio: ['ignore', windowManagerLogFd, windowManagerLogFd]
})
if (!windowManagerProcess.pid) {
throw new Error('xfwm4 did not return a PID')
}
evidence.windowManagerPid = windowManagerProcess.pid
console.error(`[terminal-ime] started xfwm4 PID ${windowManagerProcess.pid}`)
ibusProcess = spawn(
'ibus-daemon',
['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable'],
{
detached: true,
env: process.env,
stdio: ['ignore', ibusLogFd, ibusLogFd]
}
)
if (!ibusProcess.pid) {
throw new Error('ibus-daemon did not return a PID')
}
evidence.ibusDaemonPid = ibusProcess.pid
console.error(`[terminal-ime] started ibus-daemon PID ${ibusProcess.pid}`)
await waitForHangulEngine(ibusProcess)
console.error(`[terminal-ime] IBus version: ${commandOutput('ibus', ['version'])}`)
console.error(`[terminal-ime] IBus engine: ${commandOutput('ibus', ['engine'])}`)
console.error(
`[terminal-ime] Hangul initial mode: ${commandOutput('gsettings', [
'get',
'org.freedesktop.ibus.engine.hangul',
'initial-input-mode'
])}`
)
console.error(
`[terminal-ime] Hangul keyboard: ${commandOutput('gsettings', [
'get',
'org.freedesktop.ibus.engine.hangul',
'hangul-keyboard'
])}`
)
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
console.error(`[terminal-ime] owned IBus group: ${evidence.ibusGroupBeforeCleanup.join('; ')}`)
const testProcess = spawn(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
[
'run',
'test:e2e:headful',
'--workers=1',
'--',
'tests/e2e/terminal-ibus-hangul-native.spec.ts'
],
{
cwd: projectDir,
env: {
...process.env,
ORCA_E2E_FORWARD_APP_LOGS: '1',
ORCA_E2E_NATIVE_IBUS_HANGUL: '1'
},
stdio: 'inherit'
}
)
if (!testProcess.pid) {
throw new Error('Playwright did not return a PID')
}
evidence.playwrightPid = testProcess.pid
console.error(`[terminal-ime] started Playwright PID ${testProcess.pid}`)
testExitCode = await waitForExit(testProcess)
} finally {
if (ibusProcess?.pid) {
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
evidence.ibusGroupAfterCleanup = await stopOwnedProcessGroup(ibusProcess.pid)
}
if (windowManagerProcess?.pid) {
evidence.windowManagerGroupAfterCleanup = await stopOwnedProcessGroup(
windowManagerProcess.pid
)
}
closeSync(ibusLogFd)
closeSync(windowManagerLogFd)
mkdirSync(path.join(projectDir, 'test-results'), { recursive: true })
copyFileSync(
ibusLogPath,
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-ibus.log')
)
copyFileSync(
windowManagerLogPath,
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-xfwm4.log')
)
writeFileSync(
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-processes.json'),
`${JSON.stringify(evidence, null, 2)}\n`
)
}
if (evidence.ibusGroupAfterCleanup.length > 0) {
throw new Error(
`Owned IBus processes survived cleanup: ${evidence.ibusGroupAfterCleanup.join('; ')}`
)
}
if (evidence.windowManagerGroupAfterCleanup.length > 0) {
throw new Error(
`Owned window-manager processes survived cleanup: ${evidence.windowManagerGroupAfterCleanup.join('; ')}`
)
}
return testExitCode
}
async function runOuter() {
if (process.platform !== 'linux') {
throw new Error('The native IBus Hangul E2E runner requires Linux/X11')
}
const evidenceDir = mkdtempSync(path.join(os.tmpdir(), 'orca-terminal-ime-e2e-'))
const runtimeDir = path.join(evidenceDir, 'runtime')
mkdirSync(runtimeDir, { mode: 0o700 })
mkdirSync(path.join(evidenceDir, 'config'))
mkdirSync(path.join(evidenceDir, 'cache'))
console.error(`[terminal-ime] evidence directory: ${evidenceDir}`)
const sessionProcess = spawn(
'xvfb-run',
[
'--auto-servernum',
'dbus-run-session',
'--',
process.execPath,
scriptPath,
insideSessionFlag,
evidenceDir
],
{
cwd: projectDir,
detached: true,
env: {
...process.env,
GTK_IM_MODULE: 'ibus',
IBUS_ENABLE_SYNC_MODE: '1',
LANG: process.env.LANG || 'C.UTF-8',
QT_IM_MODULE: 'ibus',
XDG_CACHE_HOME: path.join(evidenceDir, 'cache'),
XDG_CONFIG_HOME: path.join(evidenceDir, 'config'),
XDG_RUNTIME_DIR: runtimeDir,
XMODIFIERS: '@im=ibus'
},
stdio: 'inherit'
}
)
if (!sessionProcess.pid) {
throw new Error('xvfb-run did not return a PID')
}
console.error(`[terminal-ime] started isolated X11 session PID ${sessionProcess.pid}`)
const exitCode = await waitForExit(sessionProcess)
const remaining = await stopOwnedProcessGroup(sessionProcess.pid)
if (remaining.length > 0) {
throw new Error(`Owned X11 session processes survived cleanup: ${remaining.join('; ')}`)
}
return exitCode
}
const insideSession = process.argv[2] === insideSessionFlag
try {
if (insideSession && !process.argv[3]) {
throw new Error(`${insideSessionFlag} requires an evidence directory argument`)
}
process.exitCode = insideSession ? await runInsideSession(process.argv[3]) : await runOuter()
} catch (error) {
console.error(`[terminal-ime] ${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
}

View File

@ -0,0 +1,93 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
describe('terminal IME e2e workflow', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/terminal-ime-e2e.yml'), 'utf8')
)
it('runs for xterm patch and terminal IME regression changes', () => {
expect(workflow.on.pull_request.paths).toEqual(
expect.arrayContaining([
'config/patches/@xterm__xterm@6.1.0-beta.287.patch',
'config/scripts/run-terminal-ibus-hangul-e2e.mjs',
'src/renderer/src/components/terminal-pane/keyboard-handlers.ts',
'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx',
'src/renderer/src/components/terminal-pane/pty-connection.ts',
'src/renderer/src/components/terminal-pane/pty-connection.test.ts',
'src/renderer/src/components/terminal-pane/terminal-ime-*',
'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts',
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts',
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts',
'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts',
'tests/e2e/terminal-ibus-hangul-native.spec.ts',
'tests/e2e/terminal-ime-*.ts'
])
)
})
it('installs native IBus Hangul and X11 input tools', () => {
const runs = workflow.jobs['linux-x11'].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const installRun = runs.find((run) => run.includes('apt-get install'))
expect(installRun).toBeDefined()
expect(installRun).toContain('ibus-hangul')
expect(installRun).toContain('xdotool')
expect(installRun).toContain('xfwm4')
expect(installRun).toContain('xvfb')
expect(installRun).toContain('dbus-x11')
expect(installRun).toContain('dconf-gsettings-backend')
expect(installRun).toContain('libglib2.0-bin')
})
it('runs deterministic boundaries before the real IBus suite', () => {
const runs = workflow.jobs['linux-x11'].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const deterministicIndex = runs.findIndex((run) =>
run.includes('terminal-ime-exact-byte.spec.ts')
)
const nativeIndex = runs.findIndex((run) => run.includes('test:e2e:terminal-ime-native'))
expect(deterministicIndex).toBeGreaterThanOrEqual(0)
expect(nativeIndex).toBeGreaterThan(deterministicIndex)
})
it('keeps IBus lifecycle scoped to owned processes', () => {
const runner = readFileSync(
join(projectDir, 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'),
'utf8'
)
expect(runner).toContain(
"['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable']"
)
expect(runner).toContain("spawn('xfwm4', ['--compositor=off']")
expect(runner).toContain("['initial-input-mode', 'hangul']")
expect(runner).toContain("['hangul-keyboard', '2']")
expect(runner).toContain("process.kill(-processGroupId, 'SIGTERM')")
expect(runner).toContain("process.kill(-processGroupId, 'SIGKILL')")
expect(runner).toContain('const killDeadline = Date.now() + processKillTimeoutMs')
expect(runner).toMatch(
/'test:e2e:headful',\s*'--workers=1',\s*'--',\s*'tests\/e2e\/terminal-ibus-hangul-native\.spec\.ts'/
)
expect(runner).not.toContain("'--replace'")
expect(runner).not.toContain('killall')
expect(runner).not.toContain('pkill')
})
it('bounds blocking native input commands', () => {
const nativeSpec = readFileSync(
join(projectDir, 'tests/e2e/terminal-ibus-hangul-native.spec.ts'),
'utf8'
)
expect(nativeSpec.match(/timeout: NATIVE_COMMAND_TIMEOUT_MS/g)).toHaveLength(3)
})
})

View File

@ -105,6 +105,7 @@
"win-crash-survival-e2e": "node tools/win-crash-survival-e2e/run.mjs",
"test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs",
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
"test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-hangul-e2e.mjs",
"test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts",
"bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs",
"bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs",

View File

@ -19,7 +19,7 @@ patchedDependencies:
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
'@xterm/xterm@6.1.0-beta.287':
hash: 8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3
hash: 082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
node-pty@1.1.0:
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
@ -46,7 +46,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.287
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@ -206,25 +206,25 @@ importers:
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
'@xterm/addon-fit':
specifier: 0.12.0-beta.287
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/addon-ligatures':
specifier: 0.11.0-beta.287
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/addon-search':
specifier: 0.17.0-beta.287
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.287
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/addon-web-links':
specifier: 0.13.0-beta.287
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.286
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
version: 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@ -9179,39 +9179,39 @@ snapshots:
'@xmldom/xmldom@0.8.13': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
lru-cache: 11.5.1
opentype.js: 2.0.0
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)
'@xterm/headless@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=082a4e714662e81b21d386f2c1de4c9dfd13e195c2497e9162b7e87f86b6a9b6)': {}
abbrev@4.0.0: {}

View File

@ -0,0 +1,277 @@
// @vitest-environment happy-dom
import { cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { PtyTransport } from './pty-transport'
import { useTerminalKeyboardShortcuts } from './keyboard-handlers'
import {
installTerminalImeCompositionRoute,
XTERM_COMPOSITION_SESSION_START_EVENT
} from './terminal-ime-composition-route'
type KeyboardHandlersDeps = Parameters<typeof useTerminalKeyboardShortcuts>[0]
function keyboardEvent(
type: 'keydown' | 'keyup',
overrides: KeyboardEventInit & { keyCode: number; timeStamp: number; isComposing?: boolean }
): KeyboardEvent {
const event = new KeyboardEvent(type, {
bubbles: true,
cancelable: true,
...overrides
})
Object.defineProperties(event, {
isComposing: { value: overrides.isComposing ?? false },
keyCode: { value: overrides.keyCode },
timeStamp: { value: overrides.timeStamp }
})
return event
}
function createHarness(): {
deps: KeyboardHandlersDeps
editable: HTMLInputElement
startComposition: () => void
terminalInput: HTMLTextAreaElement
dispose: () => void
} {
const scope = document.createElement('div')
const terminalElement = document.createElement('div')
const terminalInput = document.createElement('textarea')
const editable = document.createElement('input')
terminalInput.className = 'xterm-helper-textarea'
terminalElement.append(terminalInput)
scope.append(terminalElement, editable)
document.body.append(scope)
const sendInput = vi.fn(() => true)
const transport = {
getPtyId: () => 'pty-1',
sendInput
} as unknown as PtyTransport
const pane = {
id: 1,
leafId: '00000000-0000-4000-8000-000000000001',
terminal: {
element: terminalElement,
focus: vi.fn(),
getSelection: vi.fn(() => '')
}
}
const manager = {
getActivePane: () => pane,
getPanes: () => [pane]
} as unknown as PaneManager
const route = installTerminalImeCompositionRoute({
terminalElement,
terminal: { input: vi.fn() },
capturedTransport: transport,
getCurrentTransport: () => transport
})
const deps: KeyboardHandlersDeps = {
tabId: 'tab-1',
worktreeId: 'worktree-1',
isActive: true,
keyboardScopeRef: { current: scope },
managerRef: { current: manager },
paneTransportsRef: { current: new Map([[pane.id, transport]]) },
panePtyBindingsRef: { current: new Map() },
paneCwdRef: { current: new Map() },
fallbackCwd: '',
expandedPaneIdRef: { current: null },
setExpandedPane: vi.fn(),
restoreExpandedLayout: vi.fn(),
refreshPaneSizes: vi.fn(),
persistLayoutSnapshot: vi.fn(),
toggleExpandPane: vi.fn(),
setSearchOpen: vi.fn(),
onSearchSelectedText: vi.fn(),
onRequestClosePane: vi.fn(),
onClearPaneScrollback: vi.fn(),
onSetTitle: vi.fn(),
onClearPaneTitle: vi.fn(),
searchOpenRef: { current: false },
searchStateRef: { current: { query: '', caseSensitive: false, regex: false } },
macOptionAsAltRef: { current: 'false' }
}
return {
deps,
editable,
terminalInput,
startComposition: () => {
terminalElement.dispatchEvent(
new CustomEvent(XTERM_COMPOSITION_SESSION_START_EVENT, {
detail: { id: 1 }
})
)
},
dispose: () => {
route.dispose()
scope.remove()
}
}
}
describe('Windows IME keyboard ownership', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Windows')
})
afterEach(() => {
cleanup()
vi.clearAllTimers()
vi.useRealTimers()
vi.restoreAllMocks()
})
it.each([
{ key: 'Shift', code: 'ShiftLeft', keyCode: 16, modifier: { shiftKey: true } },
{ key: 'Control', code: 'ControlLeft', keyCode: 17, modifier: { ctrlKey: true } }
])('absorbs a bare Enter redispatch when $key was held before composition', (held) => {
const harness = createHarness()
const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps))
harness.terminalInput.dispatchEvent(
keyboardEvent('keydown', {
key: held.key,
code: held.code,
keyCode: held.keyCode,
timeStamp: 1,
...held.modifier
})
)
harness.startComposition()
harness.terminalInput.dispatchEvent(
keyboardEvent('keydown', {
key: 'Process',
code: 'Enter',
keyCode: 229,
timeStamp: 10,
isComposing: true,
...held.modifier
})
)
const redispatch = keyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
timeStamp: 20
})
harness.terminalInput.dispatchEvent(redispatch)
expect(redispatch.defaultPrevented).toBe(true)
hook.unmount()
harness.dispose()
})
it('does not route an editable-target Enter keyup into the terminal', () => {
const harness = createHarness()
const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps))
harness.startComposition()
const keyup = keyboardEvent('keyup', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
timeStamp: 10,
ctrlKey: true
})
harness.editable.dispatchEvent(keyup)
expect(keyup.defaultPrevented).toBe(false)
hook.unmount()
harness.dispose()
})
it('does not arm a modifier pressed in an editable control', () => {
const harness = createHarness()
const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps))
harness.editable.dispatchEvent(
keyboardEvent('keydown', {
key: 'Control',
code: 'ControlLeft',
keyCode: 17,
timeStamp: 1,
ctrlKey: true
})
)
harness.startComposition()
harness.terminalInput.dispatchEvent(
keyboardEvent('keydown', {
key: 'Process',
code: 'Enter',
keyCode: 229,
timeStamp: 10,
isComposing: true,
ctrlKey: true
})
)
const redispatch = keyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
timeStamp: 20
})
harness.terminalInput.dispatchEvent(redispatch)
expect(redispatch.defaultPrevented).toBe(false)
hook.unmount()
harness.dispose()
})
it('retains Ctrl ownership when a later-held Shift is released first', () => {
const harness = createHarness()
const hook = renderHook(() => useTerminalKeyboardShortcuts(harness.deps))
for (const event of [
keyboardEvent('keydown', {
key: 'Control',
code: 'ControlLeft',
keyCode: 17,
timeStamp: 1,
ctrlKey: true
}),
keyboardEvent('keydown', {
key: 'Shift',
code: 'ShiftLeft',
keyCode: 16,
timeStamp: 2,
ctrlKey: true,
shiftKey: true
}),
keyboardEvent('keyup', {
key: 'Shift',
code: 'ShiftLeft',
keyCode: 16,
timeStamp: 3,
ctrlKey: true
})
]) {
harness.terminalInput.dispatchEvent(event)
}
harness.startComposition()
harness.terminalInput.dispatchEvent(
keyboardEvent('keydown', {
key: 'Process',
code: 'Enter',
keyCode: 229,
timeStamp: 10,
isComposing: true,
ctrlKey: true
})
)
const redispatch = keyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
timeStamp: 20
})
harness.terminalInput.dispatchEvent(redispatch)
expect(redispatch.defaultPrevented).toBe(true)
hook.unmount()
harness.dispose()
})
})

View File

@ -9,6 +9,18 @@ import { safeFind } from '../terminal-search-safe-find'
import { resolveTerminalShortcutAction } from './terminal-shortcut-policy'
import type { MacOptionAsAlt } from './terminal-shortcut-policy'
import { createTerminalNativeOnlyShortcutTracker } from './terminal-native-only-shortcut'
import {
createTerminalImeDeferredNewlineSender,
createTerminalImeModifiedEnterChordOwner,
getTerminalImeModifiedEnterKind,
isTerminalImeEnterKeyUp,
isTerminalImeProcessEnter
} from './terminal-ime-deferred-newline'
import { hasPendingTerminalImeComposition } from './terminal-ime-composition-route'
import {
requestCapturedTerminalReconfirmation,
sendCapturedTerminalInput
} from './terminal-captured-input-dispatch'
import {
keybindingMatchesAction,
type KeybindingOverrides,
@ -264,15 +276,40 @@ export function useTerminalKeyboardShortcuts({
// held. To distinguish left vs right Option, we record the Option key's
// location from its own keydown event and clear it on keyup.
let optionKeyLocation = 0
const heldImeEnterModifiers = new Set<'shift' | 'ctrl'>()
const nativeOnlyShortcutTracker = createTerminalNativeOnlyShortcutTracker()
const deferredNewlineSender = createTerminalImeDeferredNewlineSender()
const modifiedEnterChordOwner = createTerminalImeModifiedEnterChordOwner()
const getHeldImeEnterModifier = () =>
heldImeEnterModifiers.size === 1
? (heldImeEnterModifiers.values().next().value ?? null)
: null
const getImeEnterModifier = (event: KeyboardEvent) => {
const eventKind = getTerminalImeModifiedEnterKind(event)
if (eventKind || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) {
return eventKind
}
return getHeldImeEnterModifier()
}
const getModifiedEnterChord = (event: KeyboardEvent) => {
const kind = getImeEnterModifier(event)
return kind ? { kind, code: event.code, timeStamp: event.timeStamp } : null
}
const onModifierDown = (e: KeyboardEvent): void => {
if (e.key === 'Alt') {
optionKeyLocation = e.location
}
}
const onModifierUp = (e: KeyboardEvent): void => {
if (e.key === 'Alt') {
optionKeyLocation = 0
if (isWindows && (e.key === 'Shift' || e.key === 'Control')) {
const manager = managerRef.current
const keyboardScope = keyboardScopeRef.current
const pane = manager?.getActivePane() ?? manager?.getPanes()[0]
if (
pane &&
(!keyboardScope || keyboardEventBelongsToScope(e, keyboardScope)) &&
!isEditableTarget(e.target)
) {
heldImeEnterModifiers.add(e.key === 'Shift' ? 'shift' : 'ctrl')
}
}
}
@ -304,6 +341,94 @@ export function useTerminalKeyboardShortcuts({
)
}
// Why: the active pane's live PTY session decides whether Ctrl+Arrow should
// pass through as native \e[1;5C/\e[1;5D or be translated to \eb/\ef.
// Resolved lazily so session/runtime lookups stay off other keystrokes.
const isLocalWindowsConptyPane = (): boolean => {
const manager = managerRef.current
const activePane = manager?.getActivePane() ?? manager?.getPanes()[0]
if (!activePane) {
return false
}
const storeState = useAppStore.getState()
return isLocalWindowsConptyPaneForCtrlArrow({
isWindows,
userAgent: navigator.userAgent,
state: storeState,
worktreeId,
tabId,
paneId: activePane.id,
paneCwd: paneCwdRef.current,
fallbackCwd,
transport: paneTransportsRef.current.get(activePane.id) ?? null
})
}
// Why: the pane's TUI opted into kitty keyboard reporting via CSI > u;
// the tracker mirrors that from PTY output so the policy can encode
// Option chords the way the application negotiated.
const isKittyKeyboardActivePane = (): boolean => {
const manager = managerRef.current
const activePane = manager?.getActivePane() ?? manager?.getPanes()[0]
if (!activePane) {
return false
}
return (paneKittyKeyboardModesRef?.current.get(activePane.id)?.flags ?? 0) > 0
}
const resolveShortcutEvent = (
event: Parameters<typeof resolveTerminalKeyboardShortcutAction>[0]
): ReturnType<typeof resolveTerminalKeyboardShortcutAction> =>
resolveTerminalKeyboardShortcutAction(
event,
isMac,
macOptionAsAltRef.current,
optionKeyLocation,
isWindows,
keybindings,
isLocalWindowsConptyPane,
isKittyKeyboardActivePane,
getLayoutBaseCharacterForCode,
getActivePaneWindowsShiftEnterEncoding,
isActivePaneWindowsTerminalHost,
terminalShortcutPolicy
)
const createCapturedInputSender = (
pane: { id: number; leafId: string },
data: string
): (() => void) => {
const capturedTransport = paneTransportsRef.current.get(pane.id)
const capturedPtyId = capturedTransport?.getPtyId() ?? null
const capturedBinding = panePtyBindingsRef.current.get(pane.id) as
| (IDisposable & { requestDroidReconfirmation?: () => void })
| undefined
const getCurrentManager = () => managerRef.current
const getCurrentTransport = () => paneTransportsRef.current.get(pane.id)
const getCurrentBinding = () => panePtyBindingsRef.current.get(pane.id)
return () => {
const targetPaneMounted =
getCurrentManager()
?.getPanes()
.some((candidate) => candidate.id === pane.id && candidate.leafId === pane.leafId) ===
true
const sent = sendCapturedTerminalInput({
targetPaneMounted,
currentTransport: getCurrentTransport(),
capturedTransport,
capturedPtyId,
data
})
if (sent) {
recordTerminalUserInputForLeaf(tabId, pane.leafId)
if (data === '\x1b[13;2u') {
// Why: this write bypasses PTY onData, so no-OSC shells need reconfirmation.
requestCapturedTerminalReconfirmation(getCurrentBinding(), capturedBinding)
}
}
}
}
const onKeyDown = (e: KeyboardEvent): void => {
// Why: replace stale state only for this physical key so rollover cannot
// disarm a still-held native-only chord before its Kitty keyup arrives.
@ -317,6 +442,20 @@ export function useTerminalKeyboardShortcuts({
return
}
const modifiedEnterChord = isWindows ? getModifiedEnterChord(e) : null
if (
e.key === 'Enter' &&
e.keyCode === 13 &&
!e.isComposing &&
((modifiedEnterChord && modifiedEnterChordOwner.absorb(modifiedEnterChord)) ||
deferredNewlineSender.absorbRedispatchedEnter(e))
) {
// Chromium can drop the modifier when re-dispatching the committing Enter.
e.preventDefault()
e.stopImmediatePropagation()
return
}
if (matchFileSearchShortcut(e, shortcutPlatform, keybindings, terminalShortcutPolicy)) {
const pane = manager.getActivePane() ?? manager.getPanes()[0]
const selectedText = normalizeSelectedTextForFileSearch(pane?.terminal.getSelection())
@ -356,53 +495,23 @@ export function useTerminalKeyboardShortcuts({
return
}
// Why: the active pane's live PTY session decides whether Ctrl+Arrow should
// pass through as native \e[1;5C/\e[1;5D or be translated to \eb/\ef.
// Resolved lazily so session/runtime lookups stay off other keystrokes.
const isLocalWindowsConptyPane = (): boolean => {
const activePane = manager.getActivePane() ?? manager.getPanes()[0]
if (!activePane) {
return false
}
const storeState = useAppStore.getState()
return isLocalWindowsConptyPaneForCtrlArrow({
isWindows,
userAgent: navigator.userAgent,
state: storeState,
worktreeId,
tabId,
paneId: activePane.id,
paneCwd: paneCwdRef.current,
fallbackCwd,
transport: paneTransportsRef.current.get(activePane.id) ?? null
})
}
// Why: the pane's TUI opted into kitty keyboard reporting via CSI > u;
// the tracker mirrors that from PTY output so the policy can encode
// Option chords the way the application negotiated.
const isKittyKeyboardActivePane = (): boolean => {
const activePane = manager.getActivePane() ?? manager.getPanes()[0]
if (!activePane) {
return false
}
return (paneKittyKeyboardModesRef?.current.get(activePane.id)?.flags ?? 0) > 0
}
const action = resolveTerminalKeyboardShortcutAction(
e,
isMac,
macOptionAsAltRef.current,
optionKeyLocation,
isWindows,
keybindings,
isLocalWindowsConptyPane,
isKittyKeyboardActivePane,
getLayoutBaseCharacterForCode,
getActivePaneWindowsShiftEnterEncoding,
isActivePaneWindowsTerminalHost,
terminalShortcutPolicy
const terminalPaneForImeShortcut = manager.getActivePane() ?? manager.getPanes()[0]
const hasPendingImeComposition = hasPendingTerminalImeComposition(
terminalPaneForImeShortcut?.terminal.element
)
const imeProcessEnter = isWindows && hasPendingImeComposition && isTerminalImeProcessEnter(e)
const shortcutEvent = imeProcessEnter
? {
key: 'Enter',
code: e.code,
metaKey: e.metaKey,
ctrlKey: e.ctrlKey,
altKey: e.altKey,
shiftKey: e.shiftKey,
repeat: e.repeat
}
: e
const action = resolveShortcutEvent(shortcutEvent)
if (!action) {
return
}
@ -422,18 +531,18 @@ export function useTerminalKeyboardShortcuts({
if (!pane) {
return
}
const sent = paneTransportsRef.current.get(pane.id)?.sendInput(action.data) === true
if (sent) {
recordTerminalUserInputForLeaf(tabId, pane.leafId)
if (action.data === '\x1b[13;2u') {
// Why: this direct shortcut write does not pass through PTY onData,
// so no-OSC shells need an explicit post-write confirmation ladder.
const binding = panePtyBindingsRef.current.get(pane.id) as
| (IDisposable & { requestDroidReconfirmation?: () => void })
| undefined
binding?.requestDroidReconfirmation?.()
const sendResolvedInput = createCapturedInputSender(pane, action.data)
if ((e.isComposing || hasPendingImeComposition) && (e.key === 'Enter' || imeProcessEnter)) {
if (isWindows) {
const chord = getModifiedEnterChord(e)
if (chord && !modifiedEnterChordOwner.claim(chord)) {
return
}
}
deferredNewlineSender.defer(e, pane.terminal.element, sendResolvedInput)
return
}
sendResolvedInput()
return
}
@ -626,6 +735,74 @@ export function useTerminalKeyboardShortcuts({
}
}
const onKeyUp = (e: KeyboardEvent): void => {
if (e.key === 'Alt') {
optionKeyLocation = 0
}
const releasedImeEnterModifier =
e.key === 'Shift' ? 'shift' : e.key === 'Control' ? 'ctrl' : null
if (releasedImeEnterModifier) {
const kind = releasedImeEnterModifier
heldImeEnterModifiers.delete(kind)
modifiedEnterChordOwner.release({ kind, code: e.code, timeStamp: e.timeStamp })
}
if (e.key !== 'Enter') {
return
}
const modifiedEnterKind = getImeEnterModifier(e)
if (isWindows && modifiedEnterKind && isTerminalImeEnterKeyUp(e)) {
const chord = { kind: modifiedEnterKind, code: e.code, timeStamp: e.timeStamp }
if (modifiedEnterChordOwner.absorb(chord)) {
modifiedEnterChordOwner.release(chord)
e.preventDefault()
e.stopImmediatePropagation()
deferredNewlineSender.releaseRedispatchedEnter(e)
return
}
const manager = managerRef.current
const keyboardScope = keyboardScopeRef.current
if (
manager &&
!isEditableTarget(e.target) &&
(!keyboardScope || keyboardEventBelongsToScope(e, keyboardScope))
) {
const pane = manager.getActivePane() ?? manager.getPanes()[0]
if (pane && hasPendingTerminalImeComposition(pane.terminal.element)) {
const action = resolveShortcutEvent({
key: 'Enter',
code: e.code,
metaKey: false,
ctrlKey: modifiedEnterKind === 'ctrl',
altKey: false,
shiftKey: modifiedEnterKind === 'shift',
repeat: false
})
if (action?.type === 'sendInput') {
e.preventDefault()
e.stopImmediatePropagation()
deferredNewlineSender.defer(
e,
pane.terminal.element,
createCapturedInputSender(pane, action.data)
)
return
}
}
}
}
if (modifiedEnterKind) {
modifiedEnterChordOwner.release({
kind: modifiedEnterKind,
code: e.code,
timeStamp: e.timeStamp
})
}
deferredNewlineSender.releaseRedispatchedEnter(e)
}
const onNativeOnlyShortcutCompanion = (e: KeyboardEvent): void => {
if (nativeOnlyShortcutTracker.consumeCompanion(e)) {
// Why: canceling only the companion keypress prevents Chromium's text
@ -649,18 +826,23 @@ export function useTerminalKeyboardShortcuts({
const onNativeOnlyBlur = (): void => {
nativeOnlyShortcutTracker.clear()
heldImeEnterModifiers.clear()
modifiedEnterChordOwner.clear()
deferredNewlineSender.clearRedispatchedEnters()
}
window.addEventListener('keydown', onModifierDown, { capture: true })
window.addEventListener('keyup', onModifierUp, { capture: true })
window.addEventListener('keyup', onKeyUp, { capture: true })
window.addEventListener('keydown', onKeyDown, { capture: true })
window.addEventListener('keypress', onNativeOnlyShortcutCompanion, { capture: true })
window.addEventListener('keyup', onNativeOnlyShortcutCompanion, { capture: true })
window.addEventListener('beforeinput', onNativeOnlyBeforeInput, { capture: true })
window.addEventListener('blur', onNativeOnlyBlur)
return () => {
modifiedEnterChordOwner.clear()
deferredNewlineSender.clearRedispatchedEnters()
window.removeEventListener('keydown', onModifierDown, { capture: true })
window.removeEventListener('keyup', onModifierUp, { capture: true })
window.removeEventListener('keyup', onKeyUp, { capture: true })
window.removeEventListener('keydown', onKeyDown, { capture: true })
window.removeEventListener('keypress', onNativeOnlyShortcutCompanion, { capture: true })
window.removeEventListener('keyup', onNativeOnlyShortcutCompanion, { capture: true })

View File

@ -5054,8 +5054,12 @@ describe('connectPanePty', () => {
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
expect(terminalTarget.handlers.size).toBe(1)
expect(terminalTarget.target.addEventListener).toHaveBeenCalledTimes(2)
expect(terminalTarget.target.removeEventListener).toHaveBeenCalledTimes(1)
expect(
terminalTarget.target.addEventListener.mock.calls.filter(([type]) => type === 'keydown')
).toHaveLength(2)
expect(
terminalTarget.target.removeEventListener.mock.calls.filter(([type]) => type === 'keydown')
).toHaveLength(1)
})
it('clears the mobile-fit pane binding when the pane connection is disposed', async () => {

View File

@ -3,6 +3,7 @@ import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager'
import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types'
import type { IBuffer, IDisposable } from '@xterm/xterm'
import { resolveCursorAgentImeAnchor } from '@/lib/pane-manager/terminal-ime-anchor'
import { installTerminalImeCompositionRoute } from './terminal-ime-composition-route'
import { detectAgentStatusFromTitle, agentTypeToIconAgent, isClaudeAgent } from '@/lib/agent-status'
import { resolvePaneTitleDecision } from './terminal-title-evidence'
import { blocksCodexPaneInput } from '../codex-restart-notice-state'
@ -4021,6 +4022,12 @@ export function connectPanePty(
requestRecoveryForUndeliverableInput()
}
})
const imeCompositionRouteDisposable = installTerminalImeCompositionRoute({
terminalElement: pane.terminal.element,
terminal: pane.terminal,
capturedTransport: transport,
getCurrentTransport: () => deps.paneTransportsRef.current.get(pane.id)
})
const shouldSuppressDesktopPtyResize = (): boolean => {
const currentPtyId = transport.getPtyId()
@ -8838,6 +8845,7 @@ export function connectPanePty(
clearTimeout(connectFallbackTimer)
connectFallbackTimer = null
}
imeCompositionRouteDisposable.dispose()
onDataDisposable.dispose()
userInputActivityDisposable?.dispose()
terminalCapabilityRepliesDisposable.dispose()

View File

@ -0,0 +1,104 @@
import { describe, expect, it, vi } from 'vitest'
import type { PtyTransport } from './pty-transport'
import {
requestCapturedTerminalReconfirmation,
sendCapturedTerminalInput
} from './terminal-captured-input-dispatch'
function createTransport(ptyId: string | null): PtyTransport {
return {
getPtyId: vi.fn(() => ptyId),
sendInput: vi.fn(() => true)
} as unknown as PtyTransport
}
describe('sendCapturedTerminalInput', () => {
it.each(['local IPC', 'SSH remote runtime'])(
'sends on the captured %s route while its PTY still owns the mounted pane',
() => {
const transport = createTransport('pty-original')
expect(
sendCapturedTerminalInput({
targetPaneMounted: true,
currentTransport: transport,
capturedTransport: transport,
capturedPtyId: 'pty-original',
data: '\r'
})
).toBe(true)
expect(transport.sendInput).toHaveBeenCalledWith('\r')
}
)
it.each(['local IPC', 'SSH remote runtime'])(
'does not deliver to a replacement %s transport for a reused pane',
() => {
const original = createTransport('pty-original')
const replacement = createTransport('pty-replacement')
expect(
sendCapturedTerminalInput({
targetPaneMounted: true,
currentTransport: replacement,
capturedTransport: original,
capturedPtyId: 'pty-original',
data: '\r'
})
).toBe(false)
expect(original.sendInput).not.toHaveBeenCalled()
expect(replacement.sendInput).not.toHaveBeenCalled()
}
)
it('does not deliver after the captured transport rebinds to another PTY', () => {
const transport = createTransport('pty-replacement')
expect(
sendCapturedTerminalInput({
targetPaneMounted: true,
currentTransport: transport,
capturedTransport: transport,
capturedPtyId: 'pty-original',
data: '\r'
})
).toBe(false)
expect(transport.sendInput).not.toHaveBeenCalled()
})
it('does not deliver after pane disposal', () => {
const transport = createTransport('pty-original')
expect(
sendCapturedTerminalInput({
targetPaneMounted: false,
currentTransport: undefined,
capturedTransport: transport,
capturedPtyId: 'pty-original',
data: '\r'
})
).toBe(false)
expect(transport.sendInput).not.toHaveBeenCalled()
})
})
describe('requestCapturedTerminalReconfirmation', () => {
it('reconfirms only through the still-current captured binding', () => {
const requestDroidReconfirmation = vi.fn()
const binding = { requestDroidReconfirmation }
requestCapturedTerminalReconfirmation(binding, binding)
expect(requestDroidReconfirmation).toHaveBeenCalledOnce()
})
it('does not call a disposed binding or its replacement', () => {
const original = { requestDroidReconfirmation: vi.fn() }
const replacement = { requestDroidReconfirmation: vi.fn() }
requestCapturedTerminalReconfirmation(replacement, original)
expect(original.requestDroidReconfirmation).not.toHaveBeenCalled()
expect(replacement.requestDroidReconfirmation).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,41 @@
import type { PtyTransport } from './pty-transport'
type CapturedTerminalInputDispatch = {
targetPaneMounted: boolean
currentTransport: PtyTransport | undefined
capturedTransport: PtyTransport | undefined
capturedPtyId: string | null
data: string
}
export type TerminalReconfirmationBinding = {
requestDroidReconfirmation?: () => void
}
export function sendCapturedTerminalInput({
targetPaneMounted,
currentTransport,
capturedTransport,
capturedPtyId,
data
}: CapturedTerminalInputDispatch): boolean {
if (
!targetPaneMounted ||
!capturedTransport ||
capturedPtyId === null ||
currentTransport !== capturedTransport ||
capturedTransport.getPtyId() !== capturedPtyId
) {
return false
}
return capturedTransport.sendInput(data)
}
export function requestCapturedTerminalReconfirmation(
currentBinding: object | undefined,
capturedBinding: TerminalReconfirmationBinding | undefined
): void {
if (currentBinding === capturedBinding) {
capturedBinding?.requestDroidReconfirmation?.()
}
}

View File

@ -0,0 +1,164 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
import type { PtyTransport } from './pty-transport'
import {
hasPendingTerminalImeComposition,
installTerminalImeCompositionRoute,
XTERM_COMPOSITION_SESSION_END_EVENT,
XTERM_COMPOSITION_SESSION_START_EVENT
} from './terminal-ime-composition-route'
function createTransport(ptyId: string | null): PtyTransport {
return {
getPtyId: vi.fn(() => ptyId)
} as unknown as PtyTransport
}
function sessionEvent(
type: string,
id: number,
data?: string,
dataPendingReconciliation = false
): CustomEvent {
return new CustomEvent(type, {
bubbles: true,
cancelable: true,
detail: { id, data, dataPendingReconciliation }
})
}
function createHarness(ptyId = 'pty-original') {
const element = document.createElement('div')
const original = createTransport(ptyId)
const state: {
currentTransport: PtyTransport | undefined
} = {
currentTransport: original
}
const input = vi.fn()
const route = installTerminalImeCompositionRoute({
terminalElement: element,
terminal: { input },
capturedTransport: original,
getCurrentTransport: () => state.currentTransport
})
const start = (id: number) =>
element.dispatchEvent(sessionEvent(XTERM_COMPOSITION_SESSION_START_EVENT, id))
const end = (id: number, data: string) =>
element.dispatchEvent(sessionEvent(XTERM_COMPOSITION_SESSION_END_EVENT, id, data))
return { element, original, state, input, route, start, end }
}
describe('installTerminalImeCompositionRoute', () => {
it('does not install on an uninitialized terminal element', () => {
const transport = createTransport('pty-original')
const route = installTerminalImeCompositionRoute({
terminalElement: {} as HTMLElement,
terminal: { input: vi.fn() },
capturedTransport: transport,
getCurrentTransport: () => transport
})
expect(() => route.dispose()).not.toThrow()
})
it.each(['visible blur', 'terminal tab switch', 'split pane switch'])(
'delivers one Hangul commit to the captured route after %s',
() => {
const harness = createHarness()
harness.start(1)
expect(harness.end(1, '한')).toBe(false)
expect(harness.input).toHaveBeenCalledExactlyOnceWith('한')
}
)
it('never routes a switched composition to an unrelated transport', () => {
const harness = createHarness()
harness.start(1)
harness.state.currentTransport = createTransport('pty-unrelated')
harness.end(1, '한')
expect(harness.input).not.toHaveBeenCalled()
})
it('finalizes empty cancellation without leaking into the next session', () => {
const harness = createHarness()
harness.start(1)
harness.end(1, '')
harness.start(2)
harness.end(1, '한')
harness.end(2, '글')
expect(harness.input).toHaveBeenCalledExactlyOnceWith('글')
})
it('ignores duplicate and late composition completion', () => {
const harness = createHarness()
harness.start(1)
harness.end(1, '한')
harness.end(1, '한')
expect(harness.input).toHaveBeenCalledExactlyOnceWith('한')
})
it('settles a session without forwarding bytes still pending xterm reconciliation', () => {
const harness = createHarness()
harness.start(1)
harness.element.dispatchEvent(sessionEvent(XTERM_COMPOSITION_SESSION_END_EVENT, 1, '앙', true))
expect(harness.input).not.toHaveBeenCalled()
expect(hasPendingTerminalImeComposition(harness.element)).toBe(false)
})
it('keeps every captured session until its delayed completion', () => {
const harness = createHarness()
harness.start(1)
harness.start(2)
harness.start(3)
expect(hasPendingTerminalImeComposition(harness.element)).toBe(true)
harness.end(1, '안')
harness.end(2, '녕')
expect(hasPendingTerminalImeComposition(harness.element)).toBe(true)
harness.end(3, '하')
expect(harness.input.mock.calls).toEqual([['안'], ['녕'], ['하']])
expect(hasPendingTerminalImeComposition(harness.element)).toBe(false)
})
it('drops a commit after same-pane PTY replacement', () => {
const harness = createHarness()
harness.start(1)
vi.mocked(harness.original.getPtyId).mockReturnValue('pty-replacement')
harness.end(1, '한')
expect(harness.input).not.toHaveBeenCalled()
})
it('drops a commit after SSH reconnect replaces the transport', () => {
const harness = createHarness('remote:env:pty-original')
harness.start(1)
harness.state.currentTransport = createTransport('remote:env:pty-replacement')
harness.end(1, '한')
expect(harness.input).not.toHaveBeenCalled()
})
it.each(['terminal close', 'tab unmount'])(
'releases the captured session and listeners on %s',
() => {
const harness = createHarness()
harness.start(1)
harness.route.dispose()
expect(harness.end(1, '한')).toBe(true)
expect(harness.input).not.toHaveBeenCalled()
expect(hasPendingTerminalImeComposition(harness.element)).toBe(false)
}
)
})

View File

@ -0,0 +1,117 @@
import type { IDisposable, Terminal } from '@xterm/xterm'
import type { PtyTransport } from './pty-transport'
export const XTERM_COMPOSITION_SESSION_START_EVENT = 'xterm-composition-session-start'
export const XTERM_COMPOSITION_SESSION_END_EVENT = 'xterm-composition-session-end'
type CompositionSessionDetail = {
id: number
data?: string
dataPendingReconciliation?: boolean
}
type CapturedCompositionSession = {
ptyId: string | null
}
const pendingCompositionCountByElement = new WeakMap<HTMLElement, number>()
function adjustPendingCompositionCount(terminalElement: HTMLElement, delta: number): void {
const count = Math.max(0, (pendingCompositionCountByElement.get(terminalElement) ?? 0) + delta)
if (count === 0) {
pendingCompositionCountByElement.delete(terminalElement)
return
}
pendingCompositionCountByElement.set(terminalElement, count)
}
export function hasPendingTerminalImeComposition(
terminalElement: HTMLElement | null | undefined
): boolean {
return Boolean(terminalElement && pendingCompositionCountByElement.has(terminalElement))
}
function getCompositionDetail(event: Event): CompositionSessionDetail | null {
if (!(event instanceof CustomEvent)) {
return null
}
const detail = event.detail as Partial<CompositionSessionDetail> | null
if (!detail || !Number.isSafeInteger(detail.id) || detail.id! <= 0) {
return null
}
return {
id: detail.id!,
data: typeof detail.data === 'string' ? detail.data : undefined,
dataPendingReconciliation: detail.dataPendingReconciliation === true
}
}
export function installTerminalImeCompositionRoute(args: {
terminalElement: HTMLElement | null | undefined
terminal: Pick<Terminal, 'input'>
capturedTransport: PtyTransport
getCurrentTransport: () => PtyTransport | undefined
}): IDisposable {
const terminalElement = args.terminalElement
const sessions = new Map<number, CapturedCompositionSession>()
let disposed = false
if (
!terminalElement ||
typeof terminalElement.addEventListener !== 'function' ||
typeof terminalElement.removeEventListener !== 'function'
) {
return { dispose: () => undefined }
}
const onSessionStart = (event: Event): void => {
const detail = getCompositionDetail(event)
if (!detail || disposed) {
return
}
if (!sessions.has(detail.id)) {
adjustPendingCompositionCount(terminalElement, 1)
}
sessions.set(detail.id, {
ptyId: args.capturedTransport.getPtyId()
})
}
const onSessionEnd = (event: Event): void => {
const detail = getCompositionDetail(event)
if (!detail) {
return
}
event.preventDefault()
const captured = sessions.get(detail.id)
if (!captured) {
return
}
sessions.delete(detail.id)
adjustPendingCompositionCount(terminalElement, -1)
if (
disposed ||
detail.dataPendingReconciliation ||
!detail.data ||
captured.ptyId === null ||
args.getCurrentTransport() !== args.capturedTransport ||
args.capturedTransport.getPtyId() !== captured.ptyId
) {
return
}
args.terminal.input(detail.data)
}
terminalElement.addEventListener(XTERM_COMPOSITION_SESSION_START_EVENT, onSessionStart)
terminalElement.addEventListener(XTERM_COMPOSITION_SESSION_END_EVENT, onSessionEnd)
return {
dispose: () => {
disposed = true
adjustPendingCompositionCount(terminalElement, -sessions.size)
sessions.clear()
terminalElement.removeEventListener(XTERM_COMPOSITION_SESSION_START_EVENT, onSessionStart)
terminalElement.removeEventListener(XTERM_COMPOSITION_SESSION_END_EVENT, onSessionEnd)
}
}
}

View File

@ -0,0 +1,151 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
installTerminalImeNativeTextForwarder,
XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT,
XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT
} from './terminal-ime-native-text-forwarder'
function wonKeydown(): {
type: 'keydown'
key: string
code: string
metaKey: false
ctrlKey: false
altKey: false
isComposing: false
keyCode: number
} {
return {
type: 'keydown',
key: '₩',
code: 'Backquote',
metaKey: false,
ctrlKey: false,
altKey: false,
isComposing: false,
keyCode: 192
}
}
function dispatchInsertText(target: HTMLElement, data: '`' | '₩' = '`'): void {
target.dispatchEvent(new InputEvent('input', { data, inputType: 'insertText', bubbles: true }))
}
describe('xterm composition transaction ownership', () => {
let element: HTMLDivElement
let textarea: HTMLTextAreaElement
beforeEach(() => {
element = document.createElement('div')
textarea = document.createElement('textarea')
element.appendChild(textarea)
document.body.replaceChildren(element)
})
it('leaves every immediate native remap with an accepted composition transaction', () => {
const sendInput = vi.fn()
const downstream = vi.fn()
const forwarder = installTerminalImeNativeTextForwarder({
terminalElement: element,
isComposing: () => false,
sendInput
})
element.addEventListener('input', downstream, true)
textarea.dispatchEvent(
new CustomEvent(XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT, { bubbles: true })
)
for (const [value, data] of [
['한`', '`'],
['한`₩', '₩']
] as const) {
expect(forwarder.claimKeyEvent(wonKeydown())).toBe(true)
textarea.value = value
dispatchInsertText(textarea, data)
}
expect(sendInput).not.toHaveBeenCalled()
expect(downstream).not.toHaveBeenCalled()
expect(textarea.value).toBe('한`₩')
textarea.dispatchEvent(
new CustomEvent(XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT, { bubbles: true })
)
expect(forwarder.claimKeyEvent(wonKeydown())).toBe(true)
textarea.value = '`'
dispatchInsertText(textarea)
expect(sendInput).toHaveBeenCalledExactlyOnceWith('`')
expect(textarea.value).toBe('')
})
it('does not let repeated rejected composition ends steal an immediate remap', () => {
const sendInput = vi.fn()
const forwarder = installTerminalImeNativeTextForwarder({
terminalElement: element,
isComposing: () => false,
sendInput
})
for (let index = 0; index < 3; index++) {
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
}
expect(forwarder.claimKeyEvent(wonKeydown())).toBe(true)
dispatchInsertText(textarea)
expect(sendInput).toHaveBeenCalledExactlyOnceWith('`')
})
it('restarts ownership when another composition transaction is accepted', () => {
const sendInput = vi.fn()
const forwarder = installTerminalImeNativeTextForwarder({
terminalElement: element,
isComposing: () => false,
sendInput
})
for (let index = 0; index < 2; index++) {
textarea.dispatchEvent(
new CustomEvent(XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT, { bubbles: true })
)
expect(forwarder.claimKeyEvent(wonKeydown())).toBe(true)
dispatchInsertText(textarea)
}
textarea.dispatchEvent(
new CustomEvent(XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT, { bubbles: true })
)
expect(forwarder.claimKeyEvent(wonKeydown())).toBe(true)
dispatchInsertText(textarea)
expect(sendInput).toHaveBeenCalledExactlyOnceWith('`')
})
it('drops composition ownership on blur and unmount', () => {
const sendInput = vi.fn()
const forwarder = installTerminalImeNativeTextForwarder({
terminalElement: element,
isComposing: () => false,
sendInput
})
textarea.dispatchEvent(
new CustomEvent(XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT, { bubbles: true })
)
expect(forwarder.claimKeyEvent(wonKeydown())).toBe(true)
textarea.dispatchEvent(new FocusEvent('blur'))
expect(forwarder.claimKeyEvent(wonKeydown())).toBe(true)
dispatchInsertText(textarea)
expect(sendInput).toHaveBeenCalledExactlyOnceWith('`')
textarea.dispatchEvent(
new CustomEvent(XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT, { bubbles: true })
)
expect(forwarder.claimKeyEvent(wonKeydown())).toBe(true)
element.remove()
forwarder.dispose()
dispatchInsertText(textarea)
expect(sendInput).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,365 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PtyTransport } from './pty-transport'
import {
createTerminalImeDeferredNewlineSender,
createTerminalImeModifiedEnterChordOwner,
isTerminalImeEnterKeyUp,
isTerminalImeProcessEnter,
sendTerminalInputAfterComposition
} from './terminal-ime-deferred-newline'
import {
installTerminalImeCompositionRoute,
XTERM_COMPOSITION_SESSION_END_EVENT,
XTERM_COMPOSITION_SESSION_START_EVENT
} from './terminal-ime-composition-route'
describe('sendTerminalInputAfterComposition', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('sends the newline one macrotask after compositionend so the glyph flushes first', () => {
const el = document.createElement('div')
const send = vi.fn()
sendTerminalInputAfterComposition(el, send)
expect(send).not.toHaveBeenCalled()
el.dispatchEvent(new Event('compositionend'))
// Deferred a macrotask so xterm's own post-compositionend flush runs first.
expect(send).not.toHaveBeenCalled()
vi.runAllTimers()
expect(send).toHaveBeenCalledTimes(1)
})
it('falls back to sending when no compositionend arrives', () => {
const el = document.createElement('div')
const send = vi.fn()
sendTerminalInputAfterComposition(el, send)
vi.runAllTimers()
expect(send).toHaveBeenCalledTimes(1)
})
it('finishes from the captured xterm transaction when deferral starts after compositionend', () => {
const el = document.createElement('div')
const send = vi.fn()
sendTerminalInputAfterComposition(el, send)
el.dispatchEvent(new CustomEvent(XTERM_COMPOSITION_SESSION_END_EVENT))
vi.advanceTimersByTime(0)
expect(send).toHaveBeenCalledTimes(1)
})
it('waits for every overlapping captured xterm transaction', () => {
const el = document.createElement('div')
const send = vi.fn()
const terminal = { input: vi.fn() }
const transport = {
getPtyId: () => 'pty-1'
} as unknown as PtyTransport
const route = installTerminalImeCompositionRoute({
terminalElement: el,
terminal,
capturedTransport: transport,
getCurrentTransport: () => transport
})
const sessionEvent = (type: string, id: number) =>
new CustomEvent(type, { detail: { id, data: `commit-${id}` } })
el.dispatchEvent(sessionEvent(XTERM_COMPOSITION_SESSION_START_EVENT, 1))
el.dispatchEvent(sessionEvent(XTERM_COMPOSITION_SESSION_START_EVENT, 2))
sendTerminalInputAfterComposition(el, send)
el.dispatchEvent(sessionEvent(XTERM_COMPOSITION_SESSION_END_EVENT, 1))
vi.advanceTimersByTime(0)
expect(send).not.toHaveBeenCalled()
el.dispatchEvent(sessionEvent(XTERM_COMPOSITION_SESSION_END_EVENT, 2))
vi.advanceTimersByTime(0)
expect(send).toHaveBeenCalledTimes(1)
route.dispose()
})
it('sends only once and drops the listener after firing', () => {
const el = document.createElement('div')
const send = vi.fn()
sendTerminalInputAfterComposition(el, send)
el.dispatchEvent(new Event('compositionend'))
vi.runAllTimers()
expect(send).toHaveBeenCalledTimes(1)
// A later composition on the same terminal must not re-fire the stale newline.
el.dispatchEvent(new Event('compositionend'))
vi.runAllTimers()
expect(send).toHaveBeenCalledTimes(1)
})
it('does not double-send when compositionend arrives after the fallback fired', () => {
const el = document.createElement('div')
const send = vi.fn()
sendTerminalInputAfterComposition(el, send)
vi.runAllTimers()
expect(send).toHaveBeenCalledTimes(1)
el.dispatchEvent(new Event('compositionend'))
vi.runAllTimers()
expect(send).toHaveBeenCalledTimes(1)
})
it('still delivers the input on the next macrotask without a terminal element', () => {
const send = vi.fn()
sendTerminalInputAfterComposition(null, send)
expect(send).not.toHaveBeenCalled()
vi.runAllTimers()
expect(send).toHaveBeenCalledTimes(1)
})
})
describe('createTerminalImeDeferredNewlineSender', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
const createSender = () => createTerminalImeDeferredNewlineSender()
const enter = (timeStamp: number, code = 'Enter') => ({ code, timeStamp })
it('absorbs the re-dispatch while the deferred send is still in flight, exactly once', () => {
const el = document.createElement('div')
const send = vi.fn()
const sender = createSender()
sender.defer(enter(10), el, send)
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(true)
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(false)
el.dispatchEvent(new Event('compositionend'))
vi.runAllTimers()
expect(send).toHaveBeenCalledTimes(1)
// The credit was consumed pre-send, so nothing lingers to eat a real Enter.
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(false)
})
it('absorbs after the deferred send even if focus moved to another pane', () => {
const el = document.createElement('div')
const send = vi.fn()
const sender = createSender()
sender.defer(enter(10), el, send)
el.dispatchEvent(new Event('compositionend'))
vi.runAllTimers()
expect(send).toHaveBeenCalledTimes(1)
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(true)
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(false)
})
it('keeps a credit across the balancing keyup copied from the same native event', () => {
const el = document.createElement('div')
const sender = createSender()
sender.defer(enter(10), el, vi.fn())
el.dispatchEvent(new Event('compositionend'))
vi.runAllTimers()
sender.releaseRedispatchedEnter(enter(10))
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(true)
})
it('releases an unused credit on a later physical keyup', () => {
const el = document.createElement('div')
const sender = createSender()
sender.defer(enter(10), el, vi.fn())
sender.releaseRedispatchedEnter(enter(11))
el.dispatchEvent(new Event('compositionend'))
vi.runAllTimers()
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(false)
})
it('retires stale credit when a genuinely new Enter begins without a redispatch', () => {
const el = document.createElement('div')
const sender = createSender()
sender.defer(enter(10), el, vi.fn())
expect(sender.absorbRedispatchedEnter(enter(20))).toBe(false)
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(false)
})
it('absorbs a matching repeated composition cycle but not a new plain repeat', () => {
const el = document.createElement('div')
const sender = createSender()
sender.defer(enter(10), el, vi.fn())
expect(sender.absorbRedispatchedEnter(enter(20))).toBe(false)
sender.defer(enter(30), el, vi.fn())
expect(sender.absorbRedispatchedEnter(enter(30))).toBe(true)
})
it('tracks the main and numpad Enter keys independently', () => {
const el = document.createElement('div')
const sender = createSender()
sender.defer(enter(10), el, vi.fn())
expect(sender.absorbRedispatchedEnter(enter(10, 'NumpadEnter'))).toBe(false)
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(true)
})
it('tracks two overlapping Enter cycles independently by native timestamp', () => {
const el = document.createElement('div')
const sender = createSender()
sender.defer(enter(10), el, vi.fn())
sender.defer(enter(20), el, vi.fn())
expect(sender.absorbRedispatchedEnter(enter(20))).toBe(true)
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(true)
expect(sender.absorbRedispatchedEnter(enter(20))).toBe(false)
})
it('keeps a re-dispatch credit on the fallback path', () => {
const el = document.createElement('div')
const send = vi.fn()
const sender = createSender()
sender.defer(enter(10), el, send)
vi.runAllTimers()
expect(send).toHaveBeenCalledTimes(1)
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(true)
})
it('still delivers without a terminal element and keeps one credit', () => {
const send = vi.fn()
const sender = createSender()
sender.defer(enter(10), null, send)
vi.runAllTimers()
expect(send).toHaveBeenCalledTimes(1)
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(true)
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(false)
})
it('clears credits after a missed keyup when the window blurs', () => {
const el = document.createElement('div')
const sender = createSender()
sender.defer(enter(10), el, vi.fn())
el.dispatchEvent(new Event('compositionend'))
vi.runAllTimers()
sender.clearRedispatchedEnters()
expect(sender.absorbRedispatchedEnter(enter(10))).toBe(false)
})
})
describe('createTerminalImeModifiedEnterChordOwner', () => {
const chord = (kind: 'shift' | 'ctrl', timeStamp: number, code = '') => ({
kind,
code,
timeStamp
})
it('owns one Windows Process sequence across changing timestamps and blank codes', () => {
const owner = createTerminalImeModifiedEnterChordOwner()
const defer = vi.fn()
for (const event of [chord('shift', 5035.8), chord('shift', 5036.9)]) {
if (owner.claim(event)) {
defer()
}
}
expect(defer).toHaveBeenCalledTimes(1)
expect(owner.absorb(chord('shift', 5037.9, 'Enter'))).toBe(true)
})
it('does not merge different modified Enter kinds into one chord', () => {
const owner = createTerminalImeModifiedEnterChordOwner()
expect(owner.claim(chord('shift', 10, 'Enter'))).toBe(true)
expect(owner.claim(chord('ctrl', 11, 'Enter'))).toBe(false)
expect(owner.absorb(chord('ctrl', 12, 'Enter'))).toBe(false)
})
it('releases at the physical key boundary so the next Enter is not consumed', () => {
const owner = createTerminalImeModifiedEnterChordOwner()
expect(owner.claim(chord('ctrl', 10))).toBe(true)
owner.release(chord('ctrl', 30, 'Enter'))
expect(owner.absorb(chord('ctrl', 40, 'Enter'))).toBe(false)
expect(owner.claim(chord('ctrl', 40, 'Enter'))).toBe(true)
})
it('ignores a mismatched release and clears lost keyup state explicitly', () => {
const owner = createTerminalImeModifiedEnterChordOwner()
expect(owner.claim(chord('shift', 10))).toBe(true)
owner.release(chord('ctrl', 20))
expect(owner.absorb(chord('shift', 30))).toBe(true)
owner.clear()
expect(owner.absorb(chord('shift', 40))).toBe(false)
})
})
describe('isTerminalImeProcessEnter', () => {
const event = (overrides: Partial<KeyboardEvent> = {}) =>
({
key: 'Process',
keyCode: 229,
metaKey: false,
ctrlKey: false,
altKey: false,
shiftKey: true,
...overrides
}) as KeyboardEvent
it.each([{ shiftKey: true }, { shiftKey: false, ctrlKey: true }])(
'recognizes a Windows IME modifier Enter reported as Process',
(modifiers) => {
expect(isTerminalImeProcessEnter(event(modifiers))).toBe(true)
}
)
it.each([
{ key: 'Enter' },
{ keyCode: 13 },
{ shiftKey: false },
{ ctrlKey: true },
{ altKey: true }
])('rejects a non-IME or ambiguous Process key', (override) => {
expect(isTerminalImeProcessEnter(event(override))).toBe(false)
})
})
describe('isTerminalImeEnterKeyUp', () => {
it('recognizes the balancing Enter keyup when Chromium drops its modifier', () => {
expect(isTerminalImeEnterKeyUp({ key: 'Enter', keyCode: 13 })).toBe(true)
})
it.each([
{ key: 'Process', keyCode: 13 },
{ key: 'Enter', keyCode: 229 }
])('rejects a non-Enter balancing event', (event) => {
expect(isTerminalImeEnterKeyUp(event)).toBe(false)
})
})

View File

@ -0,0 +1,192 @@
import {
hasPendingTerminalImeComposition,
XTERM_COMPOSITION_SESSION_END_EVENT
} from './terminal-ime-composition-route'
export const TERMINAL_IME_DEFERRED_NEWLINE_FALLBACK_MS = 200
export function sendTerminalInputAfterComposition(
terminalElement: HTMLElement | null | undefined,
send: () => void,
options?: { fallbackMs?: number }
): void {
if (!terminalElement) {
window.setTimeout(send, 0)
return
}
const fallbackMs = options?.fallbackMs ?? TERMINAL_IME_DEFERRED_NEWLINE_FALLBACK_MS
let done = false
const finish = (): void => {
if (done) {
return
}
done = true
terminalElement.removeEventListener('compositionend', onCompositionEnd)
terminalElement.removeEventListener(
XTERM_COMPOSITION_SESSION_END_EVENT,
onCompositionSessionEnd
)
window.clearTimeout(fallbackTimer)
// xterm flushes the committed glyph after compositionend.
window.setTimeout(send, 0)
}
const finishAfterPendingComposition = (): void => {
if (!hasPendingTerminalImeComposition(terminalElement)) {
finish()
}
}
const onCompositionEnd = (): void => finishAfterPendingComposition()
const onCompositionSessionEnd = (): void => finishAfterPendingComposition()
terminalElement.addEventListener('compositionend', onCompositionEnd)
terminalElement.addEventListener(XTERM_COMPOSITION_SESSION_END_EVENT, onCompositionSessionEnd)
const fallbackTimer = window.setTimeout(finish, fallbackMs)
}
export type TerminalImeDeferredNewlineSender = {
defer: (
enter: TerminalImeEnterIdentity,
terminalElement: HTMLElement | null | undefined,
send: () => void
) => void
absorbRedispatchedEnter: (enter: TerminalImeEnterIdentity) => boolean
releaseRedispatchedEnter: (enter: TerminalImeEnterIdentity) => void
clearRedispatchedEnters: () => void
}
export type TerminalImeEnterIdentity = Pick<KeyboardEvent, 'code' | 'timeStamp'>
export type TerminalImeModifiedEnterKind = 'shift' | 'ctrl'
export type TerminalImeModifiedEnterChord = TerminalImeEnterIdentity & {
kind: TerminalImeModifiedEnterKind
}
export type TerminalImeModifiedEnterChordOwner = {
claim: (chord: TerminalImeModifiedEnterChord) => boolean
absorb: (chord: TerminalImeModifiedEnterChord) => boolean
release: (chord: TerminalImeModifiedEnterChord) => void
clear: () => void
}
export function createTerminalImeModifiedEnterChordOwner(): TerminalImeModifiedEnterChordOwner {
let activeKind: TerminalImeModifiedEnterKind | null = null
return {
claim: ({ kind }) => {
if (activeKind !== null) {
return false
}
activeKind = kind
return true
},
absorb: ({ kind }) => activeKind === kind,
release: ({ kind }) => {
if (activeKind === kind) {
activeKind = null
}
},
clear: () => {
activeKind = null
}
}
}
export function getTerminalImeModifiedEnterKind(
event: Pick<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>
): TerminalImeModifiedEnterKind | null {
if (event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
return 'shift'
}
if (event.ctrlKey && !event.shiftKey && !event.metaKey && !event.altKey) {
return 'ctrl'
}
return null
}
export function isTerminalImeProcessEnter(
event: Pick<KeyboardEvent, 'key' | 'keyCode' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>
): boolean {
return (
event.key === 'Process' &&
event.keyCode === 229 &&
getTerminalImeModifiedEnterKind(event) !== null
)
}
export function isTerminalImeEnterKeyUp(event: Pick<KeyboardEvent, 'key' | 'keyCode'>): boolean {
return event.key === 'Enter' && event.keyCode === 13
}
type DeferredNewlineState = {
inFlightSends: number
absorbCredits: number
}
export function createTerminalImeDeferredNewlineSender(): TerminalImeDeferredNewlineSender {
const statesByEnterCode = new Map<string, Map<number, DeferredNewlineState>>()
const cleanUpIfSettled = (enter: TerminalImeEnterIdentity, state: DeferredNewlineState): void => {
if (state.inFlightSends <= 0 && state.absorbCredits <= 0) {
const statesByTimeStamp = statesByEnterCode.get(enter.code)
statesByTimeStamp?.delete(enter.timeStamp)
if (statesByTimeStamp?.size === 0) {
statesByEnterCode.delete(enter.code)
}
}
}
const clearCreditsForCode = (enterCode: string): void => {
const statesByTimeStamp = statesByEnterCode.get(enterCode)
if (!statesByTimeStamp) {
return
}
for (const [timeStamp, state] of statesByTimeStamp) {
state.absorbCredits = 0
cleanUpIfSettled({ code: enterCode, timeStamp }, state)
}
}
return {
defer: (enter, terminalElement, send) => {
const statesByTimeStamp = statesByEnterCode.get(enter.code) ?? new Map()
const state = statesByTimeStamp.get(enter.timeStamp) ?? {
inFlightSends: 0,
absorbCredits: 0
}
state.inFlightSends += 1
state.absorbCredits += 1
statesByTimeStamp.set(enter.timeStamp, state)
statesByEnterCode.set(enter.code, statesByTimeStamp)
sendTerminalInputAfterComposition(terminalElement, () => {
state.inFlightSends -= 1
cleanUpIfSettled(enter, state)
send()
})
},
absorbRedispatchedEnter: (enter) => {
const state = statesByEnterCode.get(enter.code)?.get(enter.timeStamp)
if (!state || state.absorbCredits <= 0) {
clearCreditsForCode(enter.code)
return false
}
state.absorbCredits -= 1
cleanUpIfSettled(enter, state)
return true
},
releaseRedispatchedEnter: (enter) => {
if (statesByEnterCode.get(enter.code)?.has(enter.timeStamp)) {
// Chromium's balancing Process-key keyup is copied from the same native event.
return
}
clearCreditsForCode(enter.code)
},
clearRedispatchedEnters: () => {
for (const enterCode of statesByEnterCode.keys()) {
clearCreditsForCode(enterCode)
}
}
}
}

View File

@ -41,6 +41,9 @@ const CJK_DIRECT_PUNCTUATION_KEYS = new Set<string>([
'『',
'』',
'¥',
// Korean sources put ₩ on Backquote; its committed text can differ from
// `key` when DefaultKeyBinding.dict rewrites it (typically back to `).
'₩',
'',
'·',
'…'

View File

@ -68,6 +68,18 @@ describe('isImeNativeTextKeydownCandidate', () => {
).toBe(true)
})
// Why: macOS Korean sources emit ₩ from Backquote, and a DefaultKeyBinding.dict
// entry can rewrite it to ` — but only in the keypress/input events.
it('accepts the Korean won-sign key even when the input source probe is stale', () => {
expect(
isImeNativeTextKeydownCandidate(
keyEvent({ key: '₩', code: 'Backquote', keyCode: 192 }),
false,
DISABLED_FEATURES
)
).toBe(true)
})
it('accepts Vietnamese short replacement keys without enabling punctuation', () => {
expect(
isImeNativeTextKeydownCandidate(
@ -264,6 +276,40 @@ describe('installTerminalImeNativeTextForwarder', () => {
expect(sendInput).toHaveBeenCalledExactlyOnceWith('。')
})
it('forwards the key binding substitution for the Korean won-sign key', () => {
const sendInput = vi.fn()
const forwarder = installTerminalImeNativeTextForwarder({
terminalElement: element,
isComposing: () => false,
sendInput,
getInputSourceFeatures: () => CJK_FEATURES
})
expect(forwarder.claimKeyEvent(keyEvent({ key: '₩', code: 'Backquote', keyCode: 192 }))).toBe(
true
)
dispatchInsertText(textarea, '`')
expect(sendInput).toHaveBeenCalledExactlyOnceWith('`')
})
it('forwards the won sign unchanged when no key binding remaps it', () => {
const sendInput = vi.fn()
const forwarder = installTerminalImeNativeTextForwarder({
terminalElement: element,
isComposing: () => false,
sendInput,
getInputSourceFeatures: () => CJK_FEATURES
})
expect(forwarder.claimKeyEvent(keyEvent({ key: '₩', code: 'Backquote', keyCode: 192 }))).toBe(
true
)
dispatchInsertText(textarea, '₩')
expect(sendInput).toHaveBeenCalledExactlyOnceWith('₩')
})
it('forwards a plain ASCII symbol unchanged when the IME does not convert it', () => {
const sendInput = vi.fn()
const forwarder = installTerminalImeNativeTextForwarder({

View File

@ -20,6 +20,9 @@ type ClaimedKeyPress = {
code?: string
}
export const XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT = 'xterm-composition-transaction-accepted'
export const XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT = 'xterm-composition-transaction-settled'
export type TerminalImeNativeTextForwarder = IDisposable & {
/**
* Returns true when this keyboard event belongs to a direct native text
@ -68,6 +71,7 @@ export function installTerminalImeNativeTextForwarder(args: {
const terminalElement = args.terminalElement
let pendingForward = false
let pendingForwardClearTimer: number | null = null
let compositionTransactionPending = false
let claimedPress: ClaimedKeyPress | null = null
const clearPendingForwardTimer = (): void => {
@ -92,6 +96,14 @@ export function installTerminalImeNativeTextForwarder(args: {
}, 100)
}
const markCompositionTransactionAccepted = (): void => {
compositionTransactionPending = true
}
const markCompositionTransactionSettled = (): void => {
compositionTransactionPending = false
}
const claimKeyEvent = (event: ImeNativeTextKeyEvent): boolean => {
if (event.type === 'keydown') {
if (
@ -138,6 +150,11 @@ export function installTerminalImeNativeTextForwarder(args: {
if (!(event instanceof InputEvent)) {
return
}
if (compositionTransactionPending && event.inputType === 'insertText') {
disarmPendingForward()
event.stopImmediatePropagation()
return
}
if (!pendingForward) {
return
}
@ -159,9 +176,20 @@ export function installTerminalImeNativeTextForwarder(args: {
const cancelPending = (): void => {
disarmPendingForward()
compositionTransactionPending = false
claimedPress = null
}
terminalElement.addEventListener(
XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT,
markCompositionTransactionAccepted,
true
)
terminalElement.addEventListener(
XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT,
markCompositionTransactionSettled,
true
)
terminalElement.addEventListener('input', forwardCommittedText, true)
terminalElement.addEventListener('blur', cancelPending, true)
@ -169,6 +197,16 @@ export function installTerminalImeNativeTextForwarder(args: {
claimKeyEvent,
dispose: () => {
cancelPending()
terminalElement.removeEventListener(
XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT,
markCompositionTransactionAccepted,
true
)
terminalElement.removeEventListener(
XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT,
markCompositionTransactionSettled,
true
)
terminalElement.removeEventListener('input', forwardCommittedText, true)
terminalElement.removeEventListener('blur', cancelPending, true)
}

View File

@ -0,0 +1,189 @@
// @vitest-environment happy-dom
import { Terminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker'
import { installTerminalImeNativeTextForwarder } from './terminal-ime-native-text-forwarder'
function nextEventLoop(): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, 0))
}
function compositionEvent(type: string, data = ''): CompositionEvent {
const event = new CompositionEvent(type, { data, bubbles: true })
Object.defineProperty(event, 'data', { value: data })
return event
}
function keyboardEvent(
type: 'keydown' | 'keypress' | 'keyup',
key: string,
code = 'Backquote',
keyCode = 192
): KeyboardEvent {
const event = new KeyboardEvent(type, { key, code, bubbles: true })
Object.defineProperties(event, {
charCode: { value: type === 'keypress' ? key.charCodeAt(0) : 0 },
keyCode: { value: keyCode },
which: { value: type === 'keypress' ? key.charCodeAt(0) : keyCode }
})
return event
}
async function typeHangulThenWon(
committedWonTexts: readonly ('`' | '₩')[],
options: {
beforeWon?: (textarea: HTMLTextAreaElement) => void
duplicateFirstInput?: boolean
} = {}
): Promise<string> {
const container = document.createElement('div')
document.body.appendChild(container)
const terminal = new Terminal()
terminal.open(container)
const textarea = terminal.textarea
const terminalElement = terminal.element
if (!textarea || !terminalElement) {
throw new Error('xterm input elements were not created')
}
const tracker = installTerminalImeCompositionTracker(terminalElement)
const forwarder = installTerminalImeNativeTextForwarder({
terminalElement,
isComposing: tracker.isActive,
sendInput: (data) => terminal.input(data)
})
terminal.attachCustomKeyEventHandler((event) => !forwarder.claimKeyEvent(event))
const emitted: string[] = []
terminal.onData((data) => emitted.push(data))
textarea.dispatchEvent(compositionEvent('compositionstart'))
textarea.dispatchEvent(compositionEvent('compositionupdate', '한'))
textarea.value = '한'
textarea.setSelectionRange(1, 1)
await nextEventLoop()
textarea.dispatchEvent(compositionEvent('compositionend', '한'))
options.beforeWon?.(textarea)
for (const [index, committedWonText] of committedWonTexts.entries()) {
textarea.dispatchEvent(keyboardEvent('keydown', '₩'))
textarea.dispatchEvent(keyboardEvent('keypress', committedWonText))
textarea.value += committedWonText
textarea.dispatchEvent(
new InputEvent('input', {
data: committedWonText,
inputType: 'insertText',
bubbles: true
})
)
if (index === 0 && options.duplicateFirstInput) {
textarea.dispatchEvent(
new InputEvent('input', {
data: committedWonText,
inputType: 'insertText',
bubbles: true
})
)
}
textarea.dispatchEvent(keyboardEvent('keyup', '₩'))
}
await nextEventLoop()
forwarder.dispose()
tracker.dispose()
terminal.dispose()
return emitted.join('')
}
async function typeHangulThenRepeatedStaleEndsThenWon(): Promise<string> {
const container = document.createElement('div')
document.body.appendChild(container)
const terminal = new Terminal()
terminal.open(container)
const textarea = terminal.textarea
const terminalElement = terminal.element
if (!textarea || !terminalElement) {
throw new Error('xterm input elements were not created')
}
const tracker = installTerminalImeCompositionTracker(terminalElement)
const forwarder = installTerminalImeNativeTextForwarder({
terminalElement,
isComposing: tracker.isActive,
sendInput: (data) => terminal.input(data)
})
terminal.attachCustomKeyEventHandler((event) => !forwarder.claimKeyEvent(event))
const emitted: string[] = []
terminal.onData((data) => emitted.push(data))
textarea.dispatchEvent(compositionEvent('compositionstart'))
textarea.dispatchEvent(compositionEvent('compositionupdate', '한'))
textarea.value = '한'
textarea.setSelectionRange(1, 1)
textarea.dispatchEvent(compositionEvent('compositionend', '한'))
await nextEventLoop()
textarea.value = ''
for (let index = 0; index < 3; index++) {
textarea.dispatchEvent(compositionEvent('compositionend', '한'))
}
textarea.dispatchEvent(keyboardEvent('keydown', '₩'))
textarea.dispatchEvent(keyboardEvent('keypress', '`'))
textarea.value = '`'
textarea.dispatchEvent(
new InputEvent('input', { data: '`', inputType: 'insertText', bubbles: true })
)
textarea.dispatchEvent(keyboardEvent('keyup', '₩'))
await nextEventLoop()
forwarder.dispose()
tracker.dispose()
terminal.dispose()
return emitted.join('')
}
describe('Korean won input after a composition commit', () => {
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
measureText: () => ({ width: 10 })
} as unknown as CanvasRenderingContext2D)
})
afterEach(() => {
vi.restoreAllMocks()
document.body.replaceChildren()
})
it.each(['`', '₩'] as const)(
'preserves composition-first order when won commits as %s',
async (committedWonText) => {
await expect(typeHangulThenWon([committedWonText])).resolves.toBe(`${committedWonText}`)
}
)
it('preserves back-to-back won transactions before the composition finalizer', async () => {
await expect(typeHangulThenWon(['`', '₩'])).resolves.toBe('한`₩')
})
it('releases composition ownership when an ordinary keydown flushes the finalizer', async () => {
await expect(
typeHangulThenWon(['₩'], {
beforeWon: (textarea) => {
textarea.dispatchEvent(keyboardEvent('keydown', 'a', 'KeyA', 65))
}
})
).resolves.toBe('한a₩')
})
it('suppresses a duplicate insertText without a new keydown or textarea mutation', async () => {
await expect(typeHangulThenWon(['`'], { duplicateFirstInput: true })).resolves.toBe('한`')
})
it('preserves a 64-input native-text burst before the composition finalizer', async () => {
const committed = Array.from({ length: 64 }, (_, index) => (index % 2 === 0 ? '`' : '₩'))
await expect(typeHangulThenWon(committed)).resolves.toBe(`${committed.join('')}`)
})
it('preserves an immediate won remap after repeated stale composition ends', async () => {
await expect(typeHangulThenRepeatedStaleEndsThenWon()).resolves.toBe('한`')
})
})

View File

@ -0,0 +1,238 @@
// @vitest-environment happy-dom
import { createRequire } from 'node:module'
import { Terminal as EsmTerminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const requireFromHere = createRequire(import.meta.url)
const { Terminal: CjsTerminal } = requireFromHere('@xterm/xterm') as {
Terminal: typeof EsmTerminal
}
function nextEventLoop(): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, 0))
}
function openTerminal(TerminalType: typeof EsmTerminal): {
emitted: string[]
terminal: EsmTerminal
textarea: HTMLTextAreaElement
} {
const container = document.createElement('div')
document.body.appendChild(container)
const terminal = new TerminalType()
terminal.open(container)
if (!terminal.textarea) {
throw new Error('xterm textarea was not created')
}
const emitted: string[] = []
terminal.onData((data) => emitted.push(data))
return { emitted, terminal, textarea: terminal.textarea }
}
function composition(
textarea: HTMLTextAreaElement,
type: 'compositionstart' | 'compositionupdate' | 'compositionend',
data?: string
): void {
const event = new CompositionEvent(type, { bubbles: true })
if (data !== undefined) {
Object.defineProperty(event, 'data', { value: data })
}
textarea.dispatchEvent(event)
}
function start(textarea: HTMLTextAreaElement, text: string): void {
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
composition(textarea, 'compositionstart')
composition(textarea, 'compositionupdate', text)
textarea.value += text
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
}
function compositionState(terminal: EsmTerminal): {
endTimer?: unknown
positionTimer?: unknown
timers: Set<unknown>
viewTimer?: unknown
} {
const helper = (
terminal as unknown as {
_core: {
_compositionHelper: {
_compositionEndTimer?: unknown
_compositionPositionTimer?: unknown
_compositionTimers: Set<unknown>
_compositionViewTimer?: unknown
}
}
}
)._core._compositionHelper
return {
endTimer: helper._compositionEndTimer,
positionTimer: helper._compositionPositionTimer,
timers: helper._compositionTimers,
viewTimer: helper._compositionViewTimer
}
}
describe.each([
['ESM', EsmTerminal],
['CJS', CjsTerminal]
])('installed xterm adversarial composition ownership (%s)', (_format, TerminalType) => {
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
measureText: () => ({ width: 10 })
} as unknown as CanvasRenderingContext2D)
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
document.body.replaceChildren()
})
it('rejects a stale end between an immediate restart and its first update', async () => {
const { emitted, terminal, textarea } = openTerminal(TerminalType)
start(textarea, 'A')
await nextEventLoop()
composition(textarea, 'compositionend', 'A')
textarea.setSelectionRange(1, 1)
composition(textarea, 'compositionstart')
composition(textarea, 'compositionend', 'A')
await nextEventLoop()
composition(textarea, 'compositionupdate', 'B')
textarea.value = 'AB'
textarea.setSelectionRange(2, 2)
composition(textarea, 'compositionend', 'B')
await nextEventLoop()
expect(emitted.join('')).toBe('AB')
terminal.dispose()
})
it('accepts a repeated no-update commit after textarea progress', async () => {
const { emitted, terminal, textarea } = openTerminal(TerminalType)
start(textarea, '가')
composition(textarea, 'compositionend', '가')
composition(textarea, 'compositionstart')
textarea.value = '가가'
textarea.setSelectionRange(2, 2)
composition(textarea, 'compositionend', '가')
await nextEventLoop()
expect(emitted.join('')).toBe('가가')
terminal.dispose()
})
it('accepts repeated no-update data when native progress follows the end event', async () => {
const { emitted, terminal, textarea } = openTerminal(TerminalType)
const lifecycle: string[] = []
textarea.addEventListener('xterm-composition-transaction-accepted', () =>
lifecycle.push('accepted')
)
textarea.addEventListener('xterm-composition-transaction-settled', () =>
lifecycle.push('settled')
)
start(textarea, '가')
composition(textarea, 'compositionend', '가')
composition(textarea, 'compositionstart')
composition(textarea, 'compositionend', '가')
textarea.value = '가가'
textarea.setSelectionRange(2, 2)
await nextEventLoop()
expect(emitted.join('')).toBe('가가')
expect(lifecycle).toEqual(['accepted', 'settled', 'accepted', 'settled'])
terminal.dispose()
})
it('rejects a data-less stale end before restarted progress', async () => {
const { emitted, terminal, textarea } = openTerminal(TerminalType)
start(textarea, 'A')
composition(textarea, 'compositionend', 'A')
composition(textarea, 'compositionstart')
composition(textarea, 'compositionend')
await nextEventLoop()
composition(textarea, 'compositionupdate', 'B')
textarea.value = 'AB'
textarea.setSelectionRange(2, 2)
composition(textarea, 'compositionend', 'B')
await nextEventLoop()
expect(emitted.join('')).toBe('AB')
terminal.dispose()
})
it('rejects a stale end after update but before textarea progress', async () => {
const { emitted, terminal, textarea } = openTerminal(TerminalType)
start(textarea, 'A')
composition(textarea, 'compositionend', 'A')
composition(textarea, 'compositionstart')
composition(textarea, 'compositionupdate', 'B')
composition(textarea, 'compositionend', 'A')
await nextEventLoop()
textarea.value = 'AB'
textarea.setSelectionRange(2, 2)
composition(textarea, 'compositionend', 'B')
await nextEventLoop()
expect(emitted.join('')).toBe('AB')
terminal.dispose()
})
it('bounds tracked timers during same-task transaction bursts', async () => {
const { emitted, terminal, textarea } = openTerminal(TerminalType)
let maximumTimerCount = 0
for (let index = 0; index < 256; index++) {
start(textarea, '가')
composition(textarea, 'compositionend', '가')
maximumTimerCount = Math.max(maximumTimerCount, compositionState(terminal).timers.size)
}
await nextEventLoop()
expect(emitted.join('')).toBe('가'.repeat(256))
expect(maximumTimerCount).toBeLessThanOrEqual(4)
expect(compositionState(terminal).timers.size).toBe(0)
terminal.dispose()
})
it('keeps newer timer slots when canceled callbacks are forced', () => {
const { terminal, textarea } = openTerminal(TerminalType)
const callbacks: (() => void)[] = []
const cleared = new Set<object>()
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => {
const token = {}
callbacks.push(() => {
if (!cleared.has(token)) {
callback()
}
})
return token
}) as typeof setTimeout)
vi.spyOn(globalThis, 'clearTimeout').mockImplementation(((token: object) => {
cleared.add(token)
}) as typeof clearTimeout)
start(textarea, 'A')
const oldState = compositionState(terminal)
composition(textarea, 'compositionstart')
composition(textarea, 'compositionend', 'A')
const staleEndTimer = compositionState(terminal).endTimer
composition(textarea, 'compositionupdate', 'B')
const newState = compositionState(terminal)
for (const callback of callbacks) {
callback()
}
expect(cleared).toContain(oldState.positionTimer)
expect(cleared).toContain(oldState.viewTimer)
expect(cleared).toContain(staleEndTimer)
expect(newState.positionTimer).not.toBe(oldState.positionTimer)
expect(newState.viewTimer).not.toBe(oldState.viewTimer)
expect(compositionState(terminal).positionTimer).toBe(newState.positionTimer)
expect(compositionState(terminal).viewTimer).toBe(newState.viewTimer)
expect(compositionState(terminal).timers.size).toBe(0)
terminal.dispose()
})
})

View File

@ -6,14 +6,14 @@ function nextEventLoop(): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, 0))
}
function openTerminal(): {
function openTerminal(screenReaderMode = false): {
emitted: string[]
terminal: Terminal
textarea: HTMLTextAreaElement
} {
const container = document.createElement('div')
document.body.appendChild(container)
const terminal = new Terminal()
const terminal = new Terminal({ screenReaderMode })
terminal.open(container)
const textarea = terminal.textarea
if (!textarea) {
@ -24,12 +24,103 @@ function openTerminal(): {
return { emitted, terminal, textarea }
}
function dispatchCompositionEvent(
textarea: HTMLTextAreaElement,
type: 'compositionstart' | 'compositionupdate' | 'compositionend',
data: string = ''
): void {
const event = new CompositionEvent(type, { bubbles: true })
// happy-dom ignores CompositionEventInit.data, but Chromium supplies it.
Object.defineProperty(event, 'data', { value: data })
textarea.dispatchEvent(event)
}
function startComposition(textarea: HTMLTextAreaElement, text: string): void {
textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }))
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: text, bubbles: true }))
dispatchCompositionEvent(textarea, 'compositionstart')
dispatchCompositionEvent(textarea, 'compositionupdate', text)
textarea.value = text
}
function dispatchKeydown(
textarea: HTMLTextAreaElement,
key: string,
code: string,
keyCode: number,
isComposing = false,
timeStamp?: number
): void {
const keydown = new KeyboardEvent('keydown', { key, code, isComposing, bubbles: true })
Object.defineProperty(keydown, 'keyCode', { value: keyCode })
if (timeStamp !== undefined) {
Object.defineProperty(keydown, 'timeStamp', { value: timeStamp })
}
textarea.dispatchEvent(keydown)
}
function dispatchComposedInput(textarea: HTMLTextAreaElement, init: InputEventInit): void {
const input = new InputEvent('input', { ...init, bubbles: true })
// happy-dom ignores InputEventInit.composed, but Chromium reports it for this IBus path.
Object.defineProperty(input, 'composed', { value: true })
textarea.dispatchEvent(input)
}
function typeObservedAscii(textarea: HTMLTextAreaElement, text: string): void {
for (const character of text) {
dispatchKeydown(textarea, character, `Key${character.toUpperCase()}`, character.charCodeAt(0))
textarea.value += character
dispatchComposedInput(textarea, { data: character, inputType: 'insertText' })
}
}
function updateObservedIbusComposition(
textarea: HTMLTextAreaElement,
prefix: string,
text: string
): void {
dispatchCompositionEvent(textarea, 'compositionupdate', text)
textarea.value = `${prefix}${text}`
dispatchComposedInput(textarea, { data: text, inputType: 'insertCompositionText' })
}
function startObservedIbusComposition(textarea: HTMLTextAreaElement, text: string): string {
const prefix = textarea.value
textarea.setSelectionRange(prefix.length, prefix.length)
dispatchCompositionEvent(textarea, 'compositionstart')
dispatchKeydown(textarea, 'Process', 'KeyG', 229)
updateObservedIbusComposition(textarea, prefix, text)
return prefix
}
function endObservedIbusComposition(textarea: HTMLTextAreaElement, prefix: string): void {
dispatchCompositionEvent(textarea, 'compositionupdate')
textarea.value = prefix
dispatchComposedInput(textarea, { inputType: 'deleteContentBackward' })
dispatchCompositionEvent(textarea, 'compositionend')
}
function commitObservedIbusComposition(
textarea: HTMLTextAreaElement,
prefix: string,
text: string
): void {
endObservedIbusComposition(textarea, prefix)
textarea.value = `${prefix}${text}`
dispatchComposedInput(textarea, { data: text, inputType: 'insertText' })
}
function typeObservedIbusCommit(textarea: HTMLTextAreaElement, text: string): void {
const prefix = startObservedIbusComposition(textarea, text)
commitObservedIbusComposition(textarea, prefix, text)
}
function typeObservedIbusKeypressCommit(textarea: HTMLTextAreaElement, text: string): void {
const prefix = startObservedIbusComposition(textarea, text)
endObservedIbusComposition(textarea, prefix)
dispatchKeypress(textarea, text)
textarea.value = `${prefix}${text}`
dispatchComposedInput(textarea, { data: text, inputType: 'insertText' })
}
function dispatchKeypress(textarea: HTMLTextAreaElement, text: string): void {
const keypress = new KeyboardEvent('keypress', { key: text, bubbles: true })
// happy-dom omits Chromium's legacy charCode field that xterm still reads.
@ -37,6 +128,25 @@ function dispatchKeypress(textarea: HTMLTextAreaElement, text: string): void {
textarea.dispatchEvent(keypress)
}
function getPendingFinalizationCount(terminal: Terminal): number {
const pending = (
terminal as unknown as {
_core: { _compositionHelper: { _pendingComposition?: unknown } }
}
)._core._compositionHelper._pendingComposition
return pending === undefined ? 0 : 1
}
function getCompositionHelper(terminal: Terminal): {
keypress(text: string): boolean
} {
return (
terminal as unknown as {
_core: { _compositionHelper: { keypress(text: string): boolean } }
}
)._core._compositionHelper
}
describe('xterm IME composition de-duplication', () => {
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
@ -49,6 +159,404 @@ describe('xterm IME composition de-duplication', () => {
document.body.replaceChildren()
})
it('emits a propagated IBus Hangul commit exactly once', async () => {
const { emitted, terminal, textarea } = openTerminal()
typeObservedIbusCommit(textarea, '한')
await nextEventLoop()
expect(emitted.join('')).toBe('한')
terminal.dispose()
})
it('does not drop an unpropagated IBus commit before the following keydown', async () => {
const { emitted, terminal, textarea } = openTerminal()
startObservedIbusComposition(textarea, '한')
dispatchCompositionEvent(textarea, 'compositionend', '한')
dispatchKeydown(textarea, 'a', 'KeyA', 65)
await nextEventLoop()
expect(emitted.join('')).toBe('한a')
terminal.dispose()
})
it('preserves a macOS Hangul candidate when the input source changes to ASCII', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '한')
dispatchCompositionEvent(textarea, 'compositionupdate', 'a')
textarea.value = '한a'
dispatchComposedInput(textarea, { data: 'a', inputType: 'insertText' })
dispatchCompositionEvent(textarea, 'compositionend', 'a')
await nextEventLoop()
expect(emitted.join('')).toBe('한a')
terminal.dispose()
})
it('leaves ordinary ASCII outside composition bookkeeping', () => {
const { emitted, terminal, textarea } = openTerminal()
typeObservedAscii(textarea, 'abcdefghijklmno')
expect(emitted.join('')).toBe('abcdefghijklmno')
expect(getPendingFinalizationCount(terminal)).toBe(0)
terminal.dispose()
})
it.each(['日本語', '中文'])('preserves a propagated %s IME commit', async (text) => {
const { emitted, terminal, textarea } = openTerminal()
typeObservedIbusCommit(textarea, text)
await nextEventLoop()
expect(emitted.join('')).toBe(text)
terminal.dispose()
})
it('uses the selected Chinese candidate instead of its Pinyin preedit', async () => {
const { emitted, terminal, textarea } = openTerminal()
dispatchCompositionEvent(textarea, 'compositionstart')
dispatchCompositionEvent(textarea, 'compositionupdate', 'ni')
textarea.value = 'ni'
dispatchCompositionEvent(textarea, 'compositionend', '你')
textarea.value = '你'
dispatchComposedInput(textarea, { data: '你', inputType: 'insertText' })
await nextEventLoop()
expect(emitted.join('')).toBe('你')
terminal.dispose()
})
it('keeps an unmatched Japanese keypress after its composition', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '日')
await nextEventLoop()
dispatchCompositionEvent(textarea, 'compositionend', '日')
dispatchKeypress(textarea, '本')
await nextEventLoop()
expect(emitted.join('')).toBe('日本')
terminal.dispose()
})
it('keeps unrelated text after the composition that preceded it', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '漢')
await nextEventLoop()
dispatchCompositionEvent(textarea, 'compositionend', '漢')
dispatchKeypress(textarea, 'x')
await nextEventLoop()
expect(emitted.join('')).toBe('漢x')
terminal.dispose()
})
it.each([
['日本', '本'],
['가나다', '다'],
['🇰🇷', '🇰'],
['👩‍💻', '💻'],
['가', '가'],
['👍🏽', '👍🏽'],
['e\u0301', '\u0301'],
['中文', '文'],
['한', 'a'],
['a', 'a']
])('keeps immediately-following %s then %s input distinct', async (composition, following) => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, composition)
await nextEventLoop()
dispatchCompositionEvent(textarea, 'compositionend', composition)
textarea.value = `${composition}${following}`
dispatchComposedInput(textarea, { data: following, inputType: 'insertText' })
await nextEventLoop()
expect(emitted.join('')).toBe(`${composition}${following}`)
terminal.dispose()
})
it('emits repeated Korean insertText within one composition exactly twice', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '가가')
await nextEventLoop()
dispatchComposedInput(textarea, { data: '가', inputType: 'insertText' })
dispatchComposedInput(textarea, { data: '가', inputType: 'insertText' })
dispatchCompositionEvent(textarea, 'compositionend', '가가')
await nextEventLoop()
expect(emitted.join('')).toBe('가가')
terminal.dispose()
})
it('owns Chinese insertText that arrives before compositionend', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '中文')
await nextEventLoop()
dispatchComposedInput(textarea, { data: '中文', inputType: 'insertText' })
dispatchCompositionEvent(textarea, 'compositionend', '中文')
await nextEventLoop()
expect(emitted.join('')).toBe('中文')
terminal.dispose()
})
it('does not scan large unrelated observations for partial overlap', async () => {
const { emitted, terminal, textarea } = openTerminal()
const composition = 'a'.repeat(10_000)
const following = 'b'.repeat(50_000)
startComposition(textarea, composition)
await nextEventLoop()
dispatchCompositionEvent(textarea, 'compositionend', composition)
const endsWith = vi.spyOn(String.prototype, 'endsWith')
const startsWith = vi.spyOn(String.prototype, 'startsWith')
expect(getCompositionHelper(terminal).keypress(following)).toBe(false)
await nextEventLoop()
expect(emitted.join('')).toBe(composition)
expect(endsWith).not.toHaveBeenCalled()
expect(startsWith).not.toHaveBeenCalled()
terminal.dispose()
})
it('preserves a Korean final-consonant transfer across compositions', async () => {
const { emitted, terminal, textarea } = openTerminal()
startObservedIbusComposition(textarea, 'ㅇ')
updateObservedIbusComposition(textarea, '', '아')
updateObservedIbusComposition(textarea, '', '앙')
dispatchCompositionEvent(textarea, 'compositionend', '앙')
textarea.setSelectionRange(1, 1)
dispatchCompositionEvent(textarea, 'compositionstart')
updateObservedIbusComposition(textarea, '아', '아')
dispatchCompositionEvent(textarea, 'compositionend', '아')
await nextEventLoop()
expect(emitted.join('')).toBe('아아')
terminal.dispose()
})
it('uses the authoritative IBus insertText after a final-consonant transfer', async () => {
const { emitted, terminal, textarea } = openTerminal()
const prefix = startObservedIbusComposition(textarea, '텟')
await nextEventLoop()
endObservedIbusComposition(textarea, prefix)
textarea.value = '테'
dispatchComposedInput(textarea, { data: '테', inputType: 'insertText' })
await nextEventLoop()
expect(emitted.join('')).toBe('테')
terminal.dispose()
})
it('does not let stale timers leak across composition transactions', async () => {
const { emitted, terminal, textarea } = openTerminal()
startObservedIbusComposition(textarea, '테')
dispatchCompositionEvent(textarea, 'compositionend', '테')
dispatchKeydown(textarea, 'a', 'KeyA', 65)
dispatchKeydown(textarea, 'Process', 'KeyR', 229)
textarea.setSelectionRange(1, 1)
dispatchCompositionEvent(textarea, 'compositionstart')
updateObservedIbusComposition(textarea, '테', '스')
commitObservedIbusComposition(textarea, '테', '스')
dispatchKeydown(textarea, 'Enter', 'Enter', 13)
await nextEventLoop()
expect(emitted.join('')).toBe('테a스\r')
terminal.dispose()
})
it('keeps a deferred composition update scoped to its transaction', async () => {
const { terminal, textarea } = openTerminal()
startObservedIbusComposition(textarea, '한')
dispatchCompositionEvent(textarea, 'compositionend', '한')
dispatchComposedInput(textarea, { data: '한', inputType: 'insertText' })
textarea.setSelectionRange(1, 1)
dispatchCompositionEvent(textarea, 'compositionstart')
textarea.value = '한글'
textarea.setSelectionRange(2, 2)
await nextEventLoop()
const compositionPosition = (
terminal as unknown as {
_core: { _compositionHelper: { _compositionPosition: { start: number; end: number } } }
}
)._core._compositionHelper._compositionPosition
expect(compositionPosition).toEqual({ start: 1, end: 1 })
dispatchCompositionEvent(textarea, 'compositionend', '글')
await nextEventLoop()
terminal.dispose()
})
it('preserves a retained commit before a no-keydown text insertion', async () => {
const { emitted, terminal, textarea } = openTerminal()
startObservedIbusComposition(textarea, '한')
dispatchCompositionEvent(textarea, 'compositionend', '한')
textarea.value = '한x'
dispatchComposedInput(textarea, { data: 'x', inputType: 'insertText' })
await nextEventLoop()
expect(emitted.join('')).toBe('한x')
terminal.dispose()
})
it('keeps screen-reader trailing text outside an authoritative commit', async () => {
const { emitted, terminal, textarea } = openTerminal(true)
textarea.value = '一二'
textarea.setSelectionRange(1, 1)
dispatchCompositionEvent(textarea, 'compositionstart')
dispatchCompositionEvent(textarea, 'compositionupdate', '一')
textarea.value = '一一二'
textarea.setSelectionRange(2, 2)
dispatchCompositionEvent(textarea, 'compositionend', '一')
dispatchComposedInput(textarea, { data: '一', inputType: 'insertText' })
await nextEventLoop()
expect(emitted.join('')).toBe('一')
terminal.dispose()
})
it('flushes an unpropagated Hangul commit before Enter', async () => {
const { emitted, terminal, textarea } = openTerminal()
startObservedIbusComposition(textarea, '한')
dispatchCompositionEvent(textarea, 'compositionend', '한')
dispatchKeydown(textarea, 'Enter', 'Enter', 13)
await nextEventLoop()
expect(emitted.join('')).toBe('한\r')
terminal.dispose()
})
it('deduplicates each commit without suppressing a legitimate repeated syllable', async () => {
const { emitted, terminal, textarea } = openTerminal()
typeObservedIbusKeypressCommit(textarea, '가')
typeObservedIbusKeypressCommit(textarea, '가')
dispatchKeydown(textarea, 'Enter', 'Enter', 13)
await nextEventLoop()
expect(emitted.join('')).toBe('가가\r')
terminal.dispose()
})
it('ignores a duplicate compositionend for the same transaction', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '한')
dispatchCompositionEvent(textarea, 'compositionend', '한')
dispatchCompositionEvent(textarea, 'compositionend', '한')
await nextEventLoop()
expect(emitted.join('')).toBe('한')
expect(getPendingFinalizationCount(terminal)).toBe(0)
terminal.dispose()
})
it('ignores a stale compositionend after the next transaction starts', async () => {
const { emitted, terminal, textarea } = openTerminal()
dispatchCompositionEvent(textarea, 'compositionstart')
dispatchCompositionEvent(textarea, 'compositionupdate', 'A')
textarea.value = 'A'
textarea.setSelectionRange(1, 1)
dispatchCompositionEvent(textarea, 'compositionend', 'A')
dispatchCompositionEvent(textarea, 'compositionstart')
dispatchCompositionEvent(textarea, 'compositionupdate', 'B')
dispatchCompositionEvent(textarea, 'compositionend', 'A')
dispatchCompositionEvent(textarea, 'compositionend', 'B')
await nextEventLoop()
expect(emitted.join('')).toBe('AB')
terminal.dispose()
})
it('bounds pending finalizations during synchronous composition turnover', async () => {
const { emitted, terminal, textarea } = openTerminal()
for (let index = 0; index < 20; index++) {
textarea.setSelectionRange(textarea.value.length, textarea.value.length)
dispatchCompositionEvent(textarea, 'compositionstart')
dispatchCompositionEvent(textarea, 'compositionupdate', '가')
textarea.value += '가'
dispatchCompositionEvent(textarea, 'compositionend', '가')
expect(getPendingFinalizationCount(terminal)).toBeLessThanOrEqual(1)
}
await nextEventLoop()
expect(emitted.join('')).toBe('가'.repeat(20))
terminal.dispose()
})
it('keeps matching text when a new composition restarts immediately', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '日本')
await nextEventLoop()
dispatchCompositionEvent(textarea, 'compositionend', '日本')
textarea.setSelectionRange(2, 2)
dispatchCompositionEvent(textarea, 'compositionstart')
dispatchCompositionEvent(textarea, 'compositionupdate', '本')
textarea.value = '日本本'
textarea.setSelectionRange(3, 3)
dispatchCompositionEvent(textarea, 'compositionend', '本')
await nextEventLoop()
expect(emitted.join('')).toBe('日本本')
terminal.dispose()
})
it('flushes a pending commit before blur clears the textarea', async () => {
const { emitted, terminal, textarea } = openTerminal()
terminal.focus()
startComposition(textarea, '한')
dispatchCompositionEvent(textarea, 'compositionend', '한')
textarea.blur()
await nextEventLoop()
expect(emitted.join('')).toBe('한')
terminal.dispose()
})
it('flushes an active composition before blur clears the textarea', async () => {
const { emitted, terminal, textarea } = openTerminal()
terminal.focus()
startComposition(textarea, '한')
textarea.blur()
await nextEventLoop()
expect(emitted.join('')).toBe('한')
terminal.dispose()
})
it('does not emit deferred composition data after disposal', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '한')
dispatchCompositionEvent(textarea, 'compositionend', '한')
terminal.dispose()
await nextEventLoop()
expect(emitted).toEqual([])
expect(getPendingFinalizationCount(terminal)).toBe(0)
})
it('emits a post-composition IBus Hangul keypress only once', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '한')
@ -57,20 +565,17 @@ describe('xterm IME composition de-duplication', () => {
// Why: IBus clears at compositionend, then restores the same commit after
// xterm's keypress path has already emitted it.
textarea.value = ''
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
dispatchCompositionEvent(textarea, 'compositionend')
const compositionHelper = (
terminal as unknown as {
_core: {
_compositionHelper: {
_isSendingComposition: boolean
_pendingKeypressData: string
}
_compositionHelper: { _pendingComposition?: { keypressData: string } }
}
}
)._core._compositionHelper
expect(compositionHelper._isSendingComposition).toBe(true)
expect(compositionHelper._pendingComposition).toBeDefined()
dispatchKeypress(textarea, '한')
expect(compositionHelper._pendingKeypressData).toBe('한')
expect(compositionHelper._pendingComposition?.keypressData).toBe('한')
expect(emitted).toEqual([])
textarea.value = '한'
textarea.dispatchEvent(
@ -82,57 +587,55 @@ describe('xterm IME composition de-duplication', () => {
terminal.dispose()
})
it('preserves composition-first order when keypress overlaps its suffix', async () => {
it('keeps a following keypress even when it matches the composition suffix', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '가한')
await nextEventLoop()
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
dispatchCompositionEvent(textarea, 'compositionend')
dispatchKeypress(textarea, '한')
await nextEventLoop()
expect(emitted.join('')).toBe('가한')
expect(emitted.join('')).toBe('가한')
terminal.dispose()
})
it('emits unmatched keypress before propagated composition text', async () => {
it('emits propagated composition text before an unmatched keypress', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '한')
await nextEventLoop()
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: 'a', bubbles: true }))
dispatchCompositionEvent(textarea, 'compositionupdate', 'a')
textarea.value = 'a'
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
dispatchCompositionEvent(textarea, 'compositionend')
dispatchKeypress(textarea, '한')
await nextEventLoop()
expect(emitted.join('')).toBe('a')
expect(emitted.join('')).toBe('a')
terminal.dispose()
})
it('does not repeat keypress contained before propagated composition text', async () => {
it('keeps a following keypress even when it matches the composition prefix', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '한')
await nextEventLoop()
textarea.dispatchEvent(
new CompositionEvent('compositionupdate', { data: '한a', bubbles: true })
)
dispatchCompositionEvent(textarea, 'compositionupdate', '한a')
textarea.value = '한a'
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
dispatchCompositionEvent(textarea, 'compositionend')
dispatchKeypress(textarea, '한')
await nextEventLoop()
expect(emitted.join('')).toBe('한a')
expect(emitted.join('')).toBe('한a')
terminal.dispose()
})
it('merges multiple deferred keypresses with partial textarea overlap', async () => {
it('preserves multiple keypresses when textarea propagation is partial', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '한')
await nextEventLoop()
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
dispatchCompositionEvent(textarea, 'compositionend')
dispatchKeypress(textarea, 'a')
dispatchKeypress(textarea, 'b')
textarea.value = '한a'
@ -148,7 +651,7 @@ describe('xterm IME composition de-duplication', () => {
await nextEventLoop()
textarea.value = ''
textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }))
dispatchCompositionEvent(textarea, 'compositionend')
dispatchKeypress(textarea, '한')
textarea.value = '한'
const keydown = new KeyboardEvent('keydown', { key: 'a', code: 'KeyA', bubbles: true })
@ -159,4 +662,85 @@ describe('xterm IME composition de-duplication', () => {
expect(emitted.join('')).toBe('한a')
terminal.dispose()
})
it('preserves macOS Korean commit boundaries across blur and refocus', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '하')
dispatchCompositionEvent(textarea, 'compositionend', '하')
textarea.dispatchEvent(new FocusEvent('blur'))
textarea.dispatchEvent(new FocusEvent('focus'))
startComposition(textarea, 'ㄴ ')
dispatchCompositionEvent(textarea, 'compositionend', 'ㄴ ')
await nextEventLoop()
expect(emitted.join('')).toBe('하ㄴ ')
terminal.dispose()
})
it('commits the visible composition when pane blur precedes compositionend', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '한')
await nextEventLoop()
textarea.dispatchEvent(new FocusEvent('blur'))
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
await nextEventLoop()
expect(emitted.join('')).toBe('한')
terminal.dispose()
})
it('commits to the composing terminal when focus switches to another terminal', async () => {
const original = openTerminal()
const unrelated = openTerminal()
startComposition(original.textarea, '한')
await nextEventLoop()
original.textarea.dispatchEvent(
new CompositionEvent('compositionend', { data: '한', bubbles: true })
)
original.textarea.dispatchEvent(new FocusEvent('blur'))
unrelated.textarea.focus()
await nextEventLoop()
expect(original.emitted.join('')).toBe('한')
expect(unrelated.emitted).toEqual([])
original.terminal.dispose()
unrelated.terminal.dispose()
})
it('finalizes source-switch cancellation before a later Korean composition', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '한')
await nextEventLoop()
dispatchKeydown(textarea, 'Escape', 'Escape', 229, true, 100)
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
dispatchKeydown(textarea, 'Escape', 'Escape', 27, false, 100)
await nextEventLoop()
expect(emitted).toEqual([])
textarea.value = ''
startComposition(textarea, '한')
await nextEventLoop()
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
await nextEventLoop()
expect(emitted.join('')).toBe('한')
terminal.dispose()
})
it('cancels a pending completion when Escape follows compositionend', async () => {
const { emitted, terminal, textarea } = openTerminal()
startComposition(textarea, '한')
await nextEventLoop()
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
dispatchKeydown(textarea, 'Escape', 'Escape', 229, true, 100)
dispatchKeydown(textarea, 'Escape', 'Escape', 27, false, 100)
await nextEventLoop()
expect(emitted).toEqual([])
terminal.dispose()
})
})

View File

@ -0,0 +1,217 @@
// @vitest-environment happy-dom
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { Terminal as EsmTerminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
installTerminalImeNativeTextForwarder,
XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT,
XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT
} from './terminal-ime-native-text-forwarder'
const requireFromHere = createRequire(import.meta.url)
const { Terminal: CjsTerminal } = requireFromHere('@xterm/xterm') as {
Terminal: typeof EsmTerminal
}
const xtermPackageRoot = dirname(requireFromHere.resolve('@xterm/xterm/package.json'))
const EXPECTED_XTERM_VERSION = '6.1.0-beta.287'
function nextEventLoop(): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, 0))
}
function dispatchWonKeyEvent(
textarea: HTMLTextAreaElement,
type: 'keydown' | 'keypress' | 'keyup',
key: string
): void {
const event = new KeyboardEvent(type, { key, code: 'Backquote', bubbles: true })
Object.defineProperties(event, {
charCode: { value: type === 'keypress' ? key.charCodeAt(0) : 0 },
keyCode: { value: 192 },
which: { value: type === 'keypress' ? key.charCodeAt(0) : 192 }
})
textarea.dispatchEvent(event)
}
async function openComposedTerminal(TerminalType: typeof EsmTerminal): Promise<{
terminal: EsmTerminal
textarea: HTMLTextAreaElement
events: string[]
output: string[]
}> {
const terminal = new TerminalType()
const container = document.createElement('div')
document.body.appendChild(container)
terminal.open(container)
if (!terminal.element || !terminal.textarea) {
throw new Error('xterm input elements were not created')
}
const events: string[] = []
const output: string[] = []
terminal.onData((data) => output.push(data))
terminal.element.addEventListener(XTERM_COMPOSITION_TRANSACTION_ACCEPTED_EVENT, () => {
events.push('accepted')
})
terminal.element.addEventListener(XTERM_COMPOSITION_TRANSACTION_SETTLED_EVENT, () => {
events.push('settled')
})
terminal.textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }))
terminal.textarea.dispatchEvent(
new CompositionEvent('compositionupdate', { data: '한', bubbles: true })
)
terminal.textarea.value = '한'
terminal.textarea.setSelectionRange(1, 1)
await nextEventLoop()
return { terminal, textarea: terminal.textarea, events, output }
}
describe.each([
['ESM', EsmTerminal, 'xterm.mjs.map'],
['CJS', CjsTerminal, 'xterm.js.map']
])('xterm composition transaction events (%s)', (_format, TerminalType, sourceMapName) => {
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
measureText: () => ({ width: 10 })
} as unknown as CanvasRenderingContext2D)
})
afterEach(() => {
vi.restoreAllMocks()
document.body.replaceChildren()
})
it('keeps the runtime and mapped source package versions aligned', async () => {
const terminal = new TerminalType()
const output: string[] = []
terminal.onData((data) => output.push(data))
await new Promise<void>((resolve) => terminal.write('\x1b[>0q', resolve))
expect(output).toEqual([`\x1bP>|xterm.js(${EXPECTED_XTERM_VERSION})\x1b\\`])
const sourceMap = JSON.parse(
readFileSync(join(xtermPackageRoot, 'lib', sourceMapName), 'utf8')
) as { sources: string[]; sourcesContent: (string | null)[] }
const versionSourceIndex = sourceMap.sources.findIndex((source) =>
source.endsWith('/common/Version.ts')
)
expect(sourceMap.sourcesContent[versionSourceIndex]).toContain(
`XTERM_VERSION = '${EXPECTED_XTERM_VERSION}'`
)
terminal.dispose()
})
it('settles after the deferred finalizer completes', async () => {
const { terminal, textarea, events } = await openComposedTerminal(TerminalType)
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
expect(events).toEqual(['accepted'])
await nextEventLoop()
expect(events).toEqual(['accepted', 'settled'])
terminal.dispose()
})
it('settles synchronously when an ordinary keydown flushes the finalizer', async () => {
const { terminal, textarea, events } = await openComposedTerminal(TerminalType)
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
const keydown = new KeyboardEvent('keydown', { key: 'a', code: 'KeyA', bubbles: true })
Object.defineProperty(keydown, 'keyCode', { value: 65 })
textarea.dispatchEvent(keydown)
expect(events).toEqual(['accepted', 'settled'])
terminal.dispose()
})
it('settles the old transaction before accepting a restarted composition', async () => {
const { terminal, textarea, events } = await openComposedTerminal(TerminalType)
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }))
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: '글', bubbles: true }))
textarea.value = '한글'
textarea.setSelectionRange(2, 2)
await nextEventLoop()
expect(events).toEqual(['accepted', 'settled'])
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '글', bubbles: true }))
expect(events).toEqual(['accepted', 'settled', 'accepted'])
await nextEventLoop()
expect(events).toEqual(['accepted', 'settled', 'accepted', 'settled'])
terminal.dispose()
})
it('settles every immediately completed restarted composition', async () => {
const { terminal, textarea, events, output } = await openComposedTerminal(TerminalType)
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }))
textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: '글', bubbles: true }))
textarea.value = '한글'
textarea.setSelectionRange(2, 2)
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '글', bubbles: true }))
expect(events).toEqual(['accepted', 'settled', 'accepted'])
await nextEventLoop()
expect(events).toEqual(['accepted', 'settled', 'accepted', 'settled'])
expect(output.join('')).toBe('한글')
terminal.dispose()
})
it('rejects immediate duplicate composition ends', async () => {
const { terminal, textarea, events, output } = await openComposedTerminal(TerminalType)
for (let index = 0; index < 3; index++) {
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
}
expect(events).toEqual(['accepted'])
await nextEventLoop()
expect(events).toEqual(['accepted', 'settled'])
expect(output).toEqual(['한'])
terminal.dispose()
})
it('rejects stale composition ends after settlement', async () => {
const { terminal, textarea, events, output } = await openComposedTerminal(TerminalType)
const forwarder = installTerminalImeNativeTextForwarder({
terminalElement: terminal.element,
isComposing: () => false,
sendInput: (data) => terminal.input(data)
})
terminal.attachCustomKeyEventHandler((event) => !forwarder.claimKeyEvent(event))
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
await nextEventLoop()
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
dispatchWonKeyEvent(textarea, 'keydown', '₩')
dispatchWonKeyEvent(textarea, 'keypress', '`')
textarea.value = '`'
textarea.dispatchEvent(
new InputEvent('input', { data: '`', inputType: 'insertText', bubbles: true })
)
dispatchWonKeyEvent(textarea, 'keyup', '₩')
await nextEventLoop()
expect(events).toEqual(['accepted', 'settled'])
expect(output).toEqual(['한', '`'])
forwarder.dispose()
terminal.dispose()
})
it('leaves an accepted transaction aborted when disposal cancels its finalizer', async () => {
const { terminal, textarea, events, output } = await openComposedTerminal(TerminalType)
textarea.dispatchEvent(new CompositionEvent('compositionend', { data: '한', bubbles: true }))
expect(events).toEqual(['accepted'])
terminal.dispose()
await nextEventLoop()
expect(events).toEqual(['accepted'])
expect(output).toEqual([])
})
})

View File

@ -0,0 +1,123 @@
# PR #11293 + PR #11177 semantic integration R1
## Result
- #11293 base: `a9d8be94df2b750c9d363fc8310567dd7e5504cd`
- Preserved contributor commit: `eb76873ce7b9564b248f7317924e6c49b4001b13`
(`JeongUk Park <jeongph.dev@gmail.com>`, cherry-picked from `2cb6a50e3`)
- Integration commit: `c1b329b72f25d8701769be52d0c400c5eaf29510`
- Finalized #11177 reference: `dc2b59723dcc8cbf2cf44ebe0511323551e9d3b3`
- Upstream xterm source: `53a98a720ae4a973e384fa2440880d09537132f3`
- Published runtime/source-map version: `6.1.0-beta.287`
- Final pnpm patch hash:
`db949d673197f31341f8cac2b25acff4cf6073725bfb6c6d3ca2ed02cfd5c898`
The integration retains #11293/#11011's transaction IDs, pending-composition record,
tracked timers, input/blur/disposal ownership, stale-end rejection, and
Japanese/Chinese/Korean reconciliation. It layers #11177's Korean won/backtick native
forwarding, compatible optional helper interface, accepted/settled events, immediate
restart balance, runtime version, and complete generated artifact set.
Immediate restart cancels the superseded finalizer timer and settles its lifecycle before
the new transaction is accepted, while retaining the stronger pending byte record until
the restarted composition supplies authoritative text. This preserves both
`accepted, settled, accepted, settled` and Korean final-consonant transfer (`아아`, not
`앙아`). When a browser/test double omits `CompositionEvent.data`, the fallback uses
#11177's shortest ordered keypress merge; data-bearing Chromium/IBus paths retain
#11293's distinct-following-keypress contract.
## Authorship and history
JeongUk Park's original contributor authorship is preserved in its own commit before the
integration fix. The exact #11293 base remains intact, including its existing
AmethystLiang and nwparker-authored history. No commit was squashed or rewritten.
## Red control
The final 16-test CJS/ESM transaction suite was run against exact round-six parent
`be7b9b85574266d2da114e3171020db645262bc1`.
- Result: 2 failed, 14 passed.
- Both failures were the intended immediate-full-restart controls.
- Parent events were `accepted, accepted`; the candidate reports
`accepted, settled, accepted`.
The candidate passes all 16 tests and produces `한글` exactly once.
## IME and terminal validation
| Gate | Result |
| ----------------------------------------------- | ----------------------------- |
| Exact #11011 composition suite from `a76760649` | 42/42 passed |
| Exact #11177 ten-file matrix from `dc2b59723` | 127/127 passed |
| Combined current IME superset | 13 files, 218/218 passed |
| Focused transaction/won/#11293 suite | 4 files, 75/75 passed |
| Broad terminal-pane suite | 200 files, 2,673/2,673 passed |
| Native IBus workflow contract tests | 4/4 passed |
The native Linux IBus harness was not run because the integration host is macOS
(`Darwin`); the harness requires Linux/X11 and running it here would violate the
no-GUI/no-input-source-change constraint.
## Artifact and supply-chain integrity
A clean checkout of upstream xterm `53a98a7` received the publication substitutions and
the semantic source union. Upstream `tsgo -b ./tsconfig.all.json`, webpack CJS, and
production esbuild ESM builds passed. A fixture-local self-package link prevented Orca's
ancestor `node_modules` typings from contaminating the clean upstream compile.
| Artifact | SHA-256 |
| ------------------- | ------------------------------------------------------------------ |
| `lib/xterm.js` | `81b4efd747ab4188635955ef60dcc02a4c4920039fad4c9b898ff93710ff8b3a` |
| `lib/xterm.mjs` | `13afb1a5bb49e2bbc8ff8b2867b71b0c8e61f6fb25f49af69ac8f25b72ccf24f` |
| `lib/xterm.js.map` | `2f4394dbed6e3a5baeab2b7fb24ce6e5f940ebbab728d30c093ff8fa0f8ebb4d` |
| `lib/xterm.mjs.map` | `4d7b8a4775bca851c4c19aac70543b2881dad7af27e4f5397403ddb59ec3f090` |
- The final patch applies cleanly to the pristine npm package.
- All patched package files match the installed pnpm package byte-for-byte.
- All 24 lockfile references use the raw patch SHA-256.
- Frozen and frozen-offline installs passed, including native dependency validation.
- CJS and ESM DCS probes both report `xterm.js(6.1.0-beta.287)`.
- Both maps contain the matching `src/common/Version.ts` source.
## Repository gates
- Node, CLI, and web typechecks: passed.
- Full repository lint: passed, including type-aware/native code-quality, reliability,
max-lines, localization, and bundled-skill gates.
- Changed-code-quality and React Doctor: 0 new findings.
- Changed TypeScript formatting: passed.
- Electron Vite production build: passed with existing chunking/CSS warnings.
- `git diff --check`: passed.
One lint invocation intentionally overlapped the Electron build and observed the build's
ephemeral `electron.vite.config.*.mjs` disappear during scanning. The standalone final
lint rerun passed; this was concurrent command interference, not a source failure.
## Latest main compatibility
- Fetched `origin/main`: `a7c8b8e07161ec05c44dbf2a0d11dad9c71a4710`
- Conflict-free merge tree: `95f1aa9fa14d3664adb9aa20a3c35f0ee481979d`
- Materialized merge frozen-offline install: passed.
- Materialized merge IME superset: 13 files, 218/218 passed.
- Materialized merge Node/CLI/web typechecks: passed.
## Security, performance, platform, and workspace audit
- No dependency version or dependency graph changed; `pnpm-lock.yaml` changes only the
xterm patch hash references.
- No filesystem, shell, Git, provider, credential, IPC, HTML injection, or network
surface changed.
- No unbounded timer or collection was added. Superseded finalizers are canceled and
removed from the tracked timer set; the large-observation performance guard remains
green.
- macOS won/backtick routing remains behind the existing input-source feature gate.
Linux and Windows retain their existing paths, including Windows IME ownership tests.
- Forwarded native text still enters `terminal.input()` and xterm's existing ordered
`onData` transport. Local PTYs, SSH/remote runtimes, git worktrees, and folder
workspaces therefore share the established transport with no host-local assumption.
- No Git command, provider-specific review behavior, path handling, or native module
baseline changed.
No push, merge, GitHub/Slack comment, reaction, GUI automation, window focus, or system
input-source change was performed.

View File

@ -0,0 +1,130 @@
# PR #11293 + PR #11177 semantic integration R2
## Result
- R1 product: `c1b329b72f25d8701769be52d0c400c5eaf29510`
- R1 evidence: `1d60de49452ebd7cf0b5e3255c5c48d83d96220a`
- R2 product: `2f5811d7f6e45c0da925ed4b17f68a558b974497`
- Integration base: `a9d8be94df2b750c9d363fc8310567dd7e5504cd`
- Preserved contributor commit:
`eb76873ce7b9564b248f7317924e6c49b4001b13`
(`JeongUk Park <jeongph.dev@gmail.com>`)
- Upstream xterm source: `53a98a720ae4a973e384fa2440880d09537132f3`
- Runtime/source-map version: `6.1.0-beta.287`
- Final xterm patch hash:
`642f6920fa1fdcbd740542f81895b092f383e9bfa926aa3967c4197729c5e40b`
Verifier A's P2 and P3 are fixed in both installed package formats. A restarted
transaction now rejects a pre-update end that repeats the retained prior
transaction's end observation, so the delayed stale sequence emits `AB`, not
`AA`. Deferred composition-position and view work is coalesced by ownership
slot and canceled on restart, bounding the 256-transaction same-task burst at
three tracked timers instead of 769; all tracked state drains to zero.
The retained pending composition record remains separate from accepted/settled
lifecycle settlement. This preserves immediate restart balance, Korean final
consonant reconciliation, Japanese/Chinese ordering, #11052 newline ownership,
and #11177 won/backtick forwarding.
## Parent red and candidate green
The exact verifier A R1 oracle was read and retained as the control:
- R1 installed CJS/ESM oracle: 10 passed, 4 failed of 14.
- Intended failures: delayed stale end in CJS and ESM, plus the timer bound in
CJS and ESM.
- R2 full verifier oracle: 14/14 passed.
- Tracked focused R2 regression:
`terminal-ime-xterm-adversarial.test.ts`, 4/4 passed.
- The tracked regression SHA-256 is
`69b48b8733b407e8cb2ddd716bbd6dbb87ec12696e950ecaf0e738b67af9512f`.
The regression uses real installed xterm DOM listeners and separately imports
the CJS and ESM bundles. It asserts exact PTY bytes, the four-timer ceiling,
timer drainage, and disposal.
## IME and terminal validation
| Gate | Result |
| ----------------------------------------- | ----------------------------- |
| Exact #11011 historical suite | 42/42 passed |
| Exact #11177 ten-file historical matrix | 127/127 passed |
| #11052 Enter/newline matrix | 3 files, 49/49 passed |
| Current combined IME matrix | 14 files, 222/222 passed |
| Full verifier A adversarial oracle | 14/14 passed |
| Focused tracked P2/P3 CJS+ESM regressions | 4/4 passed |
| Broad terminal-pane suite | 201 files, 2,677/2,677 passed |
The combined matrix covers accepted/settled restarts, stale/duplicate ends,
Korean/Japanese/Chinese reconciliation, won/backtick forwarding, Enter and
deferred newline behavior, blur, disposal, Linux candidates, and Windows
ownership guards.
## Artifact and lock integrity
The exact upstream xterm checkout at `53a98a7` compiled with `tsgo`, webpack
CJS, and production esbuild ESM. The installed package reconstructed from the
committed pnpm patch matches the build/package fixture byte-for-byte for the
four artifacts and all modified upstream sources.
| Artifact | SHA-256 |
| ------------------- | ------------------------------------------------------------------ |
| `lib/xterm.js` | `c624587a60f1ed497255262c8d894d7e83e7345e6477be1167a0531b56aedfe8` |
| `lib/xterm.mjs` | `53e259559fb78c996ad5d3cfd608a8e77285e3d39b4f445719ec2ce4eea1b6e0` |
| `lib/xterm.js.map` | `ecb49132c05a0042b563b2434756a985806cb58f024ceecec698f8cec232f509` |
| `lib/xterm.mjs.map` | `5413dad5ed07bf66a8d382e0e3da1cb3b41578f34a275e1dc5e47cbc4ae0054a` |
Both maps contain the exact rebuilt `CompositionHelper.ts` sources content
with SHA-256
`6cffe8b2aeb6d8279d1315c1c26fe7f270370e66ddc5ef43faf5b73e18c337a1`.
All 24 lockfile references use the final raw patch hash. A frozen offline
install passed and selected the matching pnpm virtual-store package.
## Repository gates
- Node, CLI, and web typechecks passed.
- Full lint passed, including native and type-aware code-quality, reliability,
max-lines ratchet, localization, and bundled-skill checks.
- Changed-code quality and React Doctor reported zero new findings.
- Changed TypeScript formatting passed.
- Clean-worktree `git diff --check` passed; the generated patch also applies
cleanly and reconstructs exact source and artifacts.
- Electron Vite production build passed with existing chunking and CSS
pseudo-element warnings.
- Frozen offline install and native dependency validation passed.
## Latest main compatibility
- Fetched `origin/main`:
`238d3a1ea167396a1b38275846cea0dd5059b04a`
- Conflict-free synthetic merge tree:
`8d3178427df19c4e3b4d96287d66a354106b1f0b`
- Materialized merge frozen-offline install: passed.
- Materialized merge IME matrix: 14 files, 222/222 passed.
- Materialized merge Node, CLI, and web typechecks: passed.
## Platform, transport, security, and performance audit
The R2 product changes only the xterm patch, its lock hash, and deterministic
tests. No Orca PTY, SSH, remote-runtime, folder-workspace, git-worktree,
filesystem, provider, IPC, credential, HTML, or network path changed.
Forwarded text still enters xterm's ordered `onData` transport for local,
remote, and SSH terminals. Linux and Windows routing remains unchanged, while
macOS won forwarding retains its existing runtime and modifier gates.
The new timer slots are constant-space and are cleared on supersession,
callback completion, and disposal. The 256-transaction burst emitted all 256
Hangul commits exactly once and drained both pending state and the timer set.
No dependency version or graph changed.
## Bounded gaps
- The host is Darwin, so the native Linux IBus harness was unavailable.
- No GUI automation, window focus, or system input-source change was used.
- No physical macOS, Linux, Windows, Japanese, Chinese, or Korean IME journey
was run.
- No live SSH or folder-workspace native-IME journey was run; deterministic
transport and ownership suites cover those invariant paths.
No push, merge, comment, reaction, GUI action, window focus, or input-source
mutation was performed.

View File

@ -0,0 +1,168 @@
# PR #11293 + PR #11177 semantic integration R3
## Result
- R2 evidence HEAD: `6c26dbae39d5c9bd338b6fd3c425a3545d1bf050`
- R2 product: `2f5811d7f6e45c0da925ed4b17f68a558b974497`
- R3 product: `06e3ec2db829b8b5e4cf40f0018fa896ca267c87`
- Integration base: `a9d8be94df2b750c9d363fc8310567dd7e5504cd`
- Preserved contributor commit:
`eb76873ce7b9564b248f7317924e6c49b4001b13`
(`JeongUk Park <jeongph.dev@gmail.com>`)
- Upstream xterm source: `53a98a720ae4a973e384fa2440880d09537132f3`
- Runtime/source-map version: `6.1.0-beta.287`
- Final xterm patch SHA-256:
`936fabb7682c1f7d37b9c7f49f3130c4141b6c32974f202e6f03bafaa42350ba`
R3 replaces R2's data-equality stale-end heuristic with transaction-owned observable
progress. Each composition records its starting textarea value and selection. An end
belongs to the current transaction only after the transaction has visibly changed that
value or selection, an input/deferred position callback has observed that change, or its
non-empty data matches the current transaction's own non-empty `compositionupdate`.
An ambiguous end is held in one tracked, callback-owned timer slot. If native
textarea/selection progress lands later in the same task, the end is accepted and
finalized; otherwise it remains stale and the transaction waits for its true end. This
fixes all three R2 correctness failures:
- a legitimate repeated no-update `가` commit with visible `가` to `가가` progress emits
`가가` with balanced `accepted, settled, accepted, settled`;
- a data-less stale end immediately after restart no longer consumes the new transaction,
and its true `B` emits final `AB`;
- a stale `end('A')` after the restarted `update('B')` but before textarea mutation no
longer emits `AA`; the true end emits final `AB`.
Immediate restart still settles the old lifecycle before accepting the new transaction,
while retained pending bytes remain available for Korean final-consonant reconciliation.
The Orca composition route does not forward provisional session-end data marked as
pending reconciliation.
## Parent red and candidate green
The complete verifier A R2 installed-bundle oracle was read and reproduced against the
R2 product:
- R2: 20 passed, 6 failed of 26.
- The six expected failures were the repeated no-update false positive, data-less stale
end, and stale end after current update, each in installed CJS and ESM.
- Verifier A's other 20 CJS/ESM controls remained green, including the R1 stale-end
sequence and timer-bound/race cases.
R3 validation combined verifier A's 26 tests, verifier B's repeated no-update test in CJS
and ESM, and the R1 14-test oracle:
- R3 verifier/R1 oracle matrix: 42/42 passed.
- Tracked installed CJS/ESM xterm plus Orca route regressions: 27/27 passed.
- Combined focused regression run: 55/55 passed.
- Tracked regression SHA-256:
`ed375b0ccba820a80385ddb2c0104fb3a37dc285cadfce6f1482483d9cdef086`.
Tracked regressions cover repeated no-update commits with progress before the end and
progress later in the same task, the original data-bearing stale end, the data-less stale
end, current-update-before-textarea stale end, same-data with and without progress, the
four-timer ceiling, timer drainage, disposal, and forced canceled-callback races.
Verifier B also reported ten trailing-whitespace additions in the R2 patch. The regenerated
R3 patch removes them: `git show --check`, `git diff --check HEAD^ HEAD`, and working-tree
`git diff --check` all pass.
## IME and terminal validation
| Gate | Result |
| ----------------------------------------------------- | ----------------------------- |
| Exact #11011 historical suite | 42/42 passed |
| Exact #11177 ten-file historical matrix | 127/127 passed |
| Exact inherited #11052 Enter/newline cases | 49/49 passed |
| Current #11052/route set with R3 reconciliation test | 50/50 passed |
| Current combined IME matrix | 14 files, 233/233 passed |
| Verifier A R2 + verifier B + R1 oracles | 42/42 passed |
| Focused tracked CJS/ESM and route regressions | 27/27 passed |
| Broad terminal-pane suite | 201 files, 2,688/2,688 passed |
| Verifier B R2 native Linux IBus shared local-PTY path | 60/60 exact sequences passed |
The combined matrix covers accepted/settled immediate restarts, stale and duplicate ends,
Korean final-consonant transfer, Japanese/Chinese ordering, #11052 Enter/deferred newline,
#11177 won/backtick forwarding, blur, disposal, Linux candidates, Windows ownership, and
bounded timer/callback ownership.
## Artifact and lock integrity
The exact upstream xterm checkout at `53a98a7` compiled with TypeScript, webpack CJS, and
production esbuild ESM. The committed pnpm patch applies to the matching pristine package;
the reconstructed package, installed package, and synthetic-merge installed package match
byte-for-byte for all modified source and generated artifacts.
| Artifact | SHA-256 |
| ------------------- | ------------------------------------------------------------------ |
| `lib/xterm.js` | `605f406ca62d58e3bdc4367a53b338a0504ef023d4b67d9ce55a0ebe4ef6e575` |
| `lib/xterm.mjs` | `3073b72926335549c5b8b3549da091035be489c774e08f2b71780b92a00db6dc` |
| `lib/xterm.js.map` | `1e2492bd1fbe8147dd9b6e47c875e4b11478c9f42c5cb24b27b8bb54482d8189` |
| `lib/xterm.mjs.map` | `851ad7e138f5a73a7ae74a404269a9a5cf1fd618c7c13db856f0b345499a09bb` |
Both maps contain the exact rebuilt `CompositionHelper.ts` sources content with SHA-256
`a2d5f3252e73c8da40e2fca91281789f8cc98ae16387d679d21b93218b697b4c`.
All 24 lockfile references use the final raw patch hash. Frozen-offline installation
passed for both the R3 product and the latest-main synthetic merge.
## Repository gates
- Node, CLI, and web typechecks passed.
- Full lint passed, including native and type-aware code-quality, reliability,
max-lines ratchet, localization, and bundled-skill checks.
- Changed-code quality, changed React Doctor, and changed React Doctor lint reported zero
new findings.
- Changed TypeScript formatting and `git diff --check` passed.
- Electron Vite production build passed with existing chunking and CSS pseudo-element
warnings.
- Exact package/source/artifact reconstruction and frozen-offline install passed.
The full-repository formatter check was run and reported 20 unrelated pre-existing files,
including documentation, workflows, reliability config, and renderer files untouched by
R3. No unrelated formatting was rewritten; all R3 TypeScript files pass the formatter.
## Latest main compatibility
- Fetched `origin/main`:
`ef55429f3d2ce3fbbcc542e4dcc8a6b36e464455`
- Conflict-free synthetic merge tree:
`5613493fa46eaedabcc86c7041bc2369a2031d38`
- Materialized merge frozen-offline install: passed.
- Materialized merge IME matrix: 14 files, 233/233 passed.
- Materialized merge Node, CLI, and web typechecks: passed.
- Materialized merge CJS, ESM, and both maps match the R3 hashes above.
## Security, performance, platform, and workspace audit
The dependency graph and versions are unchanged; `pnpm-lock.yaml` changes only the xterm
patch hash references. The production dependency audit reports no known vulnerabilities.
The all-dependency audit was also run and reported five existing development-tool
advisories: one moderate and four high, through `shadcn > postcss` and
`electron-builder > minimatch > brace-expansion`. R3 adds no package and does not alter
those dependency paths.
R3 changes no PTY, SSH, remote-runtime, folder-workspace, git-worktree, filesystem,
provider, Git command, IPC, credential, HTML, or network path. Forwarded bytes still use
xterm's ordered `onData` transport for local, SSH, and remote terminals. Linux and Windows
routing is unchanged; macOS won forwarding retains the existing runtime/modifier gates.
Deferred composition work remains constant-space: position, view, end, and finalizer work
each owns at most one tracked slot, superseded callbacks cannot clear newer slots, and all
slots are removed on callback completion, restart, cancellation, blur, or disposal. The
4,096-transaction verifier controls and tracked 256-transaction burst emit exact ordered
bytes and fully drain tracked state.
## Bounded gaps
- The host is Darwin, so the native Linux IBus harness was unavailable.
- Verifier B independently ran the exact R2 package on isolated Debian/Xvfb/IBus and
passed 60/60 local-PTY byte sequences; R3 changes only end ownership classification and
retains that transport path.
- No GUI automation, window focus, or system input-source change was used.
- No physical macOS, Linux, Windows, Japanese, Chinese, or Korean IME journey was run.
- No live SSH or folder-workspace native-IME journey was run; deterministic transport and
ownership suites cover those invariant paths.
- The repository-wide formatter and dependency audit retain the unrelated baseline
findings described above.
No push, merge, comment, reaction, GUI action, window focus, or input-source mutation was
performed.

View File

@ -0,0 +1,534 @@
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'
// Repro for the Shift/Ctrl+Enter Hangul commit race: macOS delivers a
// committing Enter chord TWICE — first as an IME keydown (keyCode 229, isComposing=true),
// then ~2 ms after compositionend as a re-dispatched plain keydown
// (keyCode 13, isComposing=false). The window-level shortcut handler must send
// exactly one newline, and only after the committed syllable has flushed.
// Deferring only the composing keydown is not enough: the re-dispatch would
// still send its newline immediately (ahead of the glyph) and the deferred
// send would then double it.
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
}
function terminalImeHarnessScript(runId: string): string {
return `
const runId = ${JSON.stringify(runId)}
let model = ''
let received = ''
function handleData(data) {
received += data
for (const ch of data) {
if (ch === '\\u0003') {
process.exit(0)
}
if (ch === '\\r' || ch === '\\n') {
process.stdout.write('\\r\\x1b[2K[SUBMITTED_JSON_' + runId + ']' + JSON.stringify(model) + '\\n')
model = ''
continue
}
if (ch === '\\u007f' || ch === '\\b') {
model = Array.from(model).slice(0, -1).join('')
continue
}
model += ch
}
process.stdout.write('\\r\\x1b[2K[RECEIVED_JSON_' + runId + ']' + JSON.stringify(received) + '\\n')
process.stdout.write('\\r\\x1b[2K${PROMPT}' + model.replace(/\\x1b/g, '<ESC>'))
}
if (process.stdin.isTTY) process.stdin.setRawMode(true)
process.stdin.setEncoding('utf8')
process.stdout.write('IME_HARNESS_READY_' + runId + '\\n')
process.stdout.write('${PROMPT}')
process.stdin.on('data', handleData)
`
}
async function readSubmitted(page: Page): Promise<string[]> {
const content = stripTerminalControls(await getTerminalContent(page, 20_000))
const matches = [...content.matchAll(/\[SUBMITTED_JSON_[^\]]+\]("[\s\S]*?")/g)]
return matches
.map((match) => {
try {
return JSON.parse(match[1] ?? '""') as string
} catch {
return null
}
})
.filter((value): value is string => value !== null)
}
async function readReceived(page: Page): Promise<string | null> {
const content = stripTerminalControls(await getTerminalContent(page, 20_000))
const matches = [...content.matchAll(/\[RECEIVED_JSON_[^\]]+\]("[\s\S]*?")/g)]
const encoded = matches.at(-1)?.[1]
if (!encoded) {
return null
}
try {
return JSON.parse(encoded) as string
} catch {
return null
}
}
type ImeKeyEvent = {
type: string
key: string
code: string
keyCode: number
isComposing: boolean
repeat: boolean
shiftKey: boolean
ctrlKey: boolean
timeStamp: number
}
async function installImeKeyEventLog(page: Page): Promise<void> {
await page.evaluate(() => {
const target = window as unknown as { __imeKeyEvents: ImeKeyEvent[] }
target.__imeKeyEvents = []
const record = (event: KeyboardEvent): void => {
target.__imeKeyEvents.push({
type: event.type,
key: event.key,
code: event.code,
keyCode: event.keyCode,
isComposing: event.isComposing,
repeat: event.repeat,
shiftKey: event.shiftKey,
ctrlKey: event.ctrlKey,
timeStamp: event.timeStamp
})
}
window.addEventListener('keydown', record, true)
window.addEventListener('keyup', record, true)
})
}
async function readImeKeyEventLog(page: Page): Promise<ImeKeyEvent[]> {
return page.evaluate(
() => (window as unknown as { __imeKeyEvents?: ImeKeyEvent[] }).__imeKeyEvents ?? []
)
}
async function attachEvidence(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const evidence = {
keyEvents: await readImeKeyEventLog(page),
received: await readReceived(page),
terminal: await getTerminalContent(page, 20_000),
submitted: await readSubmitted(page)
}
await testInfo.attach(`${name}.json`, {
body: `${JSON.stringify(evidence, null, 2)}\n`,
contentType: 'application/json'
})
}
async function dispatchHangulProcessKey(
session: CDPSession,
key: string,
code: string
): Promise<void> {
// Why: macOS Hangul jamo keydowns arrive as IME Process keys (keyCode 229)
// with the jamo in `key`; the release carries the physical keyCode.
await session.send('Input.dispatchKeyEvent', {
type: 'rawKeyDown',
key,
code,
windowsVirtualKeyCode: 229,
nativeVirtualKeyCode: 229,
text: '',
unmodifiedText: ''
})
await session.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key,
code,
windowsVirtualKeyCode: 229,
nativeVirtualKeyCode: 229,
text: '',
unmodifiedText: ''
})
}
async function composeHangulSyllable(session: CDPSession, page: Page): Promise<void> {
await dispatchHangulProcessKey(session, 'ㅎ', 'KeyG')
await session.send('Input.imeSetComposition', { text: 'ㅎ', selectionStart: 1, selectionEnd: 1 })
await page.waitForTimeout(60)
await dispatchHangulProcessKey(session, 'ㅏ', 'KeyK')
await session.send('Input.imeSetComposition', { text: '하', selectionStart: 1, selectionEnd: 1 })
await page.waitForTimeout(60)
}
async function commitSyllableAndSpace(session: CDPSession, page: Page): Promise<void> {
await session.send('Input.insertText', { text: '하' })
await page.waitForTimeout(60)
await session.send('Input.dispatchKeyEvent', {
type: 'keyDown',
key: ' ',
code: 'Space',
windowsVirtualKeyCode: 32,
nativeVirtualKeyCode: 32,
text: ' ',
unmodifiedText: ' '
})
await session.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key: ' ',
code: 'Space',
windowsVirtualKeyCode: 32,
nativeVirtualKeyCode: 32
})
await page.waitForTimeout(60)
}
/**
* The committing Enter chord as recorded from the real macOS 2-set Korean IME:
* IME keydown (229) -> commit -> re-dispatched plain keydown (13) -> keyup,
* delivered in one un-awaited burst. The real IME delivers all of this within
* the same native key-processing turn, ahead of xterm's setTimeout(0) glyph
* flush; awaiting each CDP round-trip would let the flush win and hide the
* race.
*/
async function dispatchCommittingEnterChord(
session: CDPSession,
page: Page,
modifiers: number,
redispatchedModifiers: number,
redispatchAfterKeyup: boolean,
redispatchTimestampOffset = 0
): Promise<void> {
const timestamp = Date.now() / 1000
const composingKeydown = session.send('Input.dispatchKeyEvent', {
type: 'rawKeyDown',
key: 'Enter',
code: 'Enter',
modifiers,
timestamp,
windowsVirtualKeyCode: 229,
nativeVirtualKeyCode: 229,
text: '',
unmodifiedText: ''
})
const commit = session.send('Input.insertText', { text: '하' })
const redispatch = () =>
session.send('Input.dispatchKeyEvent', {
type: 'rawKeyDown',
key: 'Enter',
code: 'Enter',
modifiers: redispatchedModifiers,
timestamp: timestamp + redispatchTimestampOffset,
windowsVirtualKeyCode: 13,
nativeVirtualKeyCode: 13,
text: '',
unmodifiedText: ''
})
const balancingKeyup = () =>
session.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key: 'Enter',
code: 'Enter',
modifiers: redispatchedModifiers,
timestamp: timestamp + redispatchTimestampOffset,
windowsVirtualKeyCode: 13,
nativeVirtualKeyCode: 13
})
if (!redispatchAfterKeyup) {
await Promise.all([composingKeydown, commit, redispatch(), balancingKeyup()])
return
}
await Promise.all([composingKeydown, commit, balancingKeyup()])
await page.waitForTimeout(80)
await redispatch()
}
type HeldModifier = {
key: 'Shift' | 'Control'
code: 'ShiftLeft' | 'ControlLeft'
keyCode: 16 | 17
modifiers: number
}
async function dispatchHeldModifier(
session: CDPSession,
modifier: HeldModifier,
type: 'rawKeyDown' | 'keyUp'
): Promise<void> {
await session.send('Input.dispatchKeyEvent', {
type,
key: modifier.key,
code: modifier.code,
modifiers: type === 'rawKeyDown' ? modifier.modifiers : 0,
windowsVirtualKeyCode: modifier.keyCode,
nativeVirtualKeyCode: modifier.keyCode
})
}
async function dispatchPlainEnter(session: CDPSession): Promise<void> {
await session.send('Input.dispatchKeyEvent', {
type: 'rawKeyDown',
key: 'Enter',
code: 'Enter',
windowsVirtualKeyCode: 13,
nativeVirtualKeyCode: 13
})
await session.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key: 'Enter',
code: 'Enter',
windowsVirtualKeyCode: 13,
nativeVirtualKeyCode: 13
})
}
async function readPromptLine(page: Page): Promise<string> {
const content = stripTerminalControls(await getTerminalContent(page, 20_000))
const promptIndex = content.lastIndexOf(PROMPT)
if (promptIndex < 0) {
return ''
}
return (content.slice(promptIndex + PROMPT.length).split(/\r?\n/)[0] ?? '').trimEnd()
}
type CommittingEnterChordCase = {
name: string
slug: string
modifiers: number
redispatchedModifiers?: number
redispatchTimestampOffset?: number
preHeldModifier?: HeldModifier
windowsOnly?: boolean
assertOutcome: (page: Page) => Promise<void>
expectedAfterPlainEnter: {
received: string
submitted: string[]
}
}
async function assertShiftOutcome(page: Page): Promise<void> {
await expect
.poll(() => readReceived(page), {
timeout: 10_000,
message: 'PTY bytes must contain committed Hangul before exactly one Shift+Enter chord'
})
.toBe('하 하 하\u001b\r')
await expect
.poll(async () => (await readSubmitted(page)).at(-1) ?? null, {
timeout: 10_000,
message: 'submitted line must contain the full text with the trailing syllable inline'
})
.toBe('하 하 하\u001b')
await page.waitForTimeout(500)
expect(await readSubmitted(page), 'Shift+Enter must produce exactly one newline').toEqual([
'하 하 하\u001b'
])
}
async function assertCtrlOutcome(page: Page): Promise<void> {
await expect
.poll(() => readReceived(page), {
timeout: 10_000,
message: 'PTY bytes must contain committed Hangul before exactly one Ctrl+Enter chord'
})
.toBe('하 하 하\u001b[13;5u')
await expect
.poll(() => readPromptLine(page), {
timeout: 10_000,
message: 'prompt must show the committed syllables followed by exactly one CSI-u chord'
})
.toBe('하 하 하<ESC>[13;5u')
await page.waitForTimeout(500)
expect(await readPromptLine(page), 'Ctrl+Enter must produce exactly one CSI-u chord').toBe(
'하 하 하<ESC>[13;5u'
)
expect(await readSubmitted(page), 'CSI-u must not submit the line').toEqual([])
}
const COMMITTING_ENTER_CHORDS: CommittingEnterChordCase[] = [
{
name: 'Shift+Enter',
slug: 'shift-enter',
modifiers: 8,
assertOutcome: assertShiftOutcome,
expectedAfterPlainEnter: {
received: '하 하 하\u001b\r\r',
submitted: ['하 하 하\u001b', '']
}
},
{
name: 'Ctrl+Enter',
slug: 'ctrl-enter',
modifiers: 2,
assertOutcome: assertCtrlOutcome,
expectedAfterPlainEnter: {
received: '하 하 하\u001b[13;5u\r',
submitted: ['하 하 하\u001b[13;5u']
}
},
{
name: 'Shift+Enter with modifier-lost redispatch',
slug: 'shift-enter-bare-redispatch',
modifiers: 8,
redispatchedModifiers: 0,
assertOutcome: assertShiftOutcome,
expectedAfterPlainEnter: {
received: '하 하 하\u001b\r\r',
submitted: ['하 하 하\u001b', '']
}
},
{
name: 'pre-held Shift+Enter with modifier-lost redispatch',
slug: 'pre-held-shift-enter-bare-redispatch',
modifiers: 8,
redispatchedModifiers: 0,
redispatchTimestampOffset: 0.01,
preHeldModifier: { key: 'Shift', code: 'ShiftLeft', keyCode: 16, modifiers: 8 },
windowsOnly: true,
assertOutcome: assertShiftOutcome,
expectedAfterPlainEnter: {
received: '하 하 하\u001b\r\r',
submitted: ['하 하 하\u001b', '']
}
},
{
name: 'pre-held Ctrl+Enter with modifier-lost redispatch',
slug: 'pre-held-ctrl-enter-bare-redispatch',
modifiers: 2,
redispatchedModifiers: 0,
redispatchTimestampOffset: 0.01,
preHeldModifier: { key: 'Control', code: 'ControlLeft', keyCode: 17, modifiers: 2 },
windowsOnly: true,
assertOutcome: assertCtrlOutcome,
expectedAfterPlainEnter: {
received: '하 하 하\u001b[13;5u\r',
submitted: ['하 하 하\u001b[13;5u']
}
}
]
test.describe('Korean IME terminal committing Enter chords', () => {
test.describe.configure({ mode: 'serial' })
for (const chord of COMMITTING_ENTER_CHORDS) {
for (const redispatchAfterKeyup of [false, true]) {
const order = redispatchAfterKeyup ? 'keyup-before-redispatch' : 'redispatch-before-keyup'
test(`${chord.name} sends once with ${order}`, async ({
orcaPage,
testRepoPath
}, testInfo) => {
test.skip(chord.windowsOnly && process.platform !== 'win32', 'Windows IME ownership')
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-korean-ime-harness-${runId}.cjs`)
const session = await orcaPage.context().newCDPSession(orcaPage)
try {
writeFileSync(scriptPath, terminalImeHarnessScript(runId))
await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
await waitForTerminalOutput(orcaPage, `IME_HARNESS_READY_${runId}`, 10_000, 20_000)
await focusActiveTerminalInput(orcaPage)
await installImeKeyEventLog(orcaPage)
// 하 하 하 with the first two syllables committed by Space and the last
// one left composing, so the Enter chord is the committing keystroke.
await composeHangulSyllable(session, orcaPage)
await commitSyllableAndSpace(session, orcaPage)
await composeHangulSyllable(session, orcaPage)
await commitSyllableAndSpace(session, orcaPage)
if (chord.preHeldModifier) {
await dispatchHeldModifier(session, chord.preHeldModifier, 'rawKeyDown')
}
await composeHangulSyllable(session, orcaPage)
await dispatchCommittingEnterChord(
session,
orcaPage,
chord.modifiers,
chord.redispatchedModifiers ?? chord.modifiers,
redispatchAfterKeyup,
chord.redispatchTimestampOffset
)
if (chord.preHeldModifier) {
await dispatchHeldModifier(session, chord.preHeldModifier, 'keyUp')
}
await chord.assertOutcome(orcaPage)
await dispatchPlainEnter(session)
await expect
.poll(() => readReceived(orcaPage), {
timeout: 10_000,
message: 'the next physical Enter must not be consumed by stale IME state'
})
.toBe(chord.expectedAfterPlainEnter.received)
expect(await readSubmitted(orcaPage)).toEqual(chord.expectedAfterPlainEnter.submitted)
await attachEvidence(orcaPage, testInfo, `korean-${chord.slug}-${order}-commit`)
} finally {
await attachEvidence(orcaPage, testInfo, `korean-${chord.slug}-${order}-final`).catch(
() => undefined
)
await session.detach().catch(() => undefined)
await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined)
rmSync(scriptPath, { force: true })
}
})
}
}
})

View File

@ -0,0 +1,186 @@
import { execFileSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import type { Page, TestInfo } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
focusActiveTerminalInput,
sendToTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager
} from './helpers/terminal'
import {
attachTerminalImeBoundaryEvidence,
disposeTerminalImeBoundaryProbe,
installTerminalImeBoundaryProbe,
readTerminalImeBoundaryTrace
} from './terminal-ime-boundary-probe'
import {
createTerminalImeByteReader,
removeTerminalImeByteReader,
startTerminalImeByteReader,
waitForTerminalImeBytes
} from './terminal-ime-byte-reader'
const DEFAULT_REPETITIONS = 30
const MAX_REPETITIONS = 30
const DEFAULT_KEY_DELAY_MS = 1
const MAX_KEY_DELAY_MS = 100
const NATIVE_COMMAND_TIMEOUT_MS = 10_000
test.use({
orcaAppExtraEnv: {
GTK_IM_MODULE: 'ibus',
IBUS_ENABLE_SYNC_MODE: '1',
QT_IM_MODULE: 'ibus',
XMODIFIERS: '@im=ibus'
}
})
function nativeRepetitions(): number {
const parsed = Number(process.env.ORCA_E2E_NATIVE_IBUS_REPETITIONS ?? DEFAULT_REPETITIONS)
return Number.isInteger(parsed) && parsed > 0
? Math.min(parsed, MAX_REPETITIONS)
: DEFAULT_REPETITIONS
}
function nativeKeyDelayMs(): number {
const parsed = Number(process.env.ORCA_E2E_NATIVE_IBUS_KEY_DELAY_MS ?? DEFAULT_KEY_DELAY_MS)
return Number.isInteger(parsed) && parsed >= 0
? Math.min(parsed, MAX_KEY_DELAY_MS)
: DEFAULT_KEY_DELAY_MS
}
function runXdotool(...args: string[]): void {
execFileSync('xdotool', args, { stdio: 'pipe', timeout: NATIVE_COMMAND_TIMEOUT_MS })
}
async function focusNativeTerminalWindow(page: Page): Promise<string> {
await focusActiveTerminalInput(page)
const title = `ORCA_NATIVE_IBUS_${randomUUID()}`
await page.evaluate((nextTitle) => {
document.title = nextTitle
}, title)
await expect.poll(() => page.title(), { timeout: 5_000 }).toBe(title)
runXdotool('search', '--onlyvisible', '--name', title, 'windowfocus', '--sync')
execFileSync('ibus', ['engine', 'hangul'], {
stdio: 'pipe',
timeout: NATIVE_COMMAND_TIMEOUT_MS
})
const engine = execFileSync('ibus', ['engine'], {
encoding: 'utf8',
timeout: NATIVE_COMMAND_TIMEOUT_MS
}).trim()
expect(engine).toBe('hangul')
return title
}
function typeExactByteSequence(repetitions: number): void {
const delay = String(nativeKeyDelayMs())
for (let index = 0; index < repetitions; index += 1) {
runXdotool('type', '--delay', delay, '--clearmodifiers', 'gks')
runXdotool('key', 'Hangul')
runXdotool('type', '--delay', delay, 'abc')
runXdotool('key', 'Hangul')
runXdotool('type', '--delay', delay, 'rmf')
runXdotool('key', 'Return')
}
}
function typeSentenceSequence(repetitions: number): void {
const delay = String(nativeKeyDelayMs())
for (let index = 0; index < repetitions; index += 1) {
runXdotool(
'type',
'--delay',
delay,
'--clearmodifiers',
'xptmxmfmf gkrh dlTsmsep duwjsgl rmfjsp'
)
runXdotool('key', 'Return')
}
}
async function runNativeIbusScenario(
page: Page,
testInfo: TestInfo,
testRepoPath: string,
expectedText: string,
driveInput: (repetitions: number) => void
): Promise<void> {
await waitForSessionReady(page)
await waitForActiveWorktree(page)
await ensureTerminalVisible(page)
await waitForActiveTerminalManager(page, 30_000)
const repetitions = nativeRepetitions()
const ptyId = await waitForActivePanePtyId(page)
const reader = createTerminalImeByteReader(testRepoPath, repetitions)
let completed = false
let receivedBytes: string[] = []
try {
await startTerminalImeByteReader(page, ptyId, reader)
await focusNativeTerminalWindow(page)
await installTerminalImeBoundaryProbe(page)
driveInput(repetitions)
receivedBytes = await waitForTerminalImeBytes(page, reader, 30_000)
const trace = await readTerminalImeBoundaryTrace(page)
expect(trace.dom.some((event) => event.type === 'compositionstart')).toBe(true)
expect(
trace.dom.some(
(event) =>
(event.type === 'compositionupdate' ||
(event.type === 'input' && event.inputType === 'insertText')) &&
/[\uac00-\ud7af]/.test(event.data ?? '')
)
).toBe(true)
const expectedBytes = Buffer.from(`${expectedText}\n`).toString('hex')
expect(receivedBytes).toEqual(Array.from({ length: repetitions }, () => expectedBytes))
expect(trace.onData.join('')).toBe(`${expectedText}\r`.repeat(repetitions))
completed = true
} finally {
await attachTerminalImeBoundaryEvidence(page, testInfo, 'native-ibus-boundaries', {
display: process.env.DISPLAY,
expectedText,
keyDelayMs: nativeKeyDelayMs(),
receivedBytes,
repetitions
}).catch(() => undefined)
await disposeTerminalImeBoundaryProbe(page).catch(() => undefined)
if (!completed) {
await sendToTerminal(page, ptyId, '\x03').catch(() => undefined)
}
removeTerminalImeByteReader(reader)
}
}
test.describe('Native IBus Hangul terminal input @headful', () => {
test.skip(
process.env.ORCA_E2E_NATIVE_IBUS_HANGUL !== '1',
'Run through config/scripts/run-terminal-ibus-hangul-e2e.mjs'
)
test('forwards the issue exact-byte sequence without loss or duplication', async ({
orcaPage,
testRepoPath
}, testInfo) => {
await runNativeIbusScenario(orcaPage, testInfo, testRepoPath, '한abc글', typeExactByteSequence)
})
test('forwards the issue sentence stress sequence without leaked ASCII', async ({
orcaPage,
testRepoPath
}, testInfo) => {
await runNativeIbusScenario(
orcaPage,
testInfo,
testRepoPath,
'테스트를 하고 있는데 여전히 그러네',
typeSentenceSequence
)
})
})

View File

@ -0,0 +1,130 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { Page, TestInfo } from '@stablyai/playwright-test'
export type TerminalImeDomEvent = {
type: string
data: string | null
inputType: string | null
key: string | null
code: string | null
keyCode: number | null
isComposing: boolean | null
selectionEnd: number | null
selectionStart: number | null
value: string
}
export type TerminalImeBoundaryTrace = {
dom: TerminalImeDomEvent[]
onData: string[]
}
type TerminalImeProbeWindow = Window & {
__terminalImeBoundaryProbe?: TerminalImeBoundaryTrace & { dispose: () => void }
}
export async function installTerminalImeBoundaryProbe(page: Page): Promise<void> {
await page.evaluate(() => {
const targetWindow = window as TerminalImeProbeWindow
targetWindow.__terminalImeBoundaryProbe?.dispose()
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 textarea for IME boundary probe')
}
const dom: TerminalImeDomEvent[] = []
const onData: string[] = []
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
dom.push({
type: event.type,
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,
selectionEnd: textarea.selectionEnd,
selectionStart: textarea.selectionStart,
value: textarea.value
})
}
const eventTypes = [
'compositionstart',
'compositionupdate',
'compositionend',
'beforeinput',
'input',
'keydown',
'keypress',
'keyup'
]
for (const eventType of eventTypes) {
textarea.addEventListener(eventType, record, true)
}
const onDataDisposable = pane.terminal.onData((data) => onData.push(data))
targetWindow.__terminalImeBoundaryProbe = {
dom,
onData,
dispose: () => {
for (const eventType of eventTypes) {
textarea.removeEventListener(eventType, record, true)
}
onDataDisposable.dispose()
}
}
})
}
export async function readTerminalImeBoundaryTrace(page: Page): Promise<TerminalImeBoundaryTrace> {
return page.evaluate(() => {
const probe = (window as TerminalImeProbeWindow).__terminalImeBoundaryProbe
return probe ? { dom: [...probe.dom], onData: [...probe.onData] } : { dom: [], onData: [] }
})
}
export async function disposeTerminalImeBoundaryProbe(page: Page): Promise<void> {
await page.evaluate(() => {
const targetWindow = window as TerminalImeProbeWindow
targetWindow.__terminalImeBoundaryProbe?.dispose()
delete targetWindow.__terminalImeBoundaryProbe
})
}
export async function attachTerminalImeBoundaryEvidence(
page: Page,
testInfo: TestInfo,
name: string,
extra: Record<string, unknown> = {}
): Promise<void> {
const body = `${JSON.stringify(
{ ...extra, trace: await readTerminalImeBoundaryTrace(page) },
null,
2
)}\n`
await testInfo.attach(`${name}.json`, {
body,
contentType: 'application/json'
})
const evidenceDir = path.join(process.cwd(), 'test-results', 'terminal-ime-evidence')
const title = testInfo.title
.replaceAll(/[^a-z0-9]+/gi, '-')
.replaceAll(/^-|-$/g, '')
.toLowerCase()
mkdirSync(evidenceDir, { recursive: true })
writeFileSync(path.join(evidenceDir, `${name}-${title}.json`), body)
}

View File

@ -0,0 +1,87 @@
import { randomUUID } from 'node:crypto'
import { rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { Page } from '@stablyai/playwright-test'
import { expect } from '@stablyai/playwright-test'
import { getTerminalContent, sendToTerminal, waitForTerminalOutput } from './helpers/terminal'
export type TerminalImeByteReader = {
expectedLineCount: number
readyMarker: string
resultPrefix: string
scriptPath: string
}
export function createTerminalImeByteReader(
testRepoPath: string,
expectedLineCount: number
): TerminalImeByteReader {
const runId = randomUUID().replaceAll('-', '')
const readyMarker = `ORCA_IME_READER_READY_${runId}`
const resultPrefix = `ORCA_IME_BYTES_${runId}`
const scriptPath = path.join(testRepoPath, `.orca-ime-byte-reader-${runId}.cjs`)
const source = `
const expectedLineCount = ${expectedLineCount}
const readyMarker = ${JSON.stringify(readyMarker)}
const resultPrefix = ${JSON.stringify(resultPrefix)}
let pending = Buffer.alloc(0)
let receivedLineCount = 0
process.stdout.write(readyMarker + '\\n')
process.stdin.on('data', (chunk) => {
pending = Buffer.concat([pending, Buffer.from(chunk)])
let newlineIndex = pending.indexOf(0x0a)
while (newlineIndex >= 0) {
const line = pending.subarray(0, newlineIndex + 1)
pending = pending.subarray(newlineIndex + 1)
receivedLineCount += 1
process.stdout.write(resultPrefix + ':' + receivedLineCount + ':' + line.toString('hex') + '\\n')
if (receivedLineCount === expectedLineCount) {
process.exit(0)
}
newlineIndex = pending.indexOf(0x0a)
}
})
`
writeFileSync(scriptPath, source)
return { expectedLineCount, readyMarker, resultPrefix, scriptPath }
}
export async function startTerminalImeByteReader(
page: Page,
ptyId: string,
reader: TerminalImeByteReader
): Promise<void> {
await sendToTerminal(page, ptyId, `node ${JSON.stringify(reader.scriptPath)}\r`)
await waitForTerminalOutput(page, reader.readyMarker, 10_000, 20_000)
}
export async function waitForTerminalImeBytes(
page: Page,
reader: TerminalImeByteReader,
timeoutMs = 15_000
): Promise<string[]> {
let results: string[] = []
await expect
.poll(
async () => {
const terminal = await getTerminalContent(page, 100_000)
const resultPattern = new RegExp(`${reader.resultPrefix}:(\\d+):([0-9a-f]+)`, 'g')
const bySequence = new Map<number, string>()
for (const match of terminal.matchAll(resultPattern)) {
bySequence.set(Number(match[1]), match[2])
}
results = [...bySequence.entries()]
.sort(([left], [right]) => left - right)
.map(([, hex]) => hex)
return results.length
},
{ timeout: timeoutMs, message: 'IME byte reader did not receive every expected line' }
)
.toBe(reader.expectedLineCount)
return results
}
export function removeTerminalImeByteReader(reader: TerminalImeByteReader): void {
rmSync(reader.scriptPath, { force: true })
}

View File

@ -0,0 +1,162 @@
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,
sendToTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager
} from './helpers/terminal'
import {
attachTerminalImeBoundaryEvidence,
disposeTerminalImeBoundaryProbe,
installTerminalImeBoundaryProbe,
readTerminalImeBoundaryTrace,
type TerminalImeBoundaryTrace
} from './terminal-ime-boundary-probe'
import {
createTerminalImeByteReader,
removeTerminalImeByteReader,
startTerminalImeByteReader,
waitForTerminalImeBytes
} from './terminal-ime-byte-reader'
import {
dispatchObservedIbusHangulMixedSequence,
dispatchObservedIbusHangulRetainedCommitSequence
} from './terminal-ime-observed-event-sequences'
test.describe.configure({ mode: 'serial' })
type TraceAssertion = (trace: TerminalImeBoundaryTrace) => void
async function runExactByteScenario(
page: Page,
testInfo: TestInfo,
testRepoPath: string,
expectedText: string,
dispatchInput: (page: Page) => Promise<void>,
assertTrace: TraceAssertion
): Promise<void> {
await waitForSessionReady(page)
await waitForActiveWorktree(page)
await ensureTerminalVisible(page)
await waitForActiveTerminalManager(page, 30_000)
const ptyId = await waitForActivePanePtyId(page)
const reader = createTerminalImeByteReader(testRepoPath, 1)
let completed = false
let receivedBytes: string[] = []
try {
await startTerminalImeByteReader(page, ptyId, reader)
await focusActiveTerminalInput(page)
await installTerminalImeBoundaryProbe(page)
await dispatchInput(page)
receivedBytes = await waitForTerminalImeBytes(page, reader)
const expectedBytes = Buffer.from(`${expectedText}\n`).toString('hex')
expect(receivedBytes).toEqual([expectedBytes])
const trace = await readTerminalImeBoundaryTrace(page)
expect(trace.onData.join('')).toBe(`${expectedText}\r`)
assertTrace(trace)
completed = true
} finally {
await attachTerminalImeBoundaryEvidence(page, testInfo, 'terminal-ime-boundaries', {
expectedText,
receivedBytes
}).catch(() => undefined)
await disposeTerminalImeBoundaryProbe(page).catch(() => undefined)
if (!completed) {
await sendToTerminal(page, ptyId, '\x03').catch(() => undefined)
}
removeTerminalImeByteReader(reader)
}
}
async function dispatchRepeatedConversion(
page: Page,
frames: string[],
committedText: string
): Promise<void> {
const session: CDPSession = await page.context().newCDPSession(page)
try {
for (let repetition = 0; repetition < 2; repetition += 1) {
for (const frame of frames) {
await session.send('Input.imeSetComposition', {
text: frame,
selectionStart: frame.length,
selectionEnd: frame.length
})
}
await session.send('Input.insertText', { text: committedText })
}
await page.keyboard.press('Enter')
} finally {
await session.detach()
}
}
test.describe('Terminal IME exact-byte forwarding', () => {
test.skip(process.platform !== 'linux', 'Linux composition order is covered by this suite')
test('replays the observed IBus Hangul mixed-input order through xterm and the PTY', async ({
orcaPage,
testRepoPath
}, testInfo) => {
await runExactByteScenario(
orcaPage,
testInfo,
testRepoPath,
'한abc글',
dispatchObservedIbusHangulMixedSequence,
(trace) => {
const commits = trace.dom
.filter((event) => event.type === 'input' && event.inputType === 'insertText')
.map((event) => event.data)
expect(commits).toEqual(expect.arrayContaining(['한', '글']))
}
)
})
test('keeps retained Hangul commits and stale fallbacks in their transactions', async ({
orcaPage,
testRepoPath
}, testInfo) => {
await runExactByteScenario(
orcaPage,
testInfo,
testRepoPath,
'테a스',
dispatchObservedIbusHangulRetainedCommitSequence,
(trace) => {
const starts = trace.dom.filter((event) => event.type === 'compositionstart')
expect(starts).toHaveLength(2)
expect(trace.onData.join('')).not.toContain('\x7f')
}
)
})
for (const scenario of [
{ name: 'Japanese', frames: ['に', 'にほんご', '日本語'], committedText: '日本語' },
{ name: 'Chinese', frames: ['n', 'ni', '你好'], committedText: '你好' }
]) {
test(`does not suppress repeated legitimate ${scenario.name} conversions`, async ({
orcaPage,
testRepoPath
}, testInfo) => {
await runExactByteScenario(
orcaPage,
testInfo,
testRepoPath,
scenario.committedText.repeat(2),
(page) => dispatchRepeatedConversion(page, scenario.frames, scenario.committedText),
(trace) => {
const commits = trace.dom.filter(
(event) => event.type === 'compositionend' && event.data === scenario.committedText
)
expect(commits).toHaveLength(2)
}
)
})
}
})

View File

@ -0,0 +1,89 @@
import type { Page } from '@stablyai/playwright-test'
async function dispatchObservedIbusHangulSequence(
page: Page,
variant: 'mixed' | 'retained'
): Promise<void> {
await page.evaluate((selectedVariant) => {
const textarea = document.activeElement
if (!(textarea instanceof HTMLTextAreaElement)) {
throw new Error('xterm helper textarea is not focused')
}
const composition = (type: string, data = ''): void => {
textarea.dispatchEvent(new CompositionEvent(type, { bubbles: true, data }))
}
const input = (type: 'beforeinput' | 'input', inputType: string, data?: string): void => {
textarea.dispatchEvent(
new InputEvent(type, {
bubbles: true,
cancelable: type === 'beforeinput',
composed: true,
data: data ?? null,
inputType
})
)
}
const replaceAndInput = (value: string, inputType: string, data?: string): void => {
input('beforeinput', inputType, data)
textarea.value = value
input('input', inputType, data)
}
const keydown = (key: string, code: string, keyCode: number, isComposing = false): void => {
const event = new KeyboardEvent('keydown', { bubbles: true, code, isComposing, key })
Object.defineProperty(event, 'keyCode', { value: keyCode })
textarea.dispatchEvent(event)
}
const update = (prefix: string, text: string): void => {
composition('compositionupdate', text)
replaceAndInput(`${prefix}${text}`, 'insertCompositionText', text)
}
const begin = (text: string): string => {
const prefix = textarea.value
textarea.setSelectionRange(prefix.length, prefix.length)
composition('compositionstart')
keydown('Process', 'KeyG', 229, true)
update(prefix, text)
return prefix
}
const end = (prefix: string): void => {
composition('compositionupdate')
replaceAndInput(prefix, 'deleteContentBackward')
composition('compositionend')
}
const commit = (prefix: string, text: string): void => {
end(prefix)
replaceAndInput(`${prefix}${text}`, 'insertText', text)
}
if (selectedVariant === 'mixed') {
let prefix = begin('한')
commit(prefix, '한')
for (const character of 'abc') {
keydown(character, `Key${character.toUpperCase()}`, character.charCodeAt(0))
replaceAndInput(`${textarea.value}${character}`, 'insertText', character)
}
prefix = begin('글')
commit(prefix, '글')
keydown('Enter', 'Enter', 13)
return
}
const prefix = begin('테')
composition('compositionend', '테')
keydown('a', 'KeyA', 65)
keydown('Process', 'KeyR', 229, true)
textarea.setSelectionRange(prefix.length + 1, prefix.length + 1)
composition('compositionstart')
update(`${prefix}`, '스')
composition('compositionend', '스')
keydown('Enter', 'Enter', 13)
}, variant)
}
export async function dispatchObservedIbusHangulMixedSequence(page: Page): Promise<void> {
await dispatchObservedIbusHangulSequence(page, 'mixed')
}
export async function dispatchObservedIbusHangulRetainedCommitSequence(page: Page): Promise<void> {
await dispatchObservedIbusHangulSequence(page, 'retained')
}