diff --git a/CLAUDE.md b/CLAUDE.md
index c4a910ccf..0fa36dfaa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -312,7 +312,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) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `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) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `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. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
+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) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `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) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `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. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on ``; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
@@ -372,7 +372,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](docs/architecture-invariants.md#sse-staleness-watchdog)
-**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers)
+**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers)
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
diff --git a/config/test-suites.ts b/config/test-suites.ts
index 9f3ed295a..0d1eac00d 100644
--- a/config/test-suites.ts
+++ b/config/test-suites.ts
@@ -33,6 +33,7 @@ export const BROWSER_TEST_GLOBS = [
'test/split-pane-terminal.browser.test.ts',
'test/split-pane-orchestration.browser.test.ts',
'test/split-pane-auto-collapse.browser.test.ts',
+ 'test/mobile-ime-preview.browser.test.ts',
];
/**
diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md
index 6a25c5b34..402a5df59 100644
--- a/docs/architecture-invariants.md
+++ b/docs/architecture-invariants.md
@@ -949,7 +949,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
### Z-index layers
-**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7).
+**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7), iOS IME composition preview (EFFECTIVE layer depends on its home: with local echo on it is part of the local echo overlay at 7, drawn by `setComposition()` as an underlined tail after the pending text; with local echo off it is a span at z-index 6 inside `.xterm-helpers`, but `.xterm-helpers` is its own z-index 5 stacking context, so the span's effective layer is 5. That is below the overlay's 7, which is why the span cannot be used while the overlay holds text: typed text never reaches the PTY before Enter, the PTY cursor that places the span stays at the prompt start, and the overlay's opaque line div covers it).
## Security layers
diff --git a/packages/xterm-zerolag-input/src/overlay-renderer.ts b/packages/xterm-zerolag-input/src/overlay-renderer.ts
index d88c87430..7e8d7a07b 100644
--- a/packages/xterm-zerolag-input/src/overlay-renderer.ts
+++ b/packages/xterm-zerolag-input/src/overlay-renderer.ts
@@ -58,6 +58,7 @@ export function stringCellWidth(terminal: XtermTerminal | null | undefined, str:
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
const {
lines,
+ compositionStart,
startCol,
totalCols,
cellW,
@@ -90,12 +91,24 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
// `startCol` indents only the line that begins at the prompt marker, so it is
// dropped along with that line when the tail is all that fits.
const rows = totalRows && totalRows > 0 ? totalRows : terminal?.rows;
+ // Code-point offset of each line in the whole text, so the composition
+ // styling survives the tail slice below.
+ const lineOffsets: number[] = [];
+ {
+ let offset = 0;
+ for (const line of lines) {
+ lineOffsets.push(offset);
+ offset += [...line].length;
+ }
+ }
let visibleLines = lines;
+ let firstVisible = 0;
let keepsPromptLine = true;
let topRow = promptRow;
if (rows && rows > 0) {
if (lines.length > rows) {
- visibleLines = lines.slice(lines.length - rows);
+ firstVisible = lines.length - rows;
+ visibleLines = lines.slice(firstVisible);
keepsPromptLine = false;
topRow = 0;
} else if (promptRow + lines.length > rows) {
@@ -116,7 +129,21 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
const leftPx = indents ? startCol * cellW : 0;
const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx;
const topPx = i * cellH;
- const lineEl = makeLine(visibleLines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal);
+ const lineCompositionFrom =
+ compositionStart === undefined ? undefined : compositionStart - lineOffsets[firstVisible + i];
+ const lineEl = makeLine(
+ visibleLines[i],
+ leftPx,
+ topPx,
+ widthPx,
+ cellH,
+ cellW,
+ charTop,
+ charHeight,
+ font,
+ terminal,
+ lineCompositionFrom
+ );
container.appendChild(lineEl);
}
@@ -144,7 +171,10 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
* Create a styled line `
` with per-character grid positioning.
*
* Each character gets its own `
` positioned by visual column offset.
- * CJK wide characters occupy 2 cell widths.
+ * CJK wide characters occupy 2 cell widths. Characters at or after
+ * `compositionFrom` (a code-point index into `text`, may be negative) are IME
+ * composition text: underlined, like xterm's own composition view, and marked
+ * `data-zerolag-composition` + `aria-hidden` since they are provisional.
*/
function makeLine(
text: string,
@@ -156,7 +186,8 @@ function makeLine(
_charTop: number,
_charHeight: number,
font: FontStyle,
- terminal?: XtermTerminal | null
+ terminal?: XtermTerminal | null,
+ compositionFrom?: number
): HTMLDivElement {
const el = document.createElement('div');
el.style.cssText = 'position:absolute;pointer-events:none';
@@ -172,6 +203,7 @@ function makeLine(
// CJK wide chars occupy 2 cells — position by visual column offset
let colOffset = 0;
+ let index = 0;
for (const ch of text) {
const cw = charCellWidth(terminal, ch);
const span = document.createElement('span');
@@ -189,9 +221,15 @@ function makeLine(
span.style.fontWeight = font.fontWeight;
span.style.color = font.color;
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
+ if (compositionFrom !== undefined && index >= compositionFrom) {
+ span.style.textDecoration = 'underline';
+ span.setAttribute('data-zerolag-composition', '');
+ span.setAttribute('aria-hidden', 'true');
+ }
span.textContent = ch;
el.appendChild(span);
colOffset += cw;
+ index++;
}
return el;
diff --git a/packages/xterm-zerolag-input/src/types.ts b/packages/xterm-zerolag-input/src/types.ts
index bb9f7fe19..e81d610dd 100644
--- a/packages/xterm-zerolag-input/src/types.ts
+++ b/packages/xterm-zerolag-input/src/types.ts
@@ -163,6 +163,12 @@ export interface CellDimensions {
/** Parameters for the overlay renderer. */
export interface RenderParams {
lines: string[];
+ /**
+ * Index (in code points, across all `lines`) where IME composition text
+ * begins. Characters from there on are drawn underlined and marked
+ * `data-zerolag-composition`. Omit when nothing is being composed.
+ */
+ compositionStart?: number;
startCol: number;
totalCols: number;
cellW: number;
diff --git a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts
index b1e4105b3..ce7831cb9 100644
--- a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts
+++ b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts
@@ -67,6 +67,8 @@ export class ZerolagInputAddon implements XtermAddon {
private _flushedOffset = 0;
private _flushedText = '';
private _bufferDetectDone = false;
+ // IME text still being composed: drawn after the pending text, never sent.
+ private _composition = '';
// Render cache
private _lastRenderKey = '';
@@ -130,7 +132,7 @@ export class ZerolagInputAddon implements XtermAddon {
clearTimeout(this._scrollTimer);
this._scrollTimer = null;
}
- } else if (this._pendingText || this._flushedOffset > 0) {
+ } else if (this._hasContent()) {
if (this._scrollTimer) clearTimeout(this._scrollTimer);
this._scrollTimer = setTimeout(() => {
this._scrollTimer = null;
@@ -208,6 +210,8 @@ export class ZerolagInputAddon implements XtermAddon {
* - `false`: Nothing to remove. The consumer should NOT send backspace.
*/
removeChar(): 'pending' | 'flushed' | false {
+ // A backspace that reaches the overlay means no composition is open.
+ this._composition = '';
if (this._pendingText.length > 0) {
this._pendingText = this._pendingText.slice(0, -1);
if (this._pendingText.length > 0 || this._flushedOffset > 0) {
@@ -252,6 +256,7 @@ export class ZerolagInputAddon implements XtermAddon {
*/
clear(): void {
this._pendingText = '';
+ this._composition = '';
this._flushedOffset = 0;
this._flushedText = '';
this._bufferDetectDone = false;
@@ -297,7 +302,7 @@ export class ZerolagInputAddon implements XtermAddon {
clearFlushed(): void {
this._flushedOffset = 0;
this._flushedText = '';
- if (this._pendingText) {
+ if (this._pendingText || this._composition) {
this._render();
} else {
this._hide();
@@ -312,7 +317,7 @@ export class ZerolagInputAddon implements XtermAddon {
* that move the prompt.
*/
rerender(): void {
- if (this._pendingText || this._flushedOffset > 0) {
+ if (this._hasContent()) {
this._lastRenderKey = '';
this._render();
}
@@ -325,7 +330,7 @@ export class ZerolagInputAddon implements XtermAddon {
refreshFont(): void {
this._cacheFont();
this._lastRenderKey = '';
- if (this._pendingText || this._flushedOffset > 0) this._render();
+ if (this._hasContent()) this._render();
}
// ─── Buffer detection ─────────────────────────────────────────────
@@ -391,7 +396,37 @@ export class ZerolagInputAddon implements XtermAddon {
this._options.prompt = finder;
this._lastPromptPos = null;
this._lastRenderKey = '';
- if (this._pendingText || this._flushedOffset > 0) this._render();
+ if (this._hasContent()) this._render();
+ }
+
+ // ─── IME composition ──────────────────────────────────────────────
+
+ /**
+ * Show text an IME is still composing as an underlined tail after the
+ * pending text, wrapped and kept on screen like the rest of the overlay.
+ * Pass `''` to remove it.
+ *
+ * Visual only: the composition is never part of `pendingText`, `hasPending`
+ * or anything a consumer sends. When the IME commits, the consumer adds the
+ * committed text the usual way (`addChar`/`appendText`) and clears the
+ * composition. `clear()` and `removeChar()` drop it too.
+ */
+ setComposition(text: string): void {
+ // One visual line of provisional text: control characters and line breaks
+ // would break the cell grid.
+ const next = typeof text === 'string' ? text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, '') : '';
+ if (next === this._composition) return;
+ this._composition = next;
+ if (this._hasContent()) {
+ this._render();
+ } else {
+ this._hide();
+ }
+ }
+
+ /** Text an IME is still composing, drawn after `pendingText` (never sent). */
+ get composition(): string {
+ return this._composition;
}
// ─── Prompt utilities ─────────────────────────────────────────────
@@ -443,6 +478,10 @@ export class ZerolagInputAddon implements XtermAddon {
// ─── Private methods ──────────────────────────────────────────────
+ private _hasContent(): boolean {
+ return this._pendingText.length > 0 || this._flushedOffset > 0 || this._composition.length > 0;
+ }
+
private _getPromptOffset(): number {
const prompt = this._options.prompt ?? DEFAULT_PROMPT;
return prompt.offset ?? 2;
@@ -505,7 +544,7 @@ export class ZerolagInputAddon implements XtermAddon {
private _render(): void {
if (!this._terminal || !this._overlay) return;
- if (!this._pendingText && !(this._flushedOffset > 0)) {
+ if (!this._hasContent()) {
this._overlay.style.display = 'none';
return;
}
@@ -563,12 +602,16 @@ export class ZerolagInputAddon implements XtermAddon {
}
}
+ // The composition is a styled tail after everything the user has typed.
+ const compositionStart = [...displayText].length;
+ displayText += this._composition;
+
// Skip redundant re-renders — include text content to detect
// same-length changes (e.g., setFlushed with different text)
// `rows` is part of the key: the layout is clamped to the visible rows
// (see renderOverlay), so a keyboard opening — which changes rows without
// changing the text — must not be skipped as a redundant render.
- const renderKey = `${displayText}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
+ const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
this._lastRenderKey = renderKey;
@@ -608,6 +651,7 @@ export class ZerolagInputAddon implements XtermAddon {
renderOverlay(this._overlay, {
lines,
+ compositionStart: this._composition ? compositionStart : undefined,
startCol,
totalCols,
cellW,
diff --git a/packages/xterm-zerolag-input/test/composition.test.ts b/packages/xterm-zerolag-input/test/composition.test.ts
new file mode 100644
index 000000000..4f7490d12
--- /dev/null
+++ b/packages/xterm-zerolag-input/test/composition.test.ts
@@ -0,0 +1,215 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { createMockTerminal } from './helpers.js';
+import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
+
+// setComposition(): IME text still being composed, drawn as an underlined tail
+// after the pending text. Visual only, never part of what a consumer sends.
+
+const CELL_W = 10;
+
+let cleanups: (() => void)[] = [];
+
+afterEach(() => {
+ for (const fn of cleanups) fn();
+ cleanups = [];
+});
+
+function setup(opts: { lines?: string[]; cols?: number; rows?: number } = {}) {
+ const mock = createMockTerminal({
+ buffer: { lines: opts.lines ?? ['$ '] },
+ cols: opts.cols,
+ rows: opts.rows,
+ cellWidth: CELL_W,
+ cellHeight: 20,
+ });
+ const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 } });
+ mock.terminal.loadAddon(addon);
+ cleanups.push(() => {
+ addon.dispose();
+ mock.cleanup();
+ });
+ const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
+ return { addon, mock, overlay };
+}
+
+/** Line divs of the overlay (the block cursor is a bare span, not a div). */
+function lineDivs(overlay: HTMLDivElement): HTMLDivElement[] {
+ return Array.from(overlay.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[];
+}
+
+function lineText(line: HTMLDivElement): string {
+ return Array.from(line.children)
+ .map((s) => s.textContent)
+ .join('');
+}
+
+function compositionText(overlay: HTMLDivElement): string {
+ return Array.from(overlay.querySelectorAll('[data-zerolag-composition]'))
+ .map((s) => s.textContent)
+ .join('');
+}
+
+describe('setComposition', () => {
+ it('renders the composition after pendingText, underlined and aria-hidden', () => {
+ const { addon, overlay } = setup();
+ addon.appendText('abc');
+ addon.setComposition('xy');
+
+ const [line] = lineDivs(overlay);
+ expect(lineText(line)).toBe('abcxy');
+ const spans = Array.from(line.children) as HTMLSpanElement[];
+ for (const span of spans.slice(0, 3)) {
+ expect(span.hasAttribute('data-zerolag-composition')).toBe(false);
+ expect(span.style.textDecoration).toBe('');
+ }
+ for (const span of spans.slice(3)) {
+ expect(span.hasAttribute('data-zerolag-composition')).toBe(true);
+ expect(span.getAttribute('aria-hidden')).toBe('true');
+ expect(span.style.textDecoration).toBe('underline');
+ }
+ // Grid positions continue straight on from the pending text.
+ expect(spans[3].style.left).toBe(3 * CELL_W + 'px');
+ expect(spans[4].style.left).toBe(4 * CELL_W + 'px');
+ expect(overlay.style.display).toBe('');
+ });
+
+ it('places a wide composition by cell width after wide pending text', () => {
+ const { addon, overlay } = setup();
+ addon.appendText('今日は');
+ addon.setComposition('天気');
+ const spans = Array.from(lineDivs(overlay)[0].children) as HTMLSpanElement[];
+ expect(spans.map((s) => s.textContent).join('')).toBe('今日は天気');
+ expect(spans[3].style.left).toBe(6 * CELL_W + 'px');
+ expect(spans[3].style.width).toBe(2 * CELL_W + 'px');
+ expect(spans[4].style.left).toBe(8 * CELL_W + 'px');
+ });
+
+ it('does not touch pendingText, hasPending, flushed state or the state snapshot', () => {
+ const { addon } = setup();
+ addon.appendText('abc');
+ addon.setFlushed(2, 'zz');
+ addon.setComposition('xy');
+ expect(addon.pendingText).toBe('abc');
+ expect(addon.getFlushed()).toEqual({ count: 2, text: 'zz' });
+ expect(addon.composition).toBe('xy');
+ expect(addon.state.pendingText).toBe('abc');
+ expect(addon.state.flushedText).toBe('zz');
+ });
+
+ it('shows on an empty prompt without making anything pending', () => {
+ const { addon, overlay } = setup();
+ addon.setComposition('かな');
+ expect(addon.pendingText).toBe('');
+ expect(addon.hasPending).toBe(false);
+ expect(addon.state.visible).toBe(true);
+ expect(compositionText(overlay)).toBe('かな');
+ });
+
+ it('wraps with the pending text: the tail continues onto the next line', () => {
+ // 12 cols, prompt at col 0 + offset 2 = 10 cells on the first line.
+ const { addon, overlay } = setup({ cols: 12 });
+ addon.appendText('abcdefgh');
+ addon.setComposition('WXYZ');
+ const lines = lineDivs(overlay);
+ expect(lines.map(lineText)).toEqual(['abcdefghWX', 'YZ']);
+ expect(compositionText(overlay)).toBe('WXYZ');
+ const second = Array.from(lines[1].children) as HTMLSpanElement[];
+ expect(second.every((s) => s.hasAttribute('data-zerolag-composition'))).toBe(true);
+ expect(second[0].style.left).toBe('0px');
+ });
+
+ it('keeps the composition styling when only the tail of a tall prompt fits', () => {
+ // 2 visible rows, 3 lines of text: the first line is dropped.
+ const { addon, overlay } = setup({ cols: 6, rows: 2 });
+ addon.appendText('abcdefghij');
+ addon.setComposition('XYZ');
+ const lines = lineDivs(overlay);
+ expect(lines.map(lineText)).toEqual(['efghij', 'XYZ']);
+ expect(compositionText(overlay)).toBe('XYZ');
+ const first = Array.from(lines[0].children);
+ expect(first.some((s) => s.hasAttribute('data-zerolag-composition'))).toBe(false);
+ });
+
+ it("setComposition('') removes the tail and keeps the pending text", () => {
+ const { addon, overlay } = setup();
+ addon.appendText('abc');
+ addon.setComposition('xy');
+ addon.setComposition('');
+ expect(lineText(lineDivs(overlay)[0])).toBe('abc');
+ expect(compositionText(overlay)).toBe('');
+ expect(addon.pendingText).toBe('abc');
+ });
+
+ it("setComposition('') on an otherwise empty overlay hides it", () => {
+ const { addon, overlay } = setup();
+ addon.setComposition('xy');
+ addon.setComposition('');
+ expect(overlay.style.display).toBe('none');
+ expect(overlay.innerHTML).toBe('');
+ });
+
+ it('clear() (Enter, Ctrl+C) drops the composition with everything else', () => {
+ const { addon, overlay } = setup();
+ addon.appendText('abc');
+ addon.setComposition('xy');
+ addon.clear();
+ expect(addon.composition).toBe('');
+ expect(overlay.style.display).toBe('none');
+ addon.addChar('q');
+ expect(lineText(lineDivs(overlay)[0])).toBe('q');
+ });
+
+ it('removeChar() drops the composition and removes a pending char, not a composed one', () => {
+ const { addon, overlay } = setup();
+ addon.appendText('abc');
+ addon.setComposition('xy');
+ expect(addon.removeChar()).toBe('pending');
+ expect(addon.pendingText).toBe('ab');
+ expect(addon.composition).toBe('');
+ expect(lineText(lineDivs(overlay)[0])).toBe('ab');
+ });
+
+ it('text appended while composing lands before the tail', () => {
+ const { addon, overlay } = setup();
+ addon.appendText('ab');
+ addon.setComposition('xy');
+ addon.addChar('c');
+ expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
+ expect(compositionText(overlay)).toBe('xy');
+ });
+
+ it('rerender() and refreshFont() keep the composition', () => {
+ const { addon, overlay } = setup();
+ addon.appendText('abc');
+ addon.setComposition('xy');
+ addon.rerender();
+ expect(compositionText(overlay)).toBe('xy');
+ addon.refreshFont();
+ expect(compositionText(overlay)).toBe('xy');
+ expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
+ });
+
+ it('re-renders when only the composition changes', () => {
+ const { addon, overlay } = setup();
+ addon.appendText('abc');
+ addon.setComposition('x');
+ addon.setComposition('xy');
+ expect(lineText(lineDivs(overlay)[0])).toBe('abcxy');
+ });
+
+ it('strips control characters and line breaks from the composition', () => {
+ const { addon, overlay } = setup();
+ addon.setComposition('a\nb\u0007c
');
+ expect(addon.composition).toBe('abc');
+ expect(compositionText(overlay)).toBe('abc');
+ });
+
+ it('draws the block cursor after the composition', () => {
+ const { addon, overlay } = setup();
+ addon.appendText('ab');
+ addon.setComposition('xy');
+ const cursor = Array.from(overlay.children).find((el) => el.tagName === 'SPAN') as HTMLSpanElement;
+ // prompt col 0 + offset 2 + 4 cells
+ expect(cursor.style.left).toBe(6 * CELL_W + 'px');
+ });
+});
diff --git a/scripts/build.mjs b/scripts/build.mjs
index 2ab8ecd5a..3422efe0c 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 mobile-ime-preview.js', 'npx esbuild dist/web/public/mobile-ime-preview.js --minify --outfile=dist/web/public/mobile-ime-preview.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');
@@ -111,6 +112,7 @@ console.log('\n[build] content-hash cache busting');
'notification-manager.js',
'keyboard-accessory.js',
'input-cjk.js',
+ 'mobile-ime-preview.js',
'terminal-keycode229-recovery.js',
'sanitize-html.js',
'app.js',
diff --git a/src/web/public/index.html b/src/web/public/index.html
index a5009b53f..435605354 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -3735,6 +3735,8 @@ 🧠 Read My Mind
+
+
diff --git a/src/web/public/mobile-ime-preview.js b/src/web/public/mobile-ime-preview.js
new file mode 100644
index 000000000..42826c8a1
--- /dev/null
+++ b/src/web/public/mobile-ime-preview.js
@@ -0,0 +1,297 @@
+/**
+ * @fileoverview In-terminal preview of IME composition text on iOS Safari.
+ *
+ * On iOS WebKit touch devices the text an IME is composing (Japanese, Chinese,
+ * Korean, dictation) is not visible inside the terminal until it commits, so
+ * the user types blind. The controller listens to the helper textarea's
+ * composition events and asks the caller to render the latest composition
+ * (`phase: 'provisional'`), coalesced to one render per animation frame and
+ * capped at 2048 characters. When xterm emits the committed text through
+ * onData, the caller hands it to `consumeTerminalData()`, which switches the
+ * preview to `phase: 'committed'` until something else shows the text: the
+ * local echo overlay or a prediction (`completeCommit`), authoritative
+ * terminal output (`noteAuthoritativeOutput`), or a 2 s fallback timer. The
+ * same 2 s bound applies while waiting for a commit that never reaches onData
+ * (the user deleted the whole composition), so a later unrelated chunk is never
+ * mistaken for it.
+ *
+ * Keydown ordering: xterm registers its textarea keydown listener in the
+ * capture phase inside terminal.open() and finalizes the composition there
+ * (CompositionHelper.keydown), emitting the commit through onData
+ * synchronously. The controller therefore observes keydown in the capture
+ * phase on an ANCESTOR (`keydownTarget`, the terminal element), which runs
+ * before any listener on the textarea itself, and finalizes on exactly the
+ * keys xterm does.
+ *
+ * VISUAL ONLY: the controller never sends, consumes or reorders input bytes,
+ * and every callback is wrapped so a failing render cannot block the wire.
+ * `isIosWebKitTouch()` gates creation; other platforms keep xterm's own
+ * composition view untouched.
+ *
+ * @dependency none (standalone IIFE; consumed by terminal-ui.js)
+ * @loadorder 5.52 (before app.js/terminal-ui.js, which create the controller)
+ */
+(function (global) {
+ 'use strict';
+
+ const COMMITTED_VISUAL_TTL = 2000;
+ const PREVIEW_CAP = 2048;
+ // keyCodes on which xterm 6's CompositionHelper.keydown keeps composing
+ // (CapsLock, the IME "composition character", Shift/Ctrl/Alt). Any other
+ // keydown during a composition finalizes it.
+ const KEEP_COMPOSING_KEYCODES = new Set([20, 229, 16, 17, 18]);
+ const CONTROL_OR_LINE_BREAK = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
+
+ function isIosWebKitTouch(nav = navigator) {
+ const userAgent = String(nav && nav.userAgent ? nav.userAgent : '');
+ const platform = String(nav && nav.platform ? nav.platform : '');
+ const touchPoints = Number(nav && nav.maxTouchPoints ? nav.maxTouchPoints : 0);
+ const iosDevice = /iPhone|iPad|iPod/.test(userAgent);
+ const desktopIpad = platform === 'MacIntel' && touchPoints > 1;
+ return touchPoints > 0 && /AppleWebKit/.test(userAgent) && (iosDevice || desktopIpad);
+ }
+
+ function create(options) {
+ const textarea = options.textarea;
+ // Must be the textarea or an ancestor of it, so its capture listener runs
+ // before xterm's capture listener on the textarea.
+ const keydownTarget = options.keydownTarget || textarea;
+ const render = typeof options.render === 'function' ? options.render : function () {};
+ const clear = typeof options.clear === 'function' ? options.clear : function () {};
+ const onCommit = typeof options.onCommit === 'function' ? options.onCommit : function () {};
+ const scheduleFrame = options.scheduleFrame || global.requestAnimationFrame.bind(global);
+ const cancelFrame = options.cancelFrame || global.cancelAnimationFrame.bind(global);
+ const setTimer = options.setTimer || global.setTimeout.bind(global);
+ const clearTimer = options.clearTimer || global.clearTimeout.bind(global);
+
+ let generation = 0;
+ let composing = false;
+ let awaitingCommit = false;
+ let committed = false;
+ let latestValue = '';
+ let renderPhase = null;
+ let frameToken = null;
+ let timerToken = null;
+ let finalizedByKeydown = false;
+ let destroyed = false;
+ let invokingClear = false;
+
+ function safely(callback, ...args) {
+ try {
+ return callback(...args);
+ } catch (_error) {
+ return undefined;
+ }
+ }
+
+ function cancelScheduledFrame() {
+ const token = frameToken;
+ frameToken = null;
+ if (token && token.id !== undefined) safely(cancelFrame, token.id);
+ }
+
+ function cancelCommittedTimer() {
+ const token = timerToken;
+ timerToken = null;
+ if (token && token.id !== undefined) safely(clearTimer, token.id);
+ }
+
+ function clearVisual() {
+ if (invokingClear) return;
+ invokingClear = true;
+ safely(clear);
+ invokingClear = false;
+ }
+
+ function cleanup() {
+ generation += 1;
+ cancelScheduledFrame();
+ cancelCommittedTimer();
+ composing = false;
+ awaitingCommit = false;
+ committed = false;
+ latestValue = '';
+ renderPhase = null;
+ finalizedByKeydown = false;
+ clearVisual();
+ }
+
+ function scheduleLatestPreview(phase) {
+ if (destroyed) return;
+ renderPhase = phase;
+ if (frameToken) return;
+ const token = { generation, id: undefined };
+ frameToken = token;
+ const callback = function () {
+ if (destroyed || frameToken !== token || token.generation !== generation || renderPhase === null) return;
+ frameToken = null;
+ const value = latestValue.slice(0, PREVIEW_CAP);
+ const phaseToRender = renderPhase;
+ safely(render, { text: value, phase: phaseToRender });
+ };
+ const id = safely(scheduleFrame, callback);
+ if (frameToken === token) {
+ if (id === undefined) frameToken = null;
+ else token.id = id;
+ }
+ }
+
+ function beginComposition() {
+ cleanup();
+ if (destroyed) return;
+ composing = true;
+ }
+
+ function updateComposition(event) {
+ if (!composing) return;
+ latestValue = event.data == null ? '' : String(event.data);
+ scheduleLatestPreview('provisional');
+ }
+
+ function onComposingInput(event) {
+ if (!event.isComposing) return;
+ updateComposition({ data: event.data == null ? textarea.value : event.data });
+ }
+
+ function armFallbackTimer(isCurrent) {
+ const token = { generation, id: undefined };
+ timerToken = token;
+ const callback = function () {
+ if (destroyed || timerToken !== token || token.generation !== generation || !isCurrent()) return;
+ timerToken = null;
+ cleanup();
+ };
+ const id = safely(setTimer, callback, COMMITTED_VISUAL_TTL);
+ if (timerToken === token) {
+ if (id === undefined) {
+ timerToken = null;
+ return false;
+ }
+ token.id = id;
+ }
+ return true;
+ }
+
+ function finalizeComposition(value, fromKeydown) {
+ if (!composing) return;
+ composing = false;
+ awaitingCommit = true;
+ committed = false;
+ finalizedByKeydown = fromKeydown;
+ latestValue = value == null ? latestValue : String(value);
+ scheduleLatestPreview('provisional');
+ // A commit that never reaches onData (the composition was deleted, so
+ // xterm emits nothing) must not leave the controller waiting forever.
+ cancelCommittedTimer();
+ const owner = generation;
+ armFallbackTimer(function () {
+ return awaitingCommit && generation === owner;
+ });
+ }
+
+ function onCompositionEnd(event) {
+ if (finalizedByKeydown) {
+ finalizedByKeydown = false;
+ return;
+ }
+ finalizeComposition(event.data, false);
+ }
+
+ // Mirrors CompositionHelper.keydown in @xterm/xterm 6.0.0
+ // (src/browser/input/CompositionHelper.ts:94-108): while composing, keyCode
+ // 20/229 and 16/17/18 keep the composition open and every other keyCode
+ // finalizes it. `isComposing` and `key` are deliberately not consulted,
+ // because xterm does not consult them.
+ function onKeydown(event) {
+ if (keydownTarget !== textarea && event.target !== textarea) return;
+ if (!composing || KEEP_COMPOSING_KEYCODES.has(event.keyCode)) return;
+ finalizeComposition(latestValue, true);
+ }
+
+ function reset() {
+ if (destroyed) return;
+ cleanup();
+ }
+
+ function consumeTerminalData(data) {
+ if (
+ destroyed ||
+ !awaitingCommit ||
+ typeof data !== 'string' ||
+ data.length === 0 ||
+ CONTROL_OR_LINE_BREAK.test(data)
+ ) {
+ return false;
+ }
+
+ generation += 1;
+ const owner = generation;
+ cancelScheduledFrame();
+ cancelCommittedTimer();
+ composing = false;
+ awaitingCommit = false;
+ committed = true;
+ latestValue = data;
+ renderPhase = 'committed';
+ safely(onCommit, data);
+ if (destroyed || generation !== owner || !committed) return true;
+
+ scheduleLatestPreview('committed');
+ if (destroyed || generation !== owner || !committed) return true;
+
+ const armed = armFallbackTimer(function () {
+ return committed;
+ });
+ if (!armed && !destroyed && generation === owner && committed) cleanup();
+ return true;
+ }
+
+ function completeCommit(result) {
+ if (destroyed || !result || result.predicted !== true || !committed) return;
+ cleanup();
+ }
+
+ function noteAuthoritativeOutput() {
+ if (destroyed || !committed) return;
+ cleanup();
+ }
+
+ const listeners = [
+ [textarea, 'compositionstart', beginComposition],
+ [textarea, 'compositionupdate', updateComposition],
+ [textarea, 'input', onComposingInput],
+ [textarea, 'compositionend', onCompositionEnd],
+ [keydownTarget, 'keydown', onKeydown, true],
+ [textarea, 'blur', reset],
+ ];
+ for (const [target, type, listener, capture] of listeners) target.addEventListener(type, listener, capture);
+
+ function destroy() {
+ if (destroyed) return;
+ destroyed = true;
+ for (const [target, type, listener, capture] of listeners) target.removeEventListener(type, listener, capture);
+ cleanup();
+ }
+
+ return {
+ consumeTerminalData,
+ completeCommit,
+ noteAuthoritativeOutput,
+ reset,
+ destroy,
+ get state() {
+ return {
+ generation,
+ composing,
+ awaitingCommit,
+ committed,
+ latest: latestValue,
+ framePending: frameToken !== null,
+ timerPending: timerToken !== null,
+ };
+ },
+ };
+ }
+
+ global.MobileImePreview = { create, isIosWebKitTouch };
+})(globalThis);
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index 050671a48..0a5c71782 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -460,6 +460,31 @@ textarea:focus-visible {
font-size: 16px !important; /* prevent iOS auto-zoom on focus */
}
+.xterm-helpers .codeman-ime-preview {
+ position: absolute;
+ left: var(--xterm-helper-left, 0px);
+ top: var(--xterm-helper-top, 0px);
+ z-index: 6;
+ pointer-events: none;
+ white-space: pre;
+ color: var(--terminal-foreground, var(--text, #fff));
+ font-family: var(--font-mono, monospace);
+ font-size: 14px;
+ font-weight: 400;
+ font-style: normal;
+ line-height: 1.2;
+ height: 1.2em;
+}
+
+.xterm-helpers .codeman-ime-preview[data-phase='provisional'] {
+ text-decoration: underline;
+ text-decoration-style: dotted;
+}
+
+.touch-device .xterm-helpers.codeman-ime-preview-owned .composition-view.active {
+ display: none;
+}
+
/* Session tab focus */
.session-tab:focus-visible {
outline: 2px solid var(--accent);
diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js
index 903661e52..5c48d5561 100644
--- a/src/web/public/terminal-ui.js
+++ b/src/web/public/terminal-ui.js
@@ -250,6 +250,235 @@ Object.assign(CodemanApp.prototype, {
this._keyCode229Recovery = null;
},
+ _destroyMobileImePreview() {
+ try {
+ this._mobileImePreview?.destroy?.();
+ } catch {
+ // The preview is visual-only; terminal replacement must continue.
+ }
+ this._mobileImePreview = null;
+ this._mobileImePreviewSessionId = null;
+ this._mobileImeCommitOutputSeq = null;
+ try {
+ this._mobileImePreviewNode?.remove?.();
+ } catch {
+ // Best-effort node cleanup only.
+ }
+ try {
+ this._mobileImePreviewHelpers?.classList?.remove('codeman-ime-preview-owned');
+ } catch {
+ // Best-effort ownership cleanup only.
+ }
+ this._mobileImePreviewNode = null;
+ this._mobileImePreviewHelpers = null;
+ try {
+ if (this._mobileImePreviewOfflineHandler) {
+ window.removeEventListener('offline', this._mobileImePreviewOfflineHandler);
+ }
+ if (this._mobileImePreviewPagehideHandler) {
+ window.removeEventListener('pagehide', this._mobileImePreviewPagehideHandler);
+ }
+ } catch {
+ // Best-effort listener cleanup only.
+ }
+ this._mobileImePreviewOfflineHandler = null;
+ this._mobileImePreviewPagehideHandler = null;
+ },
+
+ /**
+ * iOS Safari IME preview (mobile-ime-preview.js). WebKit does not show the
+ * text an IME is composing inside the terminal, so the user types blind.
+ *
+ * Two homes, chosen per render:
+ * - Local echo on: typed text sits in the LocalEchoOverlay and the PTY
+ * cursor stays at the prompt start, under the overlay's opaque text (z 7,
+ * `.xterm-screen`). So the overlay draws the composition itself, as an
+ * underlined tail after its pending text (`setComposition`).
+ * - Otherwise (a shell, or the overlay could not place it): a span inside
+ * `.xterm-helpers`, positioned by the same --xterm-helper-left/top vars as
+ * the helper textarea, which follow the PTY cursor.
+ *
+ * Visual only: nothing here touches the input path, and every failure
+ * leaves no DOM behind.
+ */
+ _initMobileImePreview() {
+ this._destroyMobileImePreview();
+ let preview = null;
+ let helpers = null;
+ try {
+ if (typeof MobileImePreview === 'undefined' || !MobileImePreview?.isIosWebKitTouch?.()) return;
+ const textarea = this.terminal?.textarea;
+ helpers = this.terminal?.element?.querySelector?.('.xterm-helpers');
+ if (!textarea || !helpers) return;
+
+ preview = document.createElement('span');
+ this._mobileImePreviewNode = preview;
+ this._mobileImePreviewHelpers = helpers;
+ preview.className = 'codeman-ime-preview';
+ preview.setAttribute('aria-hidden', 'true');
+ preview.hidden = true;
+ helpers.appendChild(preview);
+ const syncPreviewTypography = () => {
+ try {
+ const compositionView =
+ helpers.querySelector?.('.composition-view') || this.terminal?.element?.querySelector?.('.composition-view');
+ if (!compositionView || !preview.style) return;
+ const style = typeof getComputedStyle === 'function' ? getComputedStyle(compositionView) : compositionView.style;
+ for (const property of ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'lineHeight', 'height']) {
+ const value = style?.[property] || compositionView.style?.[property];
+ if (value) preview.style[property] = value;
+ }
+ const theme = this.terminal?.options?.theme;
+ let foreground = theme?.foreground;
+ let background = theme?.background;
+ if (!foreground || !background) {
+ try {
+ const current = window.codemanCurrentXtermTheme?.();
+ foreground = foreground || current?.foreground;
+ background = background || current?.background;
+ } catch {
+ // Theme lookup is best-effort; retain the safe terminal fallback.
+ }
+ }
+ preview.style.color = foreground || '#e0e0e0';
+ // Opaque, like xterm's own composition view, so the preview does not
+ // overprint whatever sits at the cursor (a dim composer placeholder).
+ preview.style.backgroundColor = background || '#0d0d0d';
+ } catch {
+ // Typography matching is visual-only and must not block input.
+ }
+ };
+ // The overlay only when it is what shows typed text right now (local echo
+ // on, and not handed back to plain PTY echo by a composer nav key).
+ const localEchoOverlay = () =>
+ this._localEchoEnabled && !this._echoPassthroughSessions?.has(this.activeSessionId)
+ ? this._localEchoOverlay || null
+ : null;
+ const clearOverlayComposition = () => {
+ try {
+ if (this._localEchoOverlay?.composition) this._localEchoOverlay.setComposition('');
+ } catch {}
+ };
+ const hideSpan = () => {
+ try {
+ preview.hidden = true;
+ } catch {}
+ try {
+ preview.textContent = '';
+ } catch {}
+ try {
+ delete preview.dataset.phase;
+ } catch {}
+ try {
+ helpers.classList.remove('codeman-ime-preview-owned');
+ } catch {}
+ };
+ const clearPreview = () => {
+ clearOverlayComposition();
+ hideSpan();
+ };
+ const controller = MobileImePreview.create({
+ textarea,
+ // An ancestor of the textarea: its capture-phase keydown listener runs
+ // before xterm's capture listener on the textarea, which finalizes the
+ // composition and emits the commit synchronously.
+ keydownTarget: this.terminal.element,
+ render: ({ text, phase }) => {
+ try {
+ const overlay = localEchoOverlay();
+ if (overlay && typeof overlay.setComposition === 'function') {
+ overlay.setComposition(text);
+ // No prompt found = nothing drawn: fall back to the span.
+ if (!text || overlay.state?.visible) {
+ hideSpan();
+ helpers.classList.toggle('codeman-ime-preview-owned', !!text);
+ return;
+ }
+ overlay.setComposition('');
+ } else {
+ clearOverlayComposition();
+ }
+ syncPreviewTypography();
+ preview.textContent = text;
+ preview.dataset.phase = phase;
+ preview.hidden = !text;
+ helpers.classList.toggle('codeman-ime-preview-owned', !!text);
+ } catch {
+ clearPreview();
+ }
+ },
+ clear: clearPreview,
+ });
+ this._mobileImePreview = controller;
+ this._mobileImePreviewSessionId = this.activeSessionId;
+
+ // Offline and pagehide only reset: initTerminal() runs once per page
+ // load, so destroying on pagehide would leave the preview off for good
+ // after a back-forward cache restore (iOS Safari keeps pages there).
+ this._mobileImePreviewOfflineHandler = () => {
+ try {
+ this._mobileImePreview?.reset?.();
+ } catch {
+ // Disconnect cleanup is visual-only.
+ }
+ };
+ this._mobileImePreviewPagehideHandler = this._mobileImePreviewOfflineHandler;
+ window.addEventListener('offline', this._mobileImePreviewOfflineHandler);
+ window.addEventListener('pagehide', this._mobileImePreviewPagehideHandler);
+ } catch {
+ this._destroyMobileImePreview();
+ }
+ },
+
+ /**
+ * Tell the IME preview about a chunk xterm emitted through onData. Returns
+ * true when the chunk is the IME's committed text, in which case the preview
+ * holds it (phase 'committed') until something else shows it. Never throws.
+ */
+ _consumeMobileImeTerminalData(data) {
+ let isImeCommit = false;
+ try {
+ isImeCommit = this._mobileImePreview?.consumeTerminalData?.(data) === true;
+ } catch {
+ // The preview is visual-only; normal terminal input must continue.
+ }
+ // Output accepted from here on can carry the echo of this commit.
+ if (isImeCommit) this._mobileImeCommitOutputSeq = this._terminalOutputSeq || 0;
+ return isImeCommit;
+ },
+
+ /**
+ * Clear a committed IME preview once terminal output accepted AFTER the
+ * commit has been parsed. `flushedOutputSeq` is the output sequence a fully
+ * written flush covered (null when part of it was deferred), so output that
+ * was already queued before the commit can never clear it early.
+ */
+ _noteMobileImeAuthoritativeOutput(flushedOutputSeq, sessionId) {
+ try {
+ const commitSeq = this._mobileImeCommitOutputSeq;
+ if (commitSeq === null || commitSeq === undefined || flushedOutputSeq === null) return;
+ if (sessionId !== this.activeSessionId || !(flushedOutputSeq > commitSeq)) return;
+ this._mobileImeCommitOutputSeq = null;
+ this._mobileImePreview?.noteAuthoritativeOutput?.();
+ } catch {
+ // Authoritative output is never delayed or consumed by the preview.
+ }
+ },
+
+ /**
+ * The local echo overlay has just taken a committed IME chunk through the
+ * ordinary printable/paste branch, so it now shows the text: release the
+ * preview instead of waiting for terminal output.
+ */
+ _transferMobileImeCommitToLocalEcho() {
+ this._mobileImeCommitOutputSeq = null;
+ try {
+ this._mobileImePreview?.completeCommit?.({ predicted: true });
+ } catch {
+ // Ownership transfer is visual-only.
+ }
+ },
+
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.
@@ -307,6 +536,7 @@ Object.assign(CodemanApp.prototype, {
const container = document.getElementById('terminalContainer');
this.terminal.open(container);
+ this._initMobileImePreview();
this._installMobileTapMouseGuard();
this._installShiftDragSelection();
this._installTouchSelectionFocusGuard();
@@ -1211,6 +1441,8 @@ Object.assign(CodemanApp.prototype, {
// survives tab switches and reconnects.
const handleTerminalData = (data) => {
+ // Before anything can rewrite `data`: is this chunk the IME's commit?
+ const isImeCommit = this._consumeMobileImeTerminalData(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
@@ -1288,6 +1520,8 @@ Object.assign(CodemanApp.prototype, {
// When enabled, keystrokes are buffered locally in the overlay for
// instant visual feedback. Nothing is sent to the PTY until Enter
// (or a control char) is pressed — avoids out-of-order char delivery.
+ // An IME commit takes the same printable/paste branch as typed text,
+ // and the overlay then shows it in place of the preview.
if (this._localEchoEnabled && !echoPassthrough) {
if (data === '\x7f') {
const source = this._localEchoOverlay?.removeChar();
@@ -1344,6 +1578,7 @@ Object.assign(CodemanApp.prototype, {
if (data.length > 1 && data.charCodeAt(0) >= 32) {
// Paste: append to overlay only (sent on Enter)
this._localEchoOverlay?.appendText(data);
+ if (isImeCommit && this._localEchoOverlay) this._transferMobileImeCommitToLocalEcho();
return;
}
if (data.charCodeAt(0) < 32) {
@@ -1489,6 +1724,7 @@ Object.assign(CodemanApp.prototype, {
if (data.length === 1 && data.charCodeAt(0) >= 32) {
// Printable char: add to overlay only (sent on Enter)
this._localEchoOverlay?.addChar(data);
+ if (isImeCommit && this._localEchoOverlay) this._transferMobileImeCommitToLocalEcho();
return;
}
}
@@ -3517,6 +3753,9 @@ Object.assign(CodemanApp.prototype, {
},
batchTerminalWrite(data) {
+ // Arrival order of output, so the IME preview can tell output that
+ // followed a commit from output that was already queued before it.
+ this._terminalOutputSeq = (this._terminalOutputSeq || 0) + 1;
// Feed the renderer watchdog. Recorded before the buffer-load early return
// below: a write that is queued rather than written still means the pipeline
// owes us a frame once it drains.
@@ -3580,6 +3819,7 @@ Object.assign(CodemanApp.prototype, {
// Accumulate raw data (may contain DEC 2026 markers)
this.pendingWrites.push(data);
+ this._pendingWritesOutputSeq = this._terminalOutputSeq;
this._scheduleTerminalWriteFlush();
},
@@ -3611,6 +3851,7 @@ Object.assign(CodemanApp.prototype, {
// Transfer buffered data to normal pending writes
this.pendingWrites.push(this.flickerFilterBuffer);
+ this._pendingWritesOutputSeq = this._terminalOutputSeq;
this.flickerFilterBuffer = '';
this.flickerFilterActive = false;
@@ -3641,6 +3882,15 @@ Object.assign(CodemanApp.prototype, {
* Position is tracked dynamically by _findPrompt() on every render.
*/
_updateLocalEchoState() {
+ if (this._mobileImePreviewSessionId !== this.activeSessionId) {
+ this._mobileImePreviewSessionId = this.activeSessionId;
+ this._mobileImeCommitOutputSeq = null;
+ try {
+ this._mobileImePreview?.reset?.();
+ } catch {
+ // The preview is visual-only; session switching must continue.
+ }
+ }
const settings = this.loadAppSettingsFromStorage();
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
@@ -3846,6 +4096,9 @@ Object.assign(CodemanApp.prototype, {
this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES));
deferred = true;
}
+ // Newest output this chunk fully contains, for the IME preview. A split
+ // chunk may not hold that output yet, so it reports nothing.
+ const flushedOutputSeq = deferred ? null : (this._pendingWritesOutputSeq ?? null);
this._terminalWriteInFlight = true;
this._terminalWriteInFlightBytes = writeChunk.length;
try {
@@ -3863,6 +4116,7 @@ Object.assign(CodemanApp.prototype, {
// because the test's write mock moved the viewport synchronously.)
this._restoreTerminalViewport(preserveViewportY, flushSessionId);
this._scheduleTerminalWriteFlush();
+ this._noteMobileImeAuthoritativeOutput(flushedOutputSeq, flushSessionId);
});
} catch (err) {
this._terminalWriteInFlight = false;
diff --git a/test/mobile-ime-preview-structure.test.ts b/test/mobile-ime-preview-structure.test.ts
new file mode 100644
index 000000000..62f4db343
--- /dev/null
+++ b/test/mobile-ime-preview-structure.test.ts
@@ -0,0 +1,703 @@
+/**
+ * @fileoverview Wiring tests for the iOS IME preview (mobile-ime-preview.js).
+ *
+ * The controller itself is covered by test/mobile-ime-preview.test.ts. These
+ * pin how terminal-ui.js and the delivery graph consume it: script order,
+ * build registration and CSS, the _init/_destroyMobileImePreview lifecycle,
+ * the onData routing of an IME commit, and the rule that only output accepted
+ * AFTER a commit may clear the committed preview.
+ */
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+import { describe, expect, it, vi } from 'vitest';
+
+const read = (path: string) => readFileSync(resolve(import.meta.dirname, '..', path), 'utf8');
+const indexSource = read('src/web/public/index.html');
+const buildSource = read('scripts/build.mjs');
+const terminalSource = read('src/web/public/terminal-ui.js');
+const cssSource = read('src/web/public/styles.css');
+
+type Fn = ReturnType;
+type App = Record;
+
+function loadMixin(globals: Record = {}) {
+ const FakeCodemanApp = function () {} as unknown as { prototype: Record };
+ const windowStub = {
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ ...(globals.window as object),
+ } as Record;
+ const context = vm.createContext({
+ console,
+ performance,
+ setTimeout,
+ clearTimeout,
+ setInterval: vi.fn(),
+ clearInterval: vi.fn(),
+ requestAnimationFrame: vi.fn(),
+ cancelAnimationFrame: vi.fn(),
+ URLSearchParams,
+ location: { search: '' },
+ localStorage: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
+ document: { addEventListener: vi.fn(), createElement: vi.fn() },
+ MobileDetection: { isTouchDevice: () => false },
+ CodemanApp: FakeCodemanApp,
+ _crashDiag: { log: vi.fn() },
+ ...globals,
+ window: windowStub,
+ });
+ vm.runInContext(terminalSource, context, { filename: 'terminal-ui.js' });
+ return { mixin: FakeCodemanApp.prototype, context, windowStub };
+}
+
+function fakeClassList() {
+ const values = new Set();
+ return {
+ add: vi.fn((value: string) => values.add(value)),
+ remove: vi.fn((value: string) => values.delete(value)),
+ toggle: vi.fn((value: string, force?: boolean) => {
+ const enabled = force === undefined ? !values.has(value) : force;
+ if (enabled) values.add(value);
+ else values.delete(value);
+ return enabled;
+ }),
+ contains: (value: string) => values.has(value),
+ };
+}
+
+function createPreviewHarness(
+ options: {
+ eligible?: boolean;
+ createThrows?: boolean;
+ omitGlobal?: boolean;
+ themeForeground?: string;
+ themeBackground?: string;
+ themeGetterThrows?: boolean;
+ } = {}
+) {
+ const compositionView = {
+ style: {
+ fontFamily: '"Fira Code"',
+ fontSize: '10px',
+ fontWeight: '500',
+ fontStyle: 'italic',
+ lineHeight: '12px',
+ height: '12px',
+ color: 'rgb(255, 255, 255)',
+ },
+ };
+ const helpers = {
+ classList: fakeClassList(),
+ children: [] as Array>,
+ querySelector: (selector: string) => (selector === '.composition-view' ? compositionView : null),
+ appendChild(node: Record) {
+ this.children.push(node);
+ },
+ };
+ const createdControllers: Array> = [];
+ const previewNodes: Array> = [];
+ const documentStub = {
+ addEventListener: vi.fn(),
+ createElement: vi.fn(() => {
+ const node = {
+ className: '',
+ hidden: false,
+ textContent: '',
+ dataset: {} as Record,
+ style: {} as Record,
+ attributes: {} as Record,
+ setAttribute(name: string, value: string) {
+ this.attributes[name] = value;
+ },
+ remove: vi.fn(),
+ };
+ previewNodes.push(node);
+ return node;
+ }),
+ };
+ const mobileImePreview = options.omitGlobal
+ ? undefined
+ : {
+ isIosWebKitTouch: vi.fn(() => options.eligible ?? true),
+ create: vi.fn((callbacks: Record) => {
+ if (options.createThrows) throw new Error('controller unavailable');
+ const controller = {
+ destroy: vi.fn(),
+ reset: vi.fn(),
+ consumeTerminalData: vi.fn(() => false),
+ completeCommit: vi.fn(),
+ noteAuthoritativeOutput: vi.fn(),
+ callbacks,
+ };
+ createdControllers.push(controller);
+ return controller;
+ }),
+ };
+ const { mixin, windowStub } = loadMixin({
+ document: documentStub,
+ MobileImePreview: mobileImePreview,
+ getComputedStyle: (node: { style: Record }) => node.style,
+ });
+ windowStub.codemanCurrentXtermTheme = () => {
+ if (options.themeGetterThrows) throw new Error('theme unavailable');
+ return { foreground: '#334455', background: '#223344' };
+ };
+ const app: App = Object.assign(Object.create(mixin), {
+ terminal: {
+ textarea: {},
+ options: {
+ theme:
+ options.themeForeground || options.themeBackground
+ ? { foreground: options.themeForeground, background: options.themeBackground }
+ : undefined,
+ },
+ element: { querySelector: (selector: string) => (selector === '.xterm-helpers' ? helpers : null) },
+ },
+ activeSessionId: 'session-a',
+ });
+ return { app, helpers, compositionView, previewNodes, createdControllers, mobileImePreview, windowStub };
+}
+
+describe('mobile IME preview delivery graph', () => {
+ it('loads the controller after xterm and before terminal wiring', () => {
+ const at = indexSource.indexOf('');
+ expect(at).toBeGreaterThan(indexSource.indexOf('vendor/xterm.min.js'));
+ expect(at).toBeLessThan(indexSource.indexOf(''));
+ expect(at).toBeLessThan(indexSource.indexOf(''));
+ });
+
+ it('is minified and content-hashed by the build', () => {
+ expect(buildSource).toContain("run('minify mobile-ime-preview.js'");
+ expect(buildSource).toMatch(/const HASHABLE = \[[^\]]*'mobile-ime-preview\.js'/);
+ });
+
+ it('scopes preview presentation and native composition suppression to touch ownership', () => {
+ expect(cssSource).toContain('.xterm-helpers .codeman-ime-preview {');
+ expect(cssSource).toContain(".xterm-helpers .codeman-ime-preview[data-phase='provisional'] {");
+ expect(cssSource).toContain('.touch-device .xterm-helpers.codeman-ime-preview-owned .composition-view.active {');
+ const rule = cssSource.slice(cssSource.indexOf('.xterm-helpers .codeman-ime-preview {'));
+ expect(rule.slice(0, rule.indexOf('}'))).toContain('left: var(--xterm-helper-left, 0px)');
+ expect(rule.slice(0, rule.indexOf('}'))).toContain('top: var(--xterm-helper-top, 0px)');
+ });
+
+ it('initializes the preview right after the terminal opens', () => {
+ expect(terminalSource).toMatch(/this\.terminal\.open\(container\);\s*this\._initMobileImePreview\(\);/);
+ });
+});
+
+describe('mobile IME preview lifecycle', () => {
+ it('fails open when the global is absent or create throws', () => {
+ for (const options of [{ omitGlobal: true }, { createThrows: true }]) {
+ const { app } = createPreviewHarness(options);
+ expect(() => app._initMobileImePreview()).not.toThrow();
+ expect(app._mobileImePreview).toBeNull();
+ }
+ });
+
+ it('does not create a controller on unsupported input platforms', () => {
+ const { app, mobileImePreview, previewNodes } = createPreviewHarness({ eligible: false });
+ app._initMobileImePreview();
+ expect(mobileImePreview?.create).not.toHaveBeenCalled();
+ expect(previewNodes).toHaveLength(0);
+ expect(app._mobileImePreview).toBeNull();
+ });
+
+ it('creates one controller bound to the terminal textarea and one hidden preview node', () => {
+ const { app, helpers, previewNodes, mobileImePreview } = createPreviewHarness();
+ app._initMobileImePreview();
+ expect(mobileImePreview?.create).toHaveBeenCalledOnce();
+ expect(mobileImePreview?.create.mock.calls[0][0].textarea).toBe(app.terminal.textarea);
+ // Keydown is observed on the terminal element (an ancestor of the
+ // textarea), so it runs before xterm's own capture listener finalizes.
+ expect(mobileImePreview?.create.mock.calls[0][0].keydownTarget).toBe(app.terminal.element);
+ expect(helpers.children).toEqual([previewNodes[0]]);
+ expect(previewNodes[0]).toMatchObject({ className: 'codeman-ime-preview', hidden: true });
+ expect(previewNodes[0].attributes['aria-hidden']).toBe('true');
+ });
+
+ it('destroys prior ownership on repeated initialization and keeps one active controller', () => {
+ const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
+ app._initMobileImePreview();
+ const first = createdControllers[0];
+ app._initMobileImePreview();
+ expect(first.destroy).toHaveBeenCalledOnce();
+ expect(previewNodes[0].remove).toHaveBeenCalledOnce();
+ expect(createdControllers).toHaveLength(2);
+ expect(app._mobileImePreview).toBe(createdControllers[1]);
+ // Window listeners are released with the controller that owned them.
+ expect((windowStub.removeEventListener as Fn).mock.calls.map((call) => call[0]).sort()).toEqual([
+ 'offline',
+ 'pagehide',
+ ]);
+ });
+
+ it('destroy releases the controller, the node and the listeners', () => {
+ const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
+ app._initMobileImePreview();
+ app._destroyMobileImePreview();
+ expect(createdControllers[0].destroy).toHaveBeenCalledOnce();
+ expect(previewNodes[0].remove).toHaveBeenCalledOnce();
+ expect(app._mobileImePreview).toBeNull();
+ expect(windowStub.removeEventListener).toHaveBeenCalledTimes(2);
+ });
+
+ it('pagehide resets the controller instead of destroying it, so a bfcache restore keeps the preview', () => {
+ const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness();
+ app._initMobileImePreview();
+ const pagehide = (windowStub.addEventListener as Fn).mock.calls.find((call) => call[0] === 'pagehide')?.[1];
+ expect(pagehide).toBeTypeOf('function');
+ pagehide();
+ expect(createdControllers[0].reset).toHaveBeenCalledOnce();
+ expect(createdControllers[0].destroy).not.toHaveBeenCalled();
+ expect(previewNodes[0].remove).not.toHaveBeenCalled();
+ expect(app._mobileImePreview).toBe(createdControllers[0]);
+ // A second hide after the page came back from the cache still works.
+ pagehide();
+ expect(createdControllers[0].reset).toHaveBeenCalledTimes(2);
+ });
+
+ it('resets the controller exactly once when the active session changes', () => {
+ const { app, createdControllers } = createPreviewHarness();
+ app._initMobileImePreview();
+ app.activeSessionId = 'session-b';
+ app.loadAppSettingsFromStorage = () => ({ localEchoEnabled: false });
+ app.sessions = new Map();
+ app._updateLocalEchoState();
+ app._updateLocalEchoState();
+ expect(createdControllers[0].reset).toHaveBeenCalledOnce();
+ });
+
+ it('renders and clears owned preview state', () => {
+ const { app, helpers, previewNodes, createdControllers } = createPreviewHarness();
+ app._initMobileImePreview();
+ const callbacks = createdControllers[0].callbacks;
+ callbacks.render({ text: '你好', phase: 'provisional' });
+ expect(previewNodes[0]).toMatchObject({ textContent: '你好', hidden: false, dataset: { phase: 'provisional' } });
+ expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(true);
+ callbacks.clear();
+ expect(previewNodes[0]).toMatchObject({ textContent: '', hidden: true, dataset: {} });
+ expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(false);
+ });
+
+ // With local echo on, typed text sits in the overlay (z-index 7) and the PTY
+ // cursor that places the span stays at the prompt start, under that text. The
+ // overlay draws the composition instead. Real-xterm proof of the covering:
+ // test/mobile-ime-preview.browser.test.ts.
+ function withOverlay(app: App, visible = true) {
+ const overlay = {
+ composition: '',
+ setComposition: vi.fn(function (this: { composition: string }, text: string) {
+ this.composition = text;
+ }),
+ state: { visible },
+ };
+ Object.assign(app, { _localEchoEnabled: true, _localEchoOverlay: overlay });
+ return overlay;
+ }
+
+ it('routes the preview into the local echo overlay when local echo is on', () => {
+ const { app, helpers, previewNodes, createdControllers } = createPreviewHarness();
+ const overlay = withOverlay(app);
+ app._initMobileImePreview();
+ const callbacks = createdControllers[0].callbacks;
+ callbacks.render({ text: '天気', phase: 'provisional' });
+ expect(overlay.setComposition).toHaveBeenLastCalledWith('天気');
+ expect(previewNodes[0]).toMatchObject({ textContent: '', hidden: true });
+ expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(true);
+ callbacks.clear();
+ expect(overlay.setComposition).toHaveBeenLastCalledWith('');
+ expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(false);
+ });
+
+ it('uses the span when the overlay cannot place the composition (no prompt found)', () => {
+ const { app, previewNodes, createdControllers } = createPreviewHarness();
+ const overlay = withOverlay(app, false);
+ app._initMobileImePreview();
+ createdControllers[0].callbacks.render({ text: '天気', phase: 'provisional' });
+ expect(overlay.composition).toBe('');
+ expect(previewNodes[0]).toMatchObject({ textContent: '天気', hidden: false });
+ });
+
+ it('uses the span, not the overlay, when local echo is off or handed back to PTY echo', () => {
+ const off = createPreviewHarness();
+ const offOverlay = withOverlay(off.app);
+ Object.assign(off.app, { _localEchoEnabled: false });
+ off.app._initMobileImePreview();
+ off.createdControllers[0].callbacks.render({ text: 'かな', phase: 'provisional' });
+ expect(offOverlay.setComposition).not.toHaveBeenCalled();
+ expect(off.previewNodes[0]).toMatchObject({ textContent: 'かな', hidden: false });
+
+ const passthrough = createPreviewHarness();
+ const passOverlay = withOverlay(passthrough.app);
+ Object.assign(passthrough.app, { _echoPassthroughSessions: new Set(['session-a']) });
+ passthrough.app._initMobileImePreview();
+ passthrough.createdControllers[0].callbacks.render({ text: 'かな', phase: 'provisional' });
+ expect(passOverlay.setComposition).not.toHaveBeenCalled();
+ expect(passthrough.previewNodes[0]).toMatchObject({ textContent: 'かな', hidden: false });
+ });
+
+ it('uses the terminal foreground and opaque background while mirroring native composition font metrics', () => {
+ const { app, compositionView, previewNodes, createdControllers } = createPreviewHarness({
+ themeForeground: '#1f2328',
+ themeBackground: '#fafafa',
+ });
+ app._initMobileImePreview();
+ createdControllers[0].callbacks.render({ text: '入力', phase: 'provisional' });
+ expect(previewNodes[0].style).toMatchObject({
+ fontFamily: compositionView.style.fontFamily,
+ fontSize: compositionView.style.fontSize,
+ fontWeight: compositionView.style.fontWeight,
+ fontStyle: compositionView.style.fontStyle,
+ lineHeight: compositionView.style.lineHeight,
+ height: compositionView.style.height,
+ color: '#1f2328',
+ backgroundColor: '#fafafa',
+ });
+ });
+
+ it('falls back to the current skin theme when the terminal options carry no theme', () => {
+ const { app, previewNodes, createdControllers } = createPreviewHarness();
+ app._initMobileImePreview();
+ createdControllers[0].callbacks.render({ text: '入力', phase: 'provisional' });
+ expect(previewNodes[0].style).toMatchObject({ color: '#334455', backgroundColor: '#223344' });
+ });
+
+ it('keeps rendering with a safe foreground when the theme getter throws', () => {
+ const { app, previewNodes, createdControllers } = createPreviewHarness({ themeGetterThrows: true });
+ app._initMobileImePreview();
+ expect(() => createdControllers[0].callbacks.render({ text: '安全', phase: 'provisional' })).not.toThrow();
+ expect(previewNodes[0].textContent).toBe('安全');
+ expect(previewNodes[0].style.color).toBe('#e0e0e0');
+ expect(previewNodes[0].style.backgroundColor).toBe('#0d0d0d');
+ });
+
+ it.each(['query', 'create', 'append', 'className', 'hidden'] as const)(
+ 'removes partial DOM ownership when %s fails',
+ (failure) => {
+ const removed = vi.fn();
+ const owner = fakeClassList();
+ const preview = new Proxy(
+ { dataset: {}, remove: removed, setAttribute: vi.fn() },
+ {
+ set(target, property, value) {
+ if (property === failure) throw new Error(`${failure} failed`);
+ return Reflect.set(target, property, value);
+ },
+ }
+ );
+ const helpers = {
+ classList: owner,
+ appendChild:
+ failure === 'append'
+ ? () => {
+ throw new Error('append failed');
+ }
+ : vi.fn(),
+ };
+ const documentStub = {
+ addEventListener: vi.fn(),
+ createElement:
+ failure === 'create'
+ ? () => {
+ throw new Error('create failed');
+ }
+ : () => preview,
+ };
+ const MobileImePreview = { isIosWebKitTouch: () => true, create: vi.fn() };
+ const { mixin } = loadMixin({ document: documentStub, MobileImePreview });
+ const app: App = Object.assign(Object.create(mixin), {
+ terminal: {
+ textarea: {},
+ element: {
+ querySelector:
+ failure === 'query'
+ ? () => {
+ throw new Error('query failed');
+ }
+ : () => helpers,
+ },
+ },
+ });
+ expect(() => app._initMobileImePreview()).not.toThrow();
+ expect(app._mobileImePreview).toBeNull();
+ expect(MobileImePreview.create).not.toHaveBeenCalled();
+ expect(owner.contains('codeman-ime-preview-owned')).toBe(false);
+ if (!['query', 'create'].includes(failure)) expect(removed).toHaveBeenCalled();
+ }
+ );
+});
+
+/**
+ * Rebuilds the real `handleTerminalData` closure from initTerminal's source,
+ * so these cases exercise the shipped routing rather than a copy of it.
+ */
+function loadHandleTerminalData(app: App, sent: string[]) {
+ const marker = 'const handleTerminalData = (data) => {';
+ const start = terminalSource.indexOf(marker);
+ if (start < 0) throw new Error('handleTerminalData definition not found');
+ const bodyStart = start + marker.length;
+ const end = terminalSource.indexOf('\n };', bodyStart);
+ if (end < 0) throw new Error('handleTerminalData boundary not found');
+ const timers: Array<() => void> = [];
+ app._sendInputAsync = (_sessionId: string, data: string) => sent.push(data);
+ const context = vm.createContext({
+ console,
+ performance,
+ setTimeout: (callback: () => void) => {
+ timers.push(callback);
+ return timers.length;
+ },
+ clearTimeout: vi.fn(),
+ document: { activeElement: null, getElementById: vi.fn(() => null) },
+ window: {
+ cjkActive: false,
+ CodemanTerminalInput: {
+ BRACKETED_PASTE_START: '\x1b[200~',
+ shouldSuppressTerminalQueryResponse: () => false,
+ isTerminalFocusOrMouseReport: () => false,
+ isComposerNavKey: () => false,
+ },
+ },
+ _crashDiag: { log: vi.fn() },
+ flushInput: () => {
+ app._inputFlushTimeout = null;
+ if (app._pendingInput && app.activeSessionId) {
+ const input = app._pendingInput;
+ app._pendingInput = '';
+ app._sendInputAsync(app.activeSessionId, input);
+ }
+ },
+ });
+ const handler = vm.runInContext(`(function (data) {${terminalSource.slice(bodyStart, end)}\n})`, context) as (
+ this: App,
+ data: string
+ ) => void;
+ return { handle: (data: string) => handler.call(app, data), timers };
+}
+
+describe('mobile IME commit onData routing', () => {
+ function onDataApp(options: {
+ localEcho: boolean;
+ tagged?: boolean;
+ overlayMissing?: boolean;
+ addThrows?: boolean;
+ appendThrows?: boolean;
+ consumeThrows?: boolean;
+ }) {
+ const { mixin } = loadMixin();
+ const sent: string[] = [];
+ const controller = {
+ consumeTerminalData: options.consumeThrows
+ ? vi.fn(() => {
+ throw new Error('consume failed');
+ })
+ : vi
+ .fn()
+ .mockReturnValueOnce(options.tagged ?? true)
+ .mockReturnValue(false),
+ completeCommit: vi.fn(),
+ noteAuthoritativeOutput: vi.fn(),
+ };
+ const overlay = {
+ pendingText: '',
+ appendText: vi.fn((data: string) => {
+ if (options.appendThrows) throw new Error('overlay failed');
+ overlay.pendingText += data;
+ }),
+ addChar: vi.fn((data: string) => {
+ if (options.addThrows) throw new Error('overlay failed');
+ overlay.pendingText += data;
+ }),
+ clear: vi.fn(() => {
+ overlay.pendingText = '';
+ }),
+ suppressBufferDetection: vi.fn(),
+ };
+ const app: App = Object.assign(Object.create(mixin), {
+ activeSessionId: 'session-a',
+ _localEchoEnabled: options.localEcho,
+ _localEchoOverlay: options.overlayMissing ? null : overlay,
+ _echoPassthroughSessions: new Set(),
+ _flushedOffsets: new Map(),
+ _flushedTexts: new Map(),
+ _pendingInput: '',
+ _inputFlushTimeout: null,
+ _lastKeystrokeTime: 0,
+ _terminalOutputSeq: 5,
+ _mobileImePreview: controller,
+ });
+ return { app, controller, overlay, sent, ...loadHandleTerminalData(app, sent) };
+ }
+
+ it('moves a multi-character commit into local echo and submits it only on Enter', () => {
+ const { app, controller, overlay, sent, handle, timers } = onDataApp({ localEcho: true });
+ handle('你好');
+ expect(controller.consumeTerminalData).toHaveBeenCalledOnce();
+ expect(overlay.appendText).toHaveBeenCalledWith('你好');
+ expect(controller.completeCommit).toHaveBeenCalledWith({ predicted: true });
+ expect(app._mobileImeCommitOutputSeq).toBeNull();
+ expect(sent).toEqual([]);
+
+ handle('\r');
+ expect(sent).toEqual(['你好']);
+ timers.shift()?.();
+ expect(sent).toEqual(['你好', '\r']);
+ });
+
+ it('moves a single-character commit into local echo through addChar', () => {
+ const { controller, overlay, sent, handle } = onDataApp({ localEcho: true });
+ handle('界');
+ expect(overlay.addChar).toHaveBeenCalledWith('界');
+ expect(overlay.appendText).not.toHaveBeenCalled();
+ expect(controller.completeCommit).toHaveBeenCalledWith({ predicted: true });
+ expect(sent).toEqual([]);
+ });
+
+ it.each([
+ ['appendText throws', { appendThrows: true }, '失敗'],
+ ['addChar throws', { addThrows: true }, '字'],
+ ])('never sends a commit the overlay may already hold when %s', (_label, extra, text) => {
+ // One code path with typed text: no send-on-throw fallback, which would
+ // double-send if the overlay threw after appending.
+ const { controller, sent, handle } = onDataApp({ localEcho: true, ...extra });
+ expect(() => handle(text)).toThrow('overlay failed');
+ expect(sent).toEqual([]);
+ // Nothing shows the text, so the preview keeps it until its fallback.
+ expect(controller.completeCommit).not.toHaveBeenCalled();
+ });
+
+ it('keeps the preview when the overlay is missing, exactly like typed text', () => {
+ const { controller, sent, handle } = onDataApp({ localEcho: true, overlayMissing: true });
+ expect(() => handle('日本')).not.toThrow();
+ expect(sent).toEqual([]);
+ expect(controller.completeCommit).not.toHaveBeenCalled();
+ });
+
+ it('completes the commit only after the printable branch has put it in the overlay', () => {
+ const { controller, overlay, handle } = onDataApp({ localEcho: true });
+ controller.completeCommit.mockImplementation(() => {
+ expect(overlay.pendingText).toBe('界');
+ });
+ handle('界');
+ expect(controller.completeCommit).toHaveBeenCalledOnce();
+ });
+
+ it('keeps an untagged paste on the existing local echo path', () => {
+ const { controller, overlay, sent, handle } = onDataApp({ localEcho: true, tagged: false });
+ handle('plain paste');
+ expect(overlay.pendingText).toBe('plain paste');
+ expect(controller.completeCommit).not.toHaveBeenCalled();
+ expect(sent).toEqual([]);
+ });
+
+ it('sends a commit once without local echo and holds the preview until output arrives', () => {
+ const { app, controller, sent, handle } = onDataApp({ localEcho: false });
+ handle('日本語');
+ expect(controller.consumeTerminalData).toHaveBeenCalledOnce();
+ expect(sent).toEqual(['日本語']);
+ expect(controller.completeCommit).not.toHaveBeenCalled();
+ expect(app._mobileImeCommitOutputSeq).toBe(5);
+ });
+
+ it('sends the original bytes exactly once when the controller throws', () => {
+ const { sent, handle } = onDataApp({ localEcho: false, consumeThrows: true });
+ expect(() => handle('你好')).not.toThrow();
+ expect(sent).toEqual(['你好']);
+ });
+});
+
+describe('mobile IME commit and authoritative terminal output', () => {
+ function outputHarness(mode = 'claude') {
+ const { mixin } = loadMixin();
+ const parses: Array<() => void> = [];
+ const written: string[] = [];
+ const controller = {
+ consumeTerminalData: vi.fn(() => true),
+ noteAuthoritativeOutput: vi.fn(),
+ };
+ const app: App = Object.assign(Object.create(mixin), {
+ pendingWrites: [],
+ terminal: {
+ rows: 24,
+ write: vi.fn((data: string, callback?: () => void) => {
+ written.push(data);
+ if (callback) parses.push(callback);
+ }),
+ buffer: { active: { viewportY: 0 } },
+ scrollToBottom: vi.fn(),
+ },
+ activeSessionId: 'session-a',
+ sessions: new Map([['session-a', { mode }]]),
+ isTerminalAtBottom: () => true,
+ _hasRecentUserScrollUp: () => false,
+ _safeYield: vi.fn(),
+ _localEchoOverlay: null,
+ _mobileImePreview: controller,
+ });
+ const flush = () => {
+ app.writeFrameScheduled = false;
+ app.flushPendingWrites();
+ };
+ const parseNext = () => parses.shift()?.();
+ return { app, controller, written, flush, parseNext };
+ }
+
+ it('clears the committed preview once output accepted after the commit is parsed', () => {
+ const { app, controller, flush, parseNext } = outputHarness();
+ app._consumeMobileImeTerminalData('你好');
+ app.batchTerminalWrite('echo');
+ flush();
+ expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
+ parseNext();
+ expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
+ });
+
+ it('does not let output queued before the commit clear it, even when it parses after', () => {
+ const { app, controller, flush, parseNext } = outputHarness();
+ app.batchTerminalWrite('before');
+ flush();
+ app._consumeMobileImeTerminalData('你好');
+ parseNext();
+ expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
+
+ app.batchTerminalWrite('after');
+ flush();
+ parseNext();
+ expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
+ });
+
+ it('does not let a split chunk clear the commit before its remainder is written', () => {
+ const { app, controller, written, flush, parseNext } = outputHarness('codex');
+ app._consumeMobileImeTerminalData('你好');
+ app.batchTerminalWrite('x'.repeat(40000));
+ flush();
+ parseNext();
+ expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
+ flush();
+ parseNext();
+ expect(written.join('')).toBe('x'.repeat(40000));
+ expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
+ });
+
+ it('does not let output parsed after a session switch clear the new session preview', () => {
+ const { app, controller, flush, parseNext } = outputHarness();
+ app._consumeMobileImeTerminalData('你好');
+ app.batchTerminalWrite('echo');
+ flush();
+ app.activeSessionId = 'session-b';
+ parseNext();
+ expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
+ });
+
+ it('notifies once per commit, never for later output', () => {
+ const { app, controller, flush, parseNext } = outputHarness();
+ app._consumeMobileImeTerminalData('你好');
+ for (const chunk of ['a', 'b']) {
+ app.batchTerminalWrite(chunk);
+ flush();
+ parseNext();
+ }
+ expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
+ });
+});
diff --git a/test/mobile-ime-preview.browser.test.ts b/test/mobile-ime-preview.browser.test.ts
new file mode 100644
index 000000000..864683adf
--- /dev/null
+++ b/test/mobile-ime-preview.browser.test.ts
@@ -0,0 +1,292 @@
+/**
+ * The iOS IME preview controller against a REAL xterm 6 instance.
+ *
+ * The controller's logic is unit-tested in test/mobile-ime-preview.test.ts
+ * with a stand-in for xterm. What only real xterm proves is the event ORDER:
+ * `terminal.open()` registers xterm's keydown listener in the capture phase on
+ * the helper textarea, and CompositionHelper.keydown finalizes a composition
+ * there and emits the commit through onData synchronously. The controller must
+ * observe that keydown first (capture phase on `terminal.element`), and must
+ * finalize on exactly the keys xterm does.
+ *
+ * No server: a blank page loads the vendored xterm bundle and the controller.
+ * Browser-driven, so it is excluded from `npm run test:ci` like the other
+ * Playwright suites. Run locally:
+ * npm run test:browser -- test/mobile-ime-preview.browser.test.ts
+ */
+
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { build } from 'esbuild';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { chromium, type Browser, type Page } from 'playwright';
+
+const root = resolve(import.meta.dirname, '..');
+
+type Step =
+ | ['start']
+ | ['update', string]
+ | ['end', string]
+ | ['key', number, string, boolean]
+ | ['wait', number]
+ | ['consume', string];
+
+describe('mobile IME preview with real xterm', () => {
+ let browser: Browser;
+ let page: Page;
+
+ beforeAll(async () => {
+ browser = await chromium.launch({ headless: true });
+ page = await browser.newPage();
+ await page.setContent('');
+ await page.addScriptTag({ path: resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js') });
+ await page.addScriptTag({ path: resolve(root, 'src/web/public/mobile-ime-preview.js') });
+ }, 60000);
+
+ afterAll(async () => {
+ if (browser) await browser.close();
+ });
+
+ async function drive(steps: Step[]) {
+ return page.evaluate(async (steps: Step[]) => {
+ const w = window as any;
+ const host = document.getElementById('t') as HTMLElement;
+ host.innerHTML = '';
+ const term = new w.Terminal();
+ term.open(host);
+ const textarea = term.textarea as HTMLTextAreaElement;
+ const renders: Array<{ text: string; phase: string }> = [];
+ const onData: Array<{ data: string; consumed: boolean }> = [];
+ const controller = w.MobileImePreview.create({
+ textarea,
+ keydownTarget: term.element,
+ render: (r: { text: string; phase: string }) => renders.push(r),
+ clear: () => {},
+ });
+ term.onData((data: string) => onData.push({ data, consumed: controller.consumeTerminalData(data) }));
+ textarea.focus();
+ const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
+ for (const step of steps) {
+ if (step[0] === 'start') textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
+ if (step[0] === 'update') {
+ textarea.value = step[1];
+ textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: step[1] }));
+ }
+ if (step[0] === 'end') textarea.dispatchEvent(new CompositionEvent('compositionend', { data: step[1] }));
+ if (step[0] === 'key') {
+ const [, keyCode, key, isComposing] = step;
+ const event = new KeyboardEvent('keydown', { key, isComposing, bubbles: true, cancelable: true });
+ Object.defineProperty(event, 'keyCode', { get: () => keyCode });
+ textarea.dispatchEvent(event);
+ }
+ if (step[0] === 'wait') await tick(step[1]);
+ if (step[0] === 'consume') onData.push({ data: step[1], consumed: controller.consumeTerminalData(step[1]) });
+ }
+ await tick(20);
+ const { composing, awaitingCommit, committed, latest } = controller.state;
+ const result = { onData, state: { composing, awaitingCommit, committed, latest }, lastRender: renders.at(-1) };
+ controller.destroy();
+ term.dispose();
+ return result;
+ }, steps);
+ }
+
+ it('Enter mid-composition: xterm emits the commit and the controller takes it as committed', async () => {
+ // compositionupdate's textarea end offset is recorded by xterm on a 0 ms timer.
+ const result = await drive([['start'], ['update', '確定'], ['wait', 10], ['key', 13, 'Enter', false]]);
+ expect(result.onData).toEqual([
+ { data: '確定', consumed: true },
+ { data: '\r', consumed: false },
+ ]);
+ expect(result.state).toMatchObject({ awaitingCommit: false, committed: true, latest: '確定' });
+ expect(result.lastRender).toEqual({ text: '確定', phase: 'committed' });
+ });
+
+ it('keyCode 229 with isComposing false: xterm keeps composing, so the preview keeps following', async () => {
+ const result = await drive([
+ ['start'],
+ ['update', 'か'],
+ ['wait', 10],
+ ['key', 229, 'k', false],
+ ['update', 'かな'],
+ ]);
+ expect(result.onData).toEqual([]);
+ expect(result.state).toMatchObject({ composing: true, awaitingCommit: false, latest: 'かな' });
+ expect(result.lastRender).toEqual({ text: 'かな', phase: 'provisional' });
+ });
+
+ it('a deleted composition stops waiting after 2 s, so a later paste is not taken as its commit', async () => {
+ const result = await drive([
+ ['start'],
+ ['update', 'abc'],
+ ['update', ''],
+ ['end', ''],
+ ['wait', 2100],
+ ['consume', 'pasted'],
+ ]);
+ expect(result.onData).toEqual([{ data: 'pasted', consumed: false }]);
+ expect(result.state).toMatchObject({ awaitingCommit: false, committed: false });
+ });
+});
+
+/**
+ * The preview with local echo ON, the default for Claude sessions on phones.
+ * Committed text then sits in the LocalEchoOverlay (a z-index 7 layer in
+ * `.xterm-screen`) and never reaches the PTY before Enter, so the PTY cursor,
+ * which is where the helper span sits, stays at the prompt start: under the
+ * overlay's own opaque text. So a composition that follows text already in the
+ * overlay must be drawn by the overlay itself, after that text.
+ *
+ * Loads the real pieces: xterm 6, the overlay bundled from its package source
+ * exactly as scripts/postinstall.js bundles it (plus the same LocalEchoOverlay
+ * alias), styles.css, mobile-ime-preview.js, and terminal-ui.js's own
+ * `_initMobileImePreview` on a bare CodemanApp prototype.
+ */
+describe('mobile IME preview over the local echo overlay', () => {
+ let browser: Browser;
+ let page: Page;
+
+ beforeAll(async () => {
+ const bundled = await build({
+ entryPoints: [resolve(root, 'packages/xterm-zerolag-input/src/zerolag-input-addon.ts')],
+ bundle: true,
+ format: 'iife',
+ globalName: 'XtermZerolagInput',
+ write: false,
+ logLevel: 'silent',
+ });
+ const overlayBundle =
+ bundled.outputFiles[0].text +
+ '\nwindow.ZerolagInputAddon=XtermZerolagInput.ZerolagInputAddon;' +
+ 'window.LocalEchoOverlay=class extends XtermZerolagInput.ZerolagInputAddon{' +
+ 'constructor(terminal){super({prompt:{type:"character",char:"\\u276f",offset:2}});this.activate(terminal);}};\n';
+
+ browser = await chromium.launch({ headless: true });
+ page = await browser.newPage({ viewport: { width: 800, height: 400 }, deviceScaleFactor: 1 });
+ await page.setContent(
+ ''
+ );
+ await page.addStyleTag({ path: resolve(root, 'node_modules/@xterm/xterm/css/xterm.css') });
+ await page.addStyleTag({ content: readFileSync(resolve(root, 'src/web/public/styles.css'), 'utf8') });
+ await page.addScriptTag({ path: resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js') });
+ await page.addScriptTag({ content: overlayBundle });
+ await page.addScriptTag({ path: resolve(root, 'src/web/public/mobile-ime-preview.js') });
+ await page.addScriptTag({ content: 'window.CodemanApp = class CodemanApp {};' });
+ await page.addScriptTag({ path: resolve(root, 'src/web/public/terminal-ui.js') });
+ }, 60000);
+
+ afterAll(async () => {
+ if (browser) await browser.close();
+ });
+
+ /**
+ * Types `pending` into the overlay (as the printable/paste branch does), then
+ * composes `composing` and reports what is PAINTED at the cell right after
+ * the pending text and at the PTY cursor. Painted = topmost by hit-testing
+ * with pointer-events forced on, since the overlay and the preview are
+ * pointer-events:none.
+ */
+ async function composeAfter(pending: string, composing: string, commit: boolean) {
+ return page.evaluate(
+ async ({ pending, composing, commit }) => {
+ const w = window as any;
+ const host = document.getElementById('t') as HTMLElement;
+ host.innerHTML = '';
+ const term = new w.Terminal({
+ cols: 40,
+ rows: 8,
+ fontSize: 14,
+ fontFamily: 'monospace',
+ allowProposedApi: true,
+ });
+ term.open(host);
+ await new Promise((r) => term.write('\u276f ', () => r()));
+ const app = new w.CodemanApp();
+ app.terminal = term;
+ app._localEchoEnabled = true;
+ app._localEchoOverlay = new w.LocalEchoOverlay(term);
+ w.MobileImePreview.isIosWebKitTouch = () => true;
+ app._initMobileImePreview();
+
+ // The helper textarea and span follow the PTY cursor (col 2, row 0), as
+ // _syncMobileHelperTextareaToCursor places them.
+ const screen = term.element.querySelector('.xterm-screen') as HTMLElement;
+ const dims = term._core._renderService.dimensions.css.cell;
+ term.element.style.setProperty('--xterm-helper-left', 2 * dims.width + 'px');
+ term.element.style.setProperty('--xterm-helper-top', '0px');
+
+ if (pending) app._localEchoOverlay.appendText(pending);
+ const textarea = term.textarea as HTMLTextAreaElement;
+ textarea.focus();
+ textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' }));
+ textarea.value = composing;
+ textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: composing }));
+ await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
+
+ const force = document.createElement('style');
+ force.textContent = '.xterm * { pointer-events: auto !important; }';
+ document.head.appendChild(force);
+ const rect = screen.getBoundingClientRect();
+ const widthOf = (s: string) => term._core.unicodeService.getStringCellWidth(s);
+ const paintedAt = (col: number) => {
+ const el = document.elementFromPoint(
+ rect.left + (col + 0.5) * dims.width,
+ rect.top + 0.5 * dims.height
+ ) as HTMLElement | null;
+ return {
+ text: el?.textContent ?? null,
+ composition: !!el?.closest?.('[data-zerolag-composition]'),
+ preview: !!el?.closest?.('.codeman-ime-preview'),
+ };
+ };
+ const afterPending = paintedAt(2 + widthOf(pending));
+ force.remove();
+
+ let afterCommit = null;
+ if (commit) {
+ textarea.dispatchEvent(new CompositionEvent('compositionend', { data: composing }));
+ // What the printable/paste branch of terminal-ui.js's onData does.
+ if (app._consumeMobileImeTerminalData(composing)) {
+ app._localEchoOverlay.appendText(composing);
+ app._transferMobileImeCommitToLocalEcho();
+ }
+ await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20)));
+ afterCommit = {
+ pendingText: app._localEchoOverlay.pendingText,
+ compositionSpans: term.element.querySelectorAll('[data-zerolag-composition]').length,
+ overlayText: app._localEchoOverlay._overlay?.textContent,
+ };
+ }
+ const result = {
+ afterPending,
+ pendingText: app._localEchoOverlay.pendingText,
+ afterCommit,
+ };
+ app._destroyMobileImePreview();
+ app._localEchoOverlay.dispose();
+ term.dispose();
+ return result;
+ },
+ { pending, composing, commit }
+ );
+ }
+
+ it('first composition on an empty prompt: the overlay draws it at the prompt', async () => {
+ const result = await composeAfter('', '今日は', false);
+ expect(result.afterPending).toEqual({ text: '今', composition: true, preview: false });
+ expect(result.pendingText).toBe('');
+ });
+
+ it('a second composition is painted after the text already in the overlay, not under it', async () => {
+ const result = await composeAfter('今日は', '天気', false);
+ expect(result.afterPending.text).toBe('天');
+ expect(result.afterPending.composition).toBe(true);
+ // Provisional text is never taken into the overlay's pending (unsent) text.
+ expect(result.pendingText).toBe('今日は');
+ });
+
+ it('the commit lands once in the overlay and the composition tail is gone', async () => {
+ const result = await composeAfter('今日は', '天気', true);
+ expect(result.afterCommit).toEqual({ pendingText: '今日は天気', compositionSpans: 0, overlayText: '今日は天気' });
+ });
+});
diff --git a/test/mobile-ime-preview.test.ts b/test/mobile-ime-preview.test.ts
new file mode 100644
index 000000000..cfdec9a3e
--- /dev/null
+++ b/test/mobile-ime-preview.test.ts
@@ -0,0 +1,672 @@
+import { readFileSync } from 'node:fs';
+import vm from 'node:vm';
+
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+type Listener = (event: Record) => void;
+type ListenerOptions = boolean | { capture?: boolean };
+type RegisteredListener = { listener: Listener; capture: boolean };
+
+/**
+ * A DOM node with just enough event dispatch to reproduce listener ORDER: an
+ * ancestor's capture listeners, then the target's capture listeners, then the
+ * target's bubble listeners (at-target capture-first, as in Chromium 89+ and
+ * WebKit), then the ancestor's bubble listeners.
+ */
+class FakeNode {
+ parent: FakeNode | null = null;
+ protected listeners = new Map();
+
+ addEventListener(type: string, listener: Listener, options?: ListenerOptions) {
+ const listeners = this.listeners.get(type) ?? [];
+ listeners.push({ listener, capture: options === true || options?.capture === true });
+ this.listeners.set(type, listeners);
+ }
+
+ removeEventListener(type: string, listener: Listener, options?: ListenerOptions) {
+ const capture = options === true || options?.capture === true;
+ const listeners = this.listeners.get(type) ?? [];
+ const index = listeners.findIndex(
+ (registered) => registered.listener === listener && registered.capture === capture
+ );
+ if (index >= 0) listeners.splice(index, 1);
+ }
+
+ run(type: string, capture: boolean, event: Record) {
+ for (const registered of [...(this.listeners.get(type) ?? [])]) {
+ if (registered.capture === capture) registered.listener(event);
+ }
+ }
+
+ dispatch(type: string, event: Record = {}) {
+ const full = { type, target: this, ...event };
+ const ancestors: FakeNode[] = [];
+ for (let node = this.parent; node; node = node.parent) ancestors.unshift(node);
+ for (const ancestor of ancestors) ancestor.run(type, true, full);
+ this.run(type, true, full);
+ this.run(type, false, full);
+ for (const ancestor of [...ancestors].reverse()) ancestor.run(type, false, full);
+ }
+
+ listenerCount() {
+ return [...this.listeners.values()].reduce((total, listeners) => total + listeners.length, 0);
+ }
+}
+
+class FakeTextarea extends FakeNode {
+ value = 'unchanged';
+}
+
+type Scheduled = { id: number; callback: () => void; delay?: number };
+
+function harness(
+ overrides: Record = {},
+ beforeCreate?: (textarea: FakeTextarea, getController: () => Record | undefined) => void
+) {
+ const source = readFileSync(new URL('../src/web/public/mobile-ime-preview.js', import.meta.url), 'utf8');
+ const context = vm.createContext({ navigator: {} });
+ vm.runInContext(source, context, { filename: 'mobile-ime-preview.js' });
+ const api = vm.runInContext('MobileImePreview', context);
+ // The terminal element: an ancestor of the helper textarea, like xterm's
+ // `.xterm` root is of `.xterm-helper-textarea`.
+ const element = new FakeNode();
+ const textarea = new FakeTextarea();
+ textarea.parent = element;
+ const frames: Scheduled[] = [];
+ const timers: Scheduled[] = [];
+ let nextId = 1;
+ const render = vi.fn();
+ const clear = vi.fn();
+ const onCommit = vi.fn();
+ const scheduleFrame = vi.fn((callback: () => void) => {
+ const id = nextId++;
+ frames.push({ id, callback });
+ return id;
+ });
+ const cancelFrame = vi.fn((id: number) => {
+ const index = frames.findIndex((frame) => frame.id === id);
+ if (index >= 0) frames.splice(index, 1);
+ });
+ const setTimer = vi.fn((callback: () => void, delay: number) => {
+ const id = nextId++;
+ timers.push({ id, callback, delay });
+ return id;
+ });
+ const clearTimer = vi.fn((id: number) => {
+ const index = timers.findIndex((timer) => timer.id === id);
+ if (index >= 0) timers.splice(index, 1);
+ });
+ let controller: Record | undefined;
+ beforeCreate?.(textarea, () => controller);
+ controller = api.create({
+ textarea,
+ keydownTarget: element,
+ render,
+ clear,
+ onCommit,
+ scheduleFrame,
+ cancelFrame,
+ setTimer,
+ clearTimer,
+ ...overrides,
+ });
+ const flushFrame = () => frames.shift()?.callback();
+ const flushTimer = () => timers.shift()?.callback();
+
+ return {
+ api,
+ element,
+ textarea,
+ frames,
+ timers,
+ render,
+ clear,
+ onCommit,
+ scheduleFrame,
+ cancelFrame,
+ setTimer,
+ clearTimer,
+ controller,
+ flushFrame,
+ flushTimer,
+ };
+}
+
+describe('MobileImePreview', () => {
+ beforeEach(() => vi.restoreAllMocks());
+
+ test('collapses 500 composition updates into one latest-state frame', () => {
+ const h = harness();
+ h.textarea.dispatch('compositionstart');
+ for (let i = 0; i < 500; i += 1) h.textarea.dispatch('compositionupdate', { data: `value-${i}` });
+
+ expect(h.scheduleFrame).toHaveBeenCalledTimes(1);
+ expect(h.render).not.toHaveBeenCalled();
+ h.flushFrame();
+ expect(h.render).toHaveBeenCalledOnce();
+ expect(h.render).toHaveBeenLastCalledWith({ text: 'value-499', phase: 'provisional' });
+ expect(h.onCommit).not.toHaveBeenCalled();
+ expect(h.textarea.value).toBe('unchanged');
+ });
+
+ test('replaces provisional text for replacement, backspace, and composing input', () => {
+ const h = harness();
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionupdate', { data: 'abcdef' });
+ h.textarea.dispatch('compositionupdate', { data: 'xy' });
+ h.flushFrame();
+ expect(h.render).toHaveBeenLastCalledWith({ text: 'xy', phase: 'provisional' });
+
+ h.textarea.dispatch('input', { data: '', isComposing: true });
+ h.flushFrame();
+ expect(h.render).toHaveBeenLastCalledWith({ text: '', phase: 'provisional' });
+ expect(h.onCommit).not.toHaveBeenCalled();
+ });
+
+ test('caps the preview without changing the terminal handoff value', () => {
+ const h = harness();
+ const value = '界'.repeat(2050);
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionupdate', { data: value });
+ h.flushFrame();
+ expect(h.render.mock.calls[0][0].text).toHaveLength(2048);
+ h.textarea.dispatch('compositionend', { data: value });
+ expect(h.controller.consumeTerminalData(value)).toBe(true);
+ expect(h.onCommit).toHaveBeenCalledWith(value);
+ });
+
+ test('hands off only the first safe xterm onData value after finalization', () => {
+ const h = harness();
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionupdate', { data: '日本語' });
+ h.textarea.dispatch('compositionend', { data: '日本語' });
+
+ expect(h.controller.consumeTerminalData('日本語')).toBe(true);
+ expect(h.controller.consumeTerminalData('日本語')).toBe(false);
+ expect(h.onCommit).toHaveBeenCalledOnce();
+ expect(h.render).not.toHaveBeenCalled();
+ expect(h.frames).toHaveLength(1);
+ h.flushFrame();
+ expect(h.render).toHaveBeenLastCalledWith({ text: '日本語', phase: 'committed' });
+ expect(h.setTimer).toHaveBeenCalledWith(expect.any(Function), 2000);
+ });
+
+ test('defers finalization rendering and updates the queued frame phase to committed', () => {
+ const h = harness();
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionupdate', { data: 'draft' });
+ h.textarea.dispatch('compositionend');
+ expect(h.render).not.toHaveBeenCalled();
+ expect(h.frames).toHaveLength(1);
+
+ expect(h.controller.consumeTerminalData('final')).toBe(true);
+ expect(h.frames).toHaveLength(1);
+ expect(h.render).not.toHaveBeenCalled();
+ h.flushFrame();
+ expect(h.render).toHaveBeenCalledOnce();
+ expect(h.render).toHaveBeenCalledWith({ text: 'final', phase: 'committed' });
+ });
+
+ test('treats the first safe xterm onData value as authoritative over stale provisional data', () => {
+ const h = harness();
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionupdate', { data: 'teh' });
+ h.textarea.dispatch('compositionend');
+
+ expect(h.controller.consumeTerminalData('the')).toBe(true);
+ expect(h.onCommit).toHaveBeenCalledOnce();
+ expect(h.onCommit).toHaveBeenCalledWith('the');
+ expect(h.controller.consumeTerminalData('teh')).toBe(false);
+ });
+
+ test.each(['', '\n', 'line\rbreak', 'two\nlines', '\u0003', '\u007f'])(
+ 'rejects non-printable or multiline terminal data %j without consuming the pending value',
+ (rejected) => {
+ const h = harness();
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionend', { data: rejected });
+ expect(h.controller.consumeTerminalData(rejected)).toBe(false);
+ expect(h.onCommit).not.toHaveBeenCalled();
+ }
+ );
+
+ test.each(['line\u2028break', 'line\u2029break'])(
+ 'rejects Unicode line separator terminal data %j without consuming the finalization fence',
+ (rejected) => {
+ const h = harness();
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionend');
+ expect(h.controller.consumeTerminalData(rejected)).toBe(false);
+ expect(h.controller.consumeTerminalData('safe')).toBe(true);
+ }
+ );
+
+ test('keydown can finalize composition before a late compositionend', () => {
+ const h = harness();
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionupdate', { data: '確定' });
+ h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
+ expect(h.controller.consumeTerminalData('確定')).toBe(true);
+ h.textarea.dispatch('compositionend', { data: 'stale' });
+ expect(h.controller.consumeTerminalData('stale')).toBe(false);
+ });
+
+ /**
+ * Stand-in for xterm 6.0: `terminal.open()` registers a CAPTURE keydown
+ * listener on the helper textarea (CoreBrowserTerminal.ts:379), and
+ * CompositionHelper.keydown (CompositionHelper.ts:94-108) finalizes the
+ * composition there, emitting the commit through onData synchronously, for
+ * every keyCode except 20/229 and 16/17/18. It is registered BEFORE the
+ * controller is created, exactly as terminal.open() precedes
+ * _initMobileImePreview().
+ */
+ function withXtermStandIn() {
+ const emitted: Array<{ data: string; consumed: boolean }> = [];
+ let composing = false;
+ let composition = '';
+ const h = harness({}, (textarea, getController) => {
+ const emit = (data: string) => emitted.push({ data, consumed: getController()?.consumeTerminalData(data) });
+ textarea.addEventListener('compositionstart', () => {
+ composing = true;
+ composition = '';
+ });
+ textarea.addEventListener('compositionupdate', (event) => {
+ composition = String(event.data ?? '');
+ });
+ textarea.addEventListener(
+ 'keydown',
+ (event) => {
+ if (composing && ![20, 229, 16, 17, 18].includes(event.keyCode as number)) {
+ composing = false;
+ emit(composition);
+ }
+ if (event.keyCode === 13) emit('\r');
+ },
+ true
+ );
+ });
+ return { ...h, emitted, isXtermComposing: () => composing };
+ }
+
+ test('Enter mid-composition hands the commit xterm emits in its capture keydown to the preview', () => {
+ const h = withXtermStandIn();
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionupdate', { data: '確定' });
+ h.flushFrame();
+ h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
+
+ expect(h.emitted).toEqual([
+ { data: '確定', consumed: true },
+ { data: '\r', consumed: false },
+ ]);
+ expect(h.onCommit).toHaveBeenCalledWith('確定');
+ expect(h.controller.state).toMatchObject({ composing: false, awaitingCommit: false, committed: true });
+ h.flushFrame();
+ expect(h.render).toHaveBeenLastCalledWith({ text: '確定', phase: 'committed' });
+
+ // The next unrelated keystroke is ordinary input, not an IME commit.
+ expect(h.controller.consumeTerminalData('x')).toBe(false);
+ });
+
+ test.each([
+ // keyCode 229 with isComposing:false and a real key identity: xterm keeps
+ // composing, so the controller must too.
+ ['the IME composition character', 'k', 229],
+ ['CapsLock', 'CapsLock', 20],
+ ['Shift', 'Shift', 16],
+ ['Control', 'Control', 17],
+ ['Alt', 'Alt', 18],
+ ])('a keydown for %s keeps tracking the composition xterm is still composing', (_label, key, keyCode) => {
+ const h = withXtermStandIn();
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionupdate', { data: 'か' });
+ h.textarea.dispatch('keydown', { key, keyCode, isComposing: false });
+ expect(h.isXtermComposing()).toBe(true);
+ expect(h.controller.state).toMatchObject({ composing: true, awaitingCommit: false });
+
+ // The preview follows the composition instead of freezing on the old value.
+ h.textarea.dispatch('compositionupdate', { data: 'かな' });
+ h.flushFrame();
+ expect(h.render).toHaveBeenLastCalledWith({ text: 'かな', phase: 'provisional' });
+ expect(h.emitted).toEqual([]);
+ });
+
+ test('ignores keydowns that did not target the helper textarea', () => {
+ const h = harness();
+ const sibling = new FakeNode();
+ sibling.parent = h.element;
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionupdate', { data: '漢字' });
+ sibling.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
+ expect(h.controller.state).toMatchObject({ composing: true, awaitingCommit: false });
+ });
+
+ test('destroy stops the controller observing keydown on the terminal element', () => {
+ const h = withXtermStandIn();
+ h.controller.destroy();
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionupdate', { data: 'later' });
+ h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false });
+ expect(h.emitted).toEqual([
+ { data: 'later', consumed: false },
+ { data: '\r', consumed: false },
+ ]);
+ expect(h.onCommit).not.toHaveBeenCalled();
+ expect(h.element.listenerCount()).toBe(0);
+ });
+
+ describe('a commit that never reaches onData', () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => vi.useRealTimers());
+
+ function realTimerHarness() {
+ return harness({
+ setTimer: (callback: () => void, delay: number) => setTimeout(callback, delay),
+ clearTimer: (id: ReturnType) => clearTimeout(id),
+ });
+ }
+
+ test.each([
+ ['compositionend', (h: ReturnType) => h.textarea.dispatch('compositionend', { data: '' })],
+ [
+ 'a finalizing keydown',
+ (h: ReturnType) => h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13 }),
+ ],
+ ])('stops waiting after the same 2 s bound when finalized by %s', (_label, finalize) => {
+ const h = realTimerHarness();
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionupdate', { data: 'deleted' });
+ finalize(h);
+ h.clear.mockClear();
+ expect(h.controller.state).toMatchObject({ awaitingCommit: true, timerPending: true });
+
+ vi.advanceTimersByTime(1999);
+ expect(h.controller.state.awaitingCommit).toBe(true);
+ vi.advanceTimersByTime(1);
+ expect(h.controller.state).toMatchObject({ awaitingCommit: false, latest: '', timerPending: false });
+ expect(h.clear).toHaveBeenCalledOnce();
+
+ // The next unrelated keystroke or paste is not adopted as the IME commit.
+ expect(h.controller.consumeTerminalData('x')).toBe(false);
+ expect(h.controller.consumeTerminalData('pasted line')).toBe(false);
+ expect(h.onCommit).not.toHaveBeenCalled();
+ });
+
+ test('a commit that arrives in time replaces the wait bound with the committed one', () => {
+ const h = realTimerHarness();
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionend', { data: '日本' });
+ vi.advanceTimersByTime(1500);
+ expect(h.controller.consumeTerminalData('日本')).toBe(true);
+ // The wait bound would have fired at 2000 ms; the committed bound runs
+ // a full 2 s from the commit instead.
+ vi.advanceTimersByTime(1000);
+ expect(h.controller.state.committed).toBe(true);
+ vi.advanceTimersByTime(1000);
+ expect(h.controller.state.committed).toBe(false);
+ });
+
+ test('a new composition cancels the previous wait bound', () => {
+ const h = realTimerHarness();
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionend', { data: '' });
+ vi.advanceTimersByTime(1500);
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionupdate', { data: 'next' });
+ vi.advanceTimersByTime(1000);
+ expect(h.controller.state).toMatchObject({ composing: true, latest: 'next' });
+ });
+ });
+
+ test('generation fences stale frames and timers', () => {
+ const h = harness();
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionupdate', { data: 'old' });
+ const staleFrame = h.frames[0].callback;
+ h.textarea.dispatch('compositionstart');
+ staleFrame();
+ expect(h.render).not.toHaveBeenCalled();
+
+ h.textarea.dispatch('compositionend', { data: 'first' });
+ h.controller.consumeTerminalData('first');
+ const staleTimer = h.timers[0].callback;
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionupdate', { data: 'new' });
+ h.flushFrame();
+ staleTimer();
+ expect(h.render).toHaveBeenLastCalledWith({ text: 'new', phase: 'provisional' });
+ });
+
+ test('predicted completion clears immediately while fallback waits for output or TTL', () => {
+ const predicted = harness();
+ predicted.textarea.dispatch('compositionstart');
+ predicted.clear.mockClear();
+ predicted.textarea.dispatch('compositionend', { data: 'one' });
+ predicted.controller.consumeTerminalData('one');
+ expect(predicted.frames).toHaveLength(1);
+ predicted.controller.completeCommit({ predicted: true });
+ expect(predicted.clear).toHaveBeenCalledOnce();
+ expect(predicted.frames).toHaveLength(0);
+ expect(predicted.timers).toHaveLength(0);
+ expect(predicted.controller.state.latest).toBe('');
+ expect(predicted.controller.state.committed).toBe(false);
+
+ const fallback = harness();
+ fallback.textarea.dispatch('compositionstart');
+ fallback.clear.mockClear();
+ fallback.textarea.dispatch('compositionend', { data: 'two' });
+ fallback.controller.consumeTerminalData('two');
+ fallback.controller.completeCommit({ predicted: false });
+ expect(fallback.clear).not.toHaveBeenCalled();
+ fallback.controller.noteAuthoritativeOutput();
+ expect(fallback.clear).toHaveBeenCalledOnce();
+ expect(fallback.frames).toHaveLength(0);
+ expect(fallback.timers).toHaveLength(0);
+ expect(fallback.controller.state.latest).toBe('');
+ expect(fallback.controller.state.committed).toBe(false);
+
+ const ttl = harness();
+ ttl.textarea.dispatch('compositionstart');
+ ttl.clear.mockClear();
+ ttl.textarea.dispatch('compositionend', { data: 'three' });
+ ttl.controller.consumeTerminalData('three');
+ ttl.controller.completeCommit({ predicted: false });
+ ttl.flushTimer();
+ expect(ttl.clear).toHaveBeenCalledOnce();
+ expect(ttl.frames).toHaveLength(0);
+ expect(ttl.timers).toHaveLength(0);
+ expect(ttl.controller.state.latest).toBe('');
+ expect(ttl.controller.state.committed).toBe(false);
+ });
+
+ test('contains re-entrant reset and destroy from commit callbacks without resurrecting work', () => {
+ let resetController: { reset(): void };
+ const reset = harness({ onCommit: () => resetController.reset() });
+ resetController = reset.controller;
+ reset.textarea.dispatch('compositionstart');
+ reset.textarea.dispatch('compositionupdate', { data: 'draft' });
+ reset.textarea.dispatch('compositionend');
+ expect(reset.controller.consumeTerminalData('final')).toBe(true);
+ expect(reset.frames).toHaveLength(0);
+ expect(reset.timers).toHaveLength(0);
+ expect(reset.controller.state.latest).toBe('');
+
+ let destroyController: { destroy(): void };
+ const destroy = harness({ onCommit: () => destroyController.destroy() });
+ destroyController = destroy.controller;
+ destroy.textarea.dispatch('compositionstart');
+ destroy.textarea.dispatch('compositionend');
+ expect(destroy.controller.consumeTerminalData('final')).toBe(true);
+ expect(destroy.textarea.listenerCount()).toBe(0);
+ expect(destroy.element.listenerCount()).toBe(0);
+ expect(destroy.frames).toHaveLength(0);
+ expect(destroy.timers).toHaveLength(0);
+ expect(destroy.controller.state.latest).toBe('');
+ });
+
+ test('contains re-entrant render and clear callbacks', () => {
+ let renderController: { reset(): void };
+ const render = harness({ render: () => renderController.reset() });
+ renderController = render.controller;
+ render.textarea.dispatch('compositionstart');
+ render.textarea.dispatch('compositionupdate', { data: 'draft' });
+ expect(() => render.flushFrame()).not.toThrow();
+ expect(render.frames).toHaveLength(0);
+ expect(render.timers).toHaveLength(0);
+ expect(render.controller.state.latest).toBe('');
+
+ let clearController: { destroy(): void };
+ const clear = harness({ clear: () => clearController?.destroy() });
+ clearController = clear.controller;
+ expect(() => clear.textarea.dispatch('compositionstart')).not.toThrow();
+ expect(clear.textarea.listenerCount()).toBe(0);
+ expect(clear.element.listenerCount()).toBe(0);
+ expect(clear.frames).toHaveLength(0);
+ expect(clear.timers).toHaveLength(0);
+ });
+
+ test('fails open when frame or timer schedulers throw', () => {
+ const frame = harness({
+ scheduleFrame: () => {
+ throw new Error('frame scheduler');
+ },
+ });
+ frame.textarea.dispatch('compositionstart');
+ expect(() => frame.textarea.dispatch('compositionupdate', { data: 'safe' })).not.toThrow();
+ expect(frame.controller.state.framePending).toBe(false);
+
+ let lateFrame: (() => void) | undefined;
+ const timer = harness({
+ scheduleFrame: (callback: () => void) => {
+ lateFrame = callback;
+ return 1;
+ },
+ cancelFrame: () => {},
+ setTimer: () => {
+ throw new Error('timer scheduler');
+ },
+ });
+ timer.textarea.dispatch('compositionstart');
+ timer.clear.mockClear();
+ timer.textarea.dispatch('compositionend');
+ expect(() => timer.controller.consumeTerminalData('safe')).not.toThrow();
+ expect(timer.controller.state.committed).toBe(false);
+ expect(timer.controller.state.latest).toBe('');
+ expect(timer.controller.state.framePending).toBe(false);
+ expect(timer.controller.state.timerPending).toBe(false);
+ expect(timer.clear).toHaveBeenCalledOnce();
+ lateFrame?.();
+ expect(timer.render).not.toHaveBeenCalled();
+
+ const tokenlessTimer = harness({ setTimer: () => undefined });
+ tokenlessTimer.textarea.dispatch('compositionstart');
+ tokenlessTimer.clear.mockClear();
+ tokenlessTimer.textarea.dispatch('compositionend');
+ expect(tokenlessTimer.controller.consumeTerminalData('safe')).toBe(true);
+ expect(tokenlessTimer.controller.state.committed).toBe(false);
+ expect(tokenlessTimer.controller.state.latest).toBe('');
+ expect(tokenlessTimer.controller.state.framePending).toBe(false);
+ expect(tokenlessTimer.controller.state.timerPending).toBe(false);
+ expect(tokenlessTimer.frames).toHaveLength(0);
+ expect(tokenlessTimer.timers).toHaveLength(0);
+ expect(tokenlessTimer.clear).toHaveBeenCalledOnce();
+
+ const cancellation = harness({
+ cancelFrame: () => {
+ throw new Error('frame cancellation');
+ },
+ clearTimer: () => {
+ throw new Error('timer cancellation');
+ },
+ });
+ cancellation.textarea.dispatch('compositionstart');
+ cancellation.textarea.dispatch('compositionupdate', { data: 'draft' });
+ cancellation.textarea.dispatch('compositionend');
+ cancellation.controller.consumeTerminalData('final');
+ expect(() => cancellation.controller.reset()).not.toThrow();
+ expect(cancellation.controller.state.framePending).toBe(false);
+ expect(cancellation.controller.state.timerPending).toBe(false);
+ });
+
+ test('authoritative output never clears active provisional composition', () => {
+ const h = harness();
+ h.textarea.dispatch('compositionstart');
+ h.clear.mockClear();
+ h.textarea.dispatch('compositionupdate', { data: 'active' });
+ h.flushFrame();
+ h.controller.noteAuthoritativeOutput();
+ expect(h.clear).not.toHaveBeenCalled();
+ });
+
+ test('blur and reset cancel scheduled work and clear visual state', () => {
+ const h = harness();
+ h.textarea.dispatch('compositionstart');
+ h.clear.mockClear();
+ h.textarea.dispatch('compositionupdate', { data: 'pending' });
+ h.textarea.dispatch('blur');
+ expect(h.frames).toHaveLength(0);
+ expect(h.clear).toHaveBeenCalledOnce();
+
+ h.textarea.dispatch('compositionstart');
+ h.clear.mockClear();
+ h.textarea.dispatch('compositionupdate', { data: 'again' });
+ h.controller.reset();
+ expect(h.frames).toHaveLength(0);
+ expect(h.clear).toHaveBeenCalledOnce();
+ expect(h.controller.state.latest).toBe('');
+ expect(h.controller.state.committed).toBe(false);
+ });
+
+ test('destroy is idempotent and removes listeners and scheduled work', () => {
+ const h = harness();
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionupdate', { data: 'pending' });
+ expect(h.textarea.listenerCount()).toBe(5);
+ expect(h.element.listenerCount()).toBe(1);
+ h.controller.destroy();
+ h.controller.destroy();
+ expect(h.textarea.listenerCount()).toBe(0);
+ expect(h.element.listenerCount()).toBe(0);
+ expect(h.frames).toHaveLength(0);
+ h.textarea.dispatch('compositionupdate', { data: 'ignored' });
+ expect(h.scheduleFrame).toHaveBeenCalledOnce();
+ expect(() => h.controller.reset()).not.toThrow();
+ expect(h.controller.state.latest).toBe('');
+ expect(h.controller.state.framePending).toBe(false);
+ expect(h.controller.state.timerPending).toBe(false);
+ });
+
+ test('contains render, clear, and commit callback exceptions', () => {
+ const h = harness({
+ render: vi.fn(() => {
+ throw new Error('render');
+ }),
+ clear: vi.fn(() => {
+ throw new Error('clear');
+ }),
+ onCommit: vi.fn(() => {
+ throw new Error('commit');
+ }),
+ });
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionupdate', { data: 'safe' });
+ expect(() => h.flushFrame()).not.toThrow();
+ h.textarea.dispatch('compositionend', { data: 'safe' });
+ expect(() => h.controller.consumeTerminalData('safe')).not.toThrow();
+ expect(() => h.controller.reset()).not.toThrow();
+ });
+
+ test.each([
+ [{ userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
+ [{ userAgent: 'Mozilla/5.0 (iPad) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
+ [{ userAgent: 'Mozilla/5.0 (iPod) AppleWebKit/605.1.15', maxTouchPoints: 1 }, true],
+ [{ userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15', platform: 'MacIntel', maxTouchPoints: 2 }, true],
+ [{ userAgent: 'CriOS/120.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true],
+ [{ userAgent: 'Mozilla/5.0 (iPhone) Gecko/120', maxTouchPoints: 5 }, false],
+ [{ userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 0 }, false],
+ [{ userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15', platform: 'MacIntel', maxTouchPoints: 1 }, false],
+ [{ userAgent: 'Mozilla/5.0 (Android) AppleWebKit/537.36', maxTouchPoints: 5 }, false],
+ ])('detects iOS WebKit touch eligibility for %j', (nav, expected) => {
+ expect(harness().api.isIosWebKitTouch(nav)).toBe(expected);
+ });
+});