diff --git a/CLAUDE.md b/CLAUDE.md
index 6ba242f7..4f9e8362 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -169,7 +169,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`) | `templates/` holds the CLAUDE.md scaffold generated into new cases |
| **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (25 modules + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | |
-| **Frontend** | `src/web/public/app.js` (~6.7K lines, core) + 31 modules + `sw.js` | See Frontend section for the load order, which is authoritative |
+| **Frontend** | `src/web/public/app.js` (~6.7K lines, core) + 32 modules + `sw.js` | See Frontend section for the load order, which is authoritative |
| **Types** | `src/types/index.ts` (barrel) → 22 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
@@ -286,7 +286,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
### Frontend
-Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
+Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke.
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on ``. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`.
diff --git a/config/test-suites.ts b/config/test-suites.ts
index cef400cc..02cc154b 100644
--- a/config/test-suites.ts
+++ b/config/test-suites.ts
@@ -26,6 +26,7 @@ export const BROWSER_TEST_GLOBS = [
'test/opencode-resize.test.ts',
'test/webgl-fallback.test.ts',
'test/terminal-copy-shortcut.test.ts',
+ 'test/terminal-keycode229-recovery.browser.test.ts',
'test/codex-predictive-echo.test.ts', // also needs a real codex binary
];
diff --git a/scripts/build.mjs b/scripts/build.mjs
index d7236f5f..1fbb34d8 100644
--- a/scripts/build.mjs
+++ b/scripts/build.mjs
@@ -83,6 +83,7 @@ appendFileSync(
// 4. Minify frontend assets
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
+run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite');
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
@@ -110,6 +111,7 @@ console.log('\n[build] content-hash cache busting');
'notification-manager.js',
'keyboard-accessory.js',
'input-cjk.js',
+ 'terminal-keycode229-recovery.js',
'sanitize-html.js',
'app.js',
'tab-rail-resize.js',
diff --git a/src/web/public/index.html b/src/web/public/index.html
index 3de02b40..0ed86223 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -3458,6 +3458,8 @@
🧠 Read My Mind
+
+
diff --git a/src/web/public/terminal-keycode229-recovery.js b/src/web/public/terminal-keycode229-recovery.js
new file mode 100644
index 00000000..4acf32a4
--- /dev/null
+++ b/src/web/public/terminal-keycode229-recovery.js
@@ -0,0 +1,187 @@
+/**
+ * @fileoverview Orphaned-input forwarder for xterm's helper textarea.
+ *
+ * xterm's `CoreBrowserTerminal._inputEvent` only forwards an `insertText`
+ * input event while `(!ev.composed || !this._keyDownSeen)` holds. A soft
+ * keyboard that delivers a `composed: true` input event after a keydown fails
+ * that guard, so xterm returns without emitting and the committed character is
+ * silently dropped.
+ *
+ * ⚠ The gap is NARROWER than "keyCode 229", and assuming otherwise produces a
+ * controller that looks useful while doing nothing. For a keydown that really
+ * does report `keyCode: 229`, xterm ALREADY self-rescues: `CompositionHelper
+ * .keydown()` calls `_handleAnyTextareaChanges()`, which snapshots
+ * `textarea.value` and diffs it on a 0 ms timer, emitting the difference
+ * itself. Measured in headless chromium against a real terminal: for a 229
+ * keydown xterm emits and this controller correctly stands down. What is left
+ * unrescued is a refused `insertText` where NO 229 diff was scheduled — that is
+ * the case this module exists for, and the case its browser test asserts by
+ * checking WHO delivered the byte rather than merely that one arrived.
+ *
+ * The recovery never guesses the character: the `input` event already carries
+ * the real committed text in `ev.data`, which is exactly what xterm itself
+ * would have forwarded. We only decide WHETHER to forward it, by asking
+ * whether xterm produced any canonical data since the keydown that started the
+ * keystroke. That snapshot must be taken at KEYDOWN, not at the input event:
+ * xterm's `_keyPress` emits and sets `_keyPressHandled` before `input` fires,
+ * so a snapshot read at input time would already contain that emission and the
+ * character would be delivered twice.
+ *
+ * Listener registration is load-bearing, in BOTH phase and order. xterm
+ * registers its own `input` listener in `terminal.open()` with `capture:
+ * true`, and ours is added afterwards, so at-target it runs second. It must
+ * also be a CAPTURE listener; see the measured table at the addEventListener
+ * call below.
+ *
+ * @dependency none (standalone IIFE; consumed by terminal-ui.js)
+ * @loadorder 5.55 (before app.js/terminal-ui.js, which create the controller)
+ */
+(function (global) {
+ 'use strict';
+
+ function create(options) {
+ const textarea = options?.textarea;
+ const emitRecovered = options?.emitRecovered;
+ if (!textarea?.addEventListener || !textarea?.removeEventListener || typeof emitRecovered !== 'function') {
+ return null;
+ }
+
+ const isScreenReaderMode = options.isScreenReaderMode;
+ const setTimer = options.setTimer || global.setTimeout.bind(global);
+ const clearTimer = options.clearTimer || global.clearTimeout.bind(global);
+
+ let destroyed = false;
+ // Number of canonical data events xterm has emitted, bumped by the caller's
+ // onData hook. Only its ORDER relative to a keydown matters.
+ let canonicalCount = 0;
+ let keydownSnapshot = null;
+ let composing = false;
+ const pending = [];
+
+ function cancelPending() {
+ for (const candidate of pending.splice(0)) {
+ candidate.active = false;
+ if (candidate.timer !== null) {
+ try {
+ clearTimer(candidate.timer);
+ } catch {
+ // A broken timer host must not break input handling.
+ }
+ candidate.timer = null;
+ }
+ }
+ }
+
+ function resolveCandidate(candidate) {
+ const index = pending.indexOf(candidate);
+ if (index !== -1) pending.splice(index, 1);
+ candidate.timer = null;
+ if (!candidate.active || destroyed) return;
+ candidate.active = false;
+ // xterm (or its keypress path) spoke for this keystroke — it is already
+ // on its way to the PTY, so there is nothing to recover.
+ if (canonicalCount > candidate.snapshot) return;
+ try {
+ emitRecovered(candidate.data);
+ } catch {
+ // Recovery is best effort; a failed delivery must never throw into the
+ // browser's input handling.
+ }
+ }
+
+ /** Called from xterm's onData hook: xterm produced canonical data. */
+ function notifyCanonicalData() {
+ canonicalCount += 1;
+ }
+
+ /**
+ * Snapshot the canonical counter at every keydown. This deliberately reads
+ * NOTHING else off the event — not `key`, not `keyCode`. Gating it on
+ * keyCode 229 would make the recovery inert on exactly the devices it
+ * exists for, whose keydowns report `key: 'Unidentified'`. It is a single
+ * assignment, so running it for every keydown costs nothing.
+ */
+ function handleKeyEvent(event) {
+ if (destroyed || event?.type !== 'keydown') return;
+ keydownSnapshot = canonicalCount;
+ }
+
+ function onInput(event) {
+ if (destroyed || composing || event?.isComposing) return;
+ if (event.inputType !== 'insertText') return;
+ const data = event.data;
+ if (typeof data !== 'string' || data === '') return;
+ try {
+ if (isScreenReaderMode?.()) return;
+ } catch {
+ return;
+ }
+
+ const candidate = {
+ data,
+ snapshot: keydownSnapshot ?? canonicalCount,
+ active: true,
+ timer: null,
+ };
+ pending.push(candidate);
+ try {
+ candidate.timer = setTimer(() => resolveCandidate(candidate), 0);
+ } catch {
+ cancelPending();
+ }
+ }
+
+ function onCompositionStart() {
+ if (destroyed) return;
+ composing = true;
+ cancelPending();
+ }
+
+ function onCompositionEnd() {
+ if (destroyed) return;
+ composing = false;
+ }
+
+ function destroy() {
+ if (destroyed) return;
+ destroyed = true;
+ cancelPending();
+ try {
+ textarea.removeEventListener('input', onInput, true);
+ textarea.removeEventListener('compositionstart', onCompositionStart, true);
+ textarea.removeEventListener('compositionend', onCompositionEnd, true);
+ } catch {
+ // Teardown is best effort; the terminal is being replaced anyway.
+ }
+ }
+
+ // capture: true, not bubble. The target (the textarea) is visited TWICE in
+ // the event path, so a capture-phase listener on it calling
+ // stopPropagation() still stops later BUBBLE-phase listeners on that same
+ // target. xterm's `_inputEvent` calls `this.cancel(ev)` (preventDefault +
+ // stopPropagation) exactly in the branch where it HANDLED the input, so on
+ // bubble we would never see handled events — and whether we saw them at
+ // all would hang off xterm's `options.cancelEvents`, which Codeman does not
+ // set. Measured (jsdom and headless chromium agree):
+ //
+ // capture-then-BUBBLE, no stop: xterm -> ours
+ // capture-then-BUBBLE, stopPropagation: xterm (ours never fires)
+ // capture-then-CAPTURE, no stop: xterm -> ours
+ // capture-then-CAPTURE, stopPropagation: xterm -> ours (still fires)
+ //
+ // On capture we therefore observe EVERY input event uniformly, and the
+ // canonicalCount snapshot alone decides whether to forward.
+ try {
+ textarea.addEventListener('input', onInput, true);
+ textarea.addEventListener('compositionstart', onCompositionStart, true);
+ textarea.addEventListener('compositionend', onCompositionEnd, true);
+ } catch {
+ destroy();
+ return null;
+ }
+
+ return Object.freeze({ handleKeyEvent, notifyCanonicalData, destroy });
+ }
+
+ global.CodemanKeyCode229Recovery = Object.freeze({ create });
+})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js
index bb83cc5a..27ebcf80 100644
--- a/src/web/public/terminal-ui.js
+++ b/src/web/public/terminal-ui.js
@@ -232,12 +232,22 @@ Object.assign(CodemanApp.prototype, {
// Terminal Setup — xterm.js config and input handling
// ═══════════════════════════════════════════════════════════════
+ _destroyKeyCode229Recovery() {
+ try {
+ this._keyCode229Recovery?.destroy?.();
+ } catch {
+ // Recovery is optional; terminal replacement must continue.
+ }
+ this._keyCode229Recovery = null;
+ },
+
initTerminal() {
// Load scrollback setting from localStorage, treating DEFAULT_SCROLLBACK as a floor
// so users who picked up the previous (smaller) default get the new minimum on upgrade.
const stored = parseInt(localStorage.getItem('codeman-scrollback'));
const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK;
+ this._destroyKeyCode229Recovery();
this.terminal = new Terminal({
theme: { ...window.codemanCurrentXtermTheme() },
fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily),
@@ -292,6 +302,16 @@ Object.assign(CodemanApp.prototype, {
// punctuation; returning false here would stop xterm before it can diff
// the helper textarea and emit the committed Unicode text.
this.terminal.attachCustomKeyEventHandler((ev) => {
+ try {
+ // Deliberately runs for EVERY keydown, not just keyCode 229: the
+ // controller snapshots a counter and reads nothing off the event, and
+ // the devices this exists for report `key: 'Unidentified'` with no
+ // reliable identity to gate on. Gating it would make recovery inert
+ // exactly where it is needed. Cost is one assignment.
+ this._keyCode229Recovery?.handleKeyEvent?.(ev);
+ } catch {
+ // The fallback must never interfere with xterm's canonical handler.
+ }
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
// Let the app's Alt/Option session-nav and Command Palette shortcuts reach the document keydown handler
@@ -1026,7 +1046,7 @@ Object.assign(CodemanApp.prototype, {
// mobile connections. The overlay + localStorage persistence ensure input
// survives tab switches and reconnects.
- this.terminal.onData((data) => {
+ const handleTerminalData = (data) => {
// Mouse SGR reports (tap-to-position) are NOT IME input — they must reach
// the PTY even while the CJK input field owns focus. Without this exception
// tapping to move the cursor silently does nothing whenever Chinese input
@@ -1348,6 +1368,36 @@ Object.assign(CodemanApp.prototype, {
}
}
}
+ };
+
+ // Chrome on Android delivers a `composed: true` input event preceded by a
+ // keydown, which is exactly the shape xterm's _inputEvent refuses to
+ // forward, so the committed character is silently dropped. The controller
+ // forwards the input event's own `data` when xterm produced nothing for
+ // that keystroke. Created AFTER terminal.open() on purpose: for an event
+ // targeting the textarea, at-target listeners run in registration order,
+ // so xterm's listener (added in open()) still runs first. The controller
+ // registers its own listener with `capture: true`; on bubble xterm's
+ // `cancel()` (stopPropagation) would swallow exactly the handled events —
+ // see the measured table in terminal-keycode229-recovery.js.
+ try {
+ this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({
+ textarea: this.terminal.textarea,
+ emitRecovered: (data) => handleTerminalData(data),
+ isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true,
+ });
+ } catch {
+ this._keyCode229Recovery = null;
+ }
+ this.terminal.onData((data) => {
+ // Canonical xterm data. Telling the controller is what lets it know a
+ // keystroke was already delivered and needs no recovery.
+ try {
+ this._keyCode229Recovery?.notifyCanonicalData?.();
+ } catch {
+ // Bookkeeping must never block real input.
+ }
+ handleTerminalData(data);
});
},
diff --git a/test/terminal-keycode229-recovery.browser.test.ts b/test/terminal-keycode229-recovery.browser.test.ts
new file mode 100644
index 00000000..8f980fc0
--- /dev/null
+++ b/test/terminal-keycode229-recovery.browser.test.ts
@@ -0,0 +1,153 @@
+/**
+ * Wiring for the orphaned-input recovery controller, in a real browser.
+ *
+ * The controller's decision logic is unit-tested in
+ * test/terminal-keycode229-recovery.test.ts. What can only be proven with a
+ * real xterm instance is the wiring:
+ *
+ * - our `input` listener is registered AFTER xterm's, so xterm's `cancel()`
+ * (stopPropagation, not stopImmediatePropagation) does not silence it;
+ * - a `composed: true` insertText preceded by a keydown — the shape Chrome on
+ * Android delivers — is dropped by xterm and recovered by us, exactly once;
+ * - a keystroke xterm DOES handle is delivered exactly once, not twice.
+ *
+ * Browser-driven, so it is excluded from `npm run test:ci` like the other
+ * Playwright suites. Run locally:
+ * npm run test:browser -- test/terminal-keycode229-recovery.browser.test.ts
+ *
+ * Port: 3186 (per CLAUDE.md, ports 3150+ for tests)
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { chromium, type Browser, type Page } from 'playwright';
+import { WebServer } from '../src/web/server.js';
+
+const PORT = 3186;
+const BASE_URL = `http://localhost:${PORT}`;
+
+describe('orphaned terminal input recovery wiring', () => {
+ let server: WebServer;
+ let browser: Browser;
+ let page: Page;
+
+ beforeAll(async () => {
+ server = new WebServer(PORT, false, true);
+ await server.start();
+ browser = await chromium.launch({ headless: true });
+ page = await browser.newPage();
+ await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
+ await page.waitForFunction(() => (window as any).app?._keyCode229Recovery, null, { timeout: 30000 });
+ }, 90000);
+
+ afterAll(async () => {
+ if (browser) await browser.close();
+ if (server) await server.stop();
+ }, 60000);
+
+ /**
+ * Drive one keystroke through the real textarea and report what reached the
+ * PTY send path. `dispatchInput` mirrors GBoard: a keydown with no usable key
+ * identity, then a `composed: true` insertText that xterm refuses to forward.
+ */
+ async function keystroke(options: { data: string; dispatchInput: boolean; keyCode: number }) {
+ return page.evaluate(async ({ data, dispatchInput, keyCode }) => {
+ const app = (window as any).app;
+ const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement;
+ const originalSessionId = app.activeSessionId;
+ const originalLocalEcho = app._localEchoEnabled;
+ const originalSendInput = app._sendInputAsync;
+ const originalPendingInput = app._pendingInput;
+ const originalLastKeystrokeTime = app._lastKeystrokeTime;
+ const sent: string[] = [];
+ let xtermEmitted = 0;
+ const rec = app._keyCode229Recovery;
+
+ try {
+ app.activeSessionId = 'cod388-browser-regression';
+ app._localEchoEnabled = false;
+ app._pendingInput = '';
+ app._lastKeystrokeTime = 0;
+ app._sendInputAsync = (_sessionId: string, chunk: string) => sent.push(chunk);
+ // The controller object is Object.freeze()d, so count xterm's own
+ // canonical emissions by swapping the (writable) property on app.
+ app._keyCode229Recovery = {
+ handleKeyEvent: (e: any) => rec.handleKeyEvent(e),
+ notifyCanonicalData: () => {
+ xtermEmitted += 1;
+ return rec.notifyCanonicalData();
+ },
+ destroy: () => rec.destroy(),
+ };
+ textarea.focus();
+
+ const down = new KeyboardEvent('keydown', {
+ key: 'Unidentified',
+ bubbles: true,
+ cancelable: true,
+ composed: true,
+ });
+ Object.defineProperties(down, { keyCode: { value: keyCode }, which: { value: keyCode } });
+ textarea.dispatchEvent(down);
+
+ if (dispatchInput) {
+ textarea.value = data;
+ textarea.dispatchEvent(
+ new InputEvent('input', { data, inputType: 'insertText', bubbles: true, composed: true })
+ );
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 60));
+ return { sent, xtermEmitted };
+ } finally {
+ app.activeSessionId = originalSessionId;
+ app._localEchoEnabled = originalLocalEcho;
+ app._sendInputAsync = originalSendInput;
+ app._pendingInput = originalPendingInput;
+ app._lastKeystrokeTime = originalLastKeystrokeTime;
+ app._keyCode229Recovery = rec;
+ textarea.value = '';
+ }
+ }, options);
+ }
+
+ /**
+ * ⚠ The gap this controller actually fills is NARROWER than "keyCode 229",
+ * and that matters for what these tests can prove.
+ *
+ * xterm already self-recovers keyCode 229: `CompositionHelper.keydown()`
+ * calls `_handleAnyTextareaChanges()`, which snapshots `textarea.value` and
+ * diffs it on a 0 ms timer, emitting the difference itself. So for a 229
+ * keydown there is nothing orphaned to recover, and a test asserting "we
+ * recovered it" would pass while xterm did all the work — measured: xterm
+ * emits, our controller correctly stands down.
+ *
+ * The real gap is an `insertText` input event that xterm's `_inputEvent`
+ * refuses (`composed: true` with a keydown seen) where NO 229 diff was
+ * scheduled to rescue it. These tests therefore assert WHO delivered the
+ * byte, via `xtermEmitted`, not merely that a byte arrived.
+ */
+ it('recovers a composed insertText that xterm dropped and did not self-rescue', async () => {
+ const { sent, xtermEmitted } = await keystroke({ data: 'x', dispatchInput: true, keyCode: 65 });
+ expect(xtermEmitted).toBe(0); // xterm delivered nothing: genuinely orphaned
+ expect(sent.join('')).toBe('x'); // ...so this byte is ours
+ });
+
+ it('does not duplicate a keystroke xterm self-rescued via its own 0 ms diff', async () => {
+ const { sent, xtermEmitted } = await keystroke({ data: 'y', dispatchInput: true, keyCode: 229 });
+ expect(xtermEmitted).toBe(1); // xterm's 229 textarea diff spoke
+ expect(sent.join('')).toBe('y'); // exactly once — we must not add a second copy
+ });
+
+ it('recovers the same character twice when both keystrokes are orphaned', async () => {
+ const first = await keystroke({ data: 'z', dispatchInput: true, keyCode: 65 });
+ const second = await keystroke({ data: 'z', dispatchInput: true, keyCode: 65 });
+ expect(first.sent.join('')).toBe('z');
+ expect(second.sent.join('')).toBe('z');
+ });
+
+ it('sends nothing for a keydown that produces no input event', async () => {
+ const { sent } = await keystroke({ data: 'q', dispatchInput: false, keyCode: 65 });
+ expect(sent).toEqual([]);
+ });
+});
diff --git a/test/terminal-keycode229-recovery.test.ts b/test/terminal-keycode229-recovery.test.ts
new file mode 100644
index 00000000..25cf87dd
--- /dev/null
+++ b/test/terminal-keycode229-recovery.test.ts
@@ -0,0 +1,278 @@
+/**
+ * Orphaned-input recovery for xterm's helper textarea (PR #388 / COD-27).
+ *
+ * xterm's CoreBrowserTerminal._inputEvent only forwards an `insertText` input
+ * event when `(!ev.composed || !this._keyDownSeen)`. Chrome-on-Android's soft
+ * keyboard produces `composed: true` input events preceded by a keydown, so
+ * that guard is false and the committed character is silently dropped.
+ *
+ * The controller under test forwards the event's own `data` when — and only
+ * when — xterm produced no canonical data for that keystroke. These tests
+ * drive it with synthetic events and an injected timer; no browser is needed.
+ */
+import { readFileSync } from 'node:fs';
+import vm from 'node:vm';
+import { describe, expect, it } from 'vitest';
+
+type Listener = (event: Record) => void;
+
+function makeTextarea() {
+ const listeners = new Map>();
+ const registrations: Array<{ type: string; capture: unknown }> = [];
+ return {
+ addEventListener(type: string, listener: Listener, capture?: unknown) {
+ const bucket = listeners.get(type) ?? new Set();
+ bucket.add(listener);
+ listeners.set(type, bucket);
+ registrations.push({ type, capture });
+ },
+ removeEventListener(type: string, listener: Listener) {
+ listeners.get(type)?.delete(listener);
+ },
+ fire(type: string, event: Record = {}) {
+ for (const listener of [...(listeners.get(type) ?? [])]) listener({ type, ...event });
+ },
+ listenerCount() {
+ return [...listeners.values()].reduce((total, bucket) => total + bucket.size, 0);
+ },
+ registrations() {
+ return [...registrations];
+ },
+ };
+}
+
+/** A committed-text `input` event of the shape Chrome-on-Android delivers. */
+function inputEvent(data: string, overrides: Record = {}) {
+ return { data, inputType: 'insertText', isComposing: false, ...overrides };
+}
+
+function harness({ screenReader = false } = {}) {
+ const source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8');
+ const exposed: Record = {};
+ vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' });
+
+ const textarea = makeTextarea();
+ const emitted: string[] = [];
+ const timers = new Map void>();
+ let timerId = 0;
+
+ const controller = exposed.CodemanKeyCode229Recovery.create({
+ textarea,
+ emitRecovered: (data: string) => emitted.push(data),
+ isScreenReaderMode: () => screenReader,
+ setTimer: (callback: () => void) => {
+ const id = ++timerId;
+ timers.set(id, callback);
+ return id;
+ },
+ clearTimer: (id: number) => timers.delete(id),
+ });
+
+ return {
+ controller,
+ emitted,
+ textarea,
+ /** A keydown that carries NO usable key identity, exactly like GBoard's. */
+ keydown(overrides: Record = {}) {
+ controller.handleKeyEvent({ type: 'keydown', key: 'Unidentified', keyCode: 229, ...overrides });
+ },
+ input(data: string, overrides: Record = {}) {
+ textarea.fire('input', inputEvent(data, overrides));
+ },
+ flushTimers() {
+ for (const [id, callback] of [...timers]) {
+ timers.delete(id);
+ callback();
+ }
+ },
+ pendingTimers: () => timers.size,
+ };
+}
+
+describe('orphaned terminal input recovery', () => {
+ it('forwards the committed text when xterm stayed silent', () => {
+ const h = harness();
+ h.keydown();
+ h.input('x');
+ expect(h.emitted).toEqual([]);
+ h.flushTimers();
+ expect(h.emitted).toEqual(['x']);
+ });
+
+ it('forwards nothing when xterm emitted canonical data after the keydown', () => {
+ // The "xterm handled it" case is decided by the COUNTER, never by assuming
+ // the input event does not reach us. On capture it always does (xterm's
+ // cancel() only stops later BUBBLE listeners), so this test dispatches the
+ // real input event AND has xterm emit canonical data for that keystroke.
+ const h = harness();
+ h.keydown();
+ h.input('x');
+ h.controller.notifyCanonicalData();
+ h.flushTimers();
+ expect(h.emitted).toEqual([]);
+ });
+
+ it('registers the input listener in the CAPTURE phase', () => {
+ // Measured in jsdom and headless chromium: a capture-phase listener on the
+ // TARGET calling stopPropagation() (which is what xterm's cancel() does in
+ // the branch where it handled the input) stops later BUBBLE listeners on
+ // that same target, because the target is visited twice in the event path.
+ //
+ // capture-then-BUBBLE, stopPropagation: ours NEVER fires
+ // capture-then-CAPTURE, stopPropagation: ours still fires
+ //
+ // So this must not be "tidied" to bubble: on bubble we would silently stop
+ // seeing exactly the events xterm handled, and whether we saw them at all
+ // would depend on xterm's `options.cancelEvents`, which Codeman never sets.
+ const h = harness();
+ const input = h.textarea.registrations().filter((entry) => entry.type === 'input');
+ expect(input).toHaveLength(1);
+ expect(input[0].capture).toBe(true);
+ for (const entry of h.textarea.registrations()) expect(entry.capture).toBe(true);
+ });
+
+ it('forwards nothing on the keypress path, where canonical data precedes the input event', () => {
+ // xterm's _keyPress calls triggerDataEvent() and sets _keyPressHandled
+ // BEFORE the input event fires. The "did xterm speak?" snapshot therefore
+ // has to be taken at keydown; taken at input time it would already include
+ // this emission and the character would be delivered twice.
+ const h = harness();
+ h.keydown();
+ h.controller.notifyCanonicalData();
+ h.input('x');
+ h.flushTimers();
+ expect(h.emitted).toEqual([]);
+ });
+
+ it('does not let a stale candidate swallow a later identical keystroke (defect 1)', () => {
+ const h = harness();
+
+ // First keystroke: orphaned, recovered.
+ h.keydown();
+ h.input('x');
+ h.flushTimers();
+ expect(h.emitted).toEqual(['x']);
+
+ // Second identical keystroke, handled by xterm itself.
+ h.keydown();
+ h.input('x');
+ h.controller.notifyCanonicalData();
+ h.flushTimers();
+
+ // Exactly one recovery total, and the second keystroke's canonical byte was
+ // never claimed or suppressed by the first one.
+ expect(h.emitted).toEqual(['x']);
+ });
+
+ it('forwards committed text that no keydown key could describe, exactly once (defect 2)', () => {
+ const h = harness();
+ h.keydown({ key: 'Enter' });
+ h.input('a longer commit');
+ h.flushTimers();
+ h.flushTimers();
+ expect(h.emitted).toEqual(['a longer commit']);
+ });
+
+ it('recovers a GBoard keydown and never reads key or keyCode (defect 3)', () => {
+ const h = harness();
+ const reads: string[] = [];
+ h.controller.handleKeyEvent({
+ type: 'keydown',
+ get key() {
+ reads.push('key');
+ return 'Unidentified';
+ },
+ get keyCode() {
+ reads.push('keyCode');
+ return 229;
+ },
+ get which() {
+ reads.push('which');
+ return 229;
+ },
+ });
+ h.input('x');
+ h.flushTimers();
+ expect(h.emitted).toEqual(['x']);
+ expect(reads).toEqual([]);
+ });
+
+ it('ignores input events that are not committed text', () => {
+ const h = harness();
+ for (const inputType of ['insertCompositionText', 'deleteContentBackward', 'insertLineBreak', 'insertFromPaste']) {
+ h.keydown();
+ h.input('x', { inputType });
+ }
+ h.keydown();
+ h.input('');
+ h.keydown();
+ h.textarea.fire('input', { data: null, inputType: 'insertText' });
+ h.flushTimers();
+ expect(h.emitted).toEqual([]);
+ });
+
+ it('ignores composition and cancels pending candidates on compositionstart', () => {
+ const composing = harness();
+ composing.keydown();
+ composing.input('x', { isComposing: true });
+ composing.flushTimers();
+ expect(composing.emitted).toEqual([]);
+
+ const lifecycle = harness();
+ lifecycle.textarea.fire('compositionstart');
+ lifecycle.keydown();
+ lifecycle.input('中');
+ lifecycle.flushTimers();
+ expect(lifecycle.emitted).toEqual([]);
+
+ // compositionstart arriving after a candidate is queued must cancel it.
+ const cancelled = harness();
+ cancelled.keydown();
+ cancelled.input('x');
+ cancelled.textarea.fire('compositionstart');
+ cancelled.flushTimers();
+ expect(cancelled.emitted).toEqual([]);
+
+ // compositionend releases the gate again.
+ cancelled.textarea.fire('compositionend');
+ cancelled.keydown();
+ cancelled.input('y');
+ cancelled.flushTimers();
+ expect(cancelled.emitted).toEqual(['y']);
+ });
+
+ it('stays out of the way in screen reader mode', () => {
+ const h = harness({ screenReader: true });
+ h.keydown();
+ h.input('x');
+ h.flushTimers();
+ expect(h.emitted).toEqual([]);
+ });
+
+ it('recovers an input event that arrives with no preceding keydown', () => {
+ const h = harness();
+ h.input('x');
+ h.flushTimers();
+ expect(h.emitted).toEqual(['x']);
+ });
+
+ it('clears timers and listeners on destroy', () => {
+ const h = harness();
+ expect(h.textarea.listenerCount()).toBeGreaterThan(0);
+ h.keydown();
+ h.input('x');
+ expect(h.pendingTimers()).toBe(1);
+
+ h.controller.destroy();
+ expect(h.pendingTimers()).toBe(0);
+ expect(h.textarea.listenerCount()).toBe(0);
+
+ h.flushTimers();
+ expect(h.emitted).toEqual([]);
+
+ // Nothing fires after destroy, even if a stray event is delivered.
+ h.textarea.fire('input', inputEvent('y'));
+ h.flushTimers();
+ expect(h.emitted).toEqual([]);
+ });
+});