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); + }); +});