Merge pull request #499 from aakhter/pr/mobile-ime-preview

feat(terminal): preview IME composition text on iOS Safari

# Conflicts:
#	CLAUDE.md
This commit is contained in:
Codeman maintainer
2026-10-04 23:26:30 +02:00
15 changed files with 2565 additions and 14 deletions
+2 -2
View File
@@ -320,7 +320,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) → `tab-layout-browser.js`(5.9) → `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) → `tab-layout-browser.js`(5.9) → `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 `<html>`; 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)
@@ -380,7 +380,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).
+1
View File
@@ -37,6 +37,7 @@ export const BROWSER_TEST_GLOBS = [
'test/webhook-settings.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',
];
/**
+1 -1
View File
@@ -975,7 +975,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
@@ -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 `<div>` with per-character grid positioning.
*
* Each character gets its own `<span>` 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;
@@ -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;
@@ -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,
@@ -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');
});
});
+2
View File
@@ -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');
@@ -112,6 +113,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',
'tab-layout-browser.js',
+2
View File
@@ -3838,6 +3838,8 @@
<script defer src="notification-manager.js"></script>
<script defer src="keyboard-accessory.js"></script>
<script defer src="input-cjk.js"></script>
<!-- Shows iOS Safari IME composition text inside the terminal. Must precede terminal-ui.js. -->
<script defer src="mobile-ime-preview.js"></script>
<!-- Forwards committed input events that xterm drops on Android/GBoard soft keyboards. Must precede terminal-ui.js. -->
<script defer src="terminal-keycode229-recovery.js"></script>
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
+297
View File
@@ -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);
+25
View File
@@ -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);
+254
View File
@@ -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();
@@ -1215,6 +1445,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
@@ -1292,6 +1524,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();
@@ -1348,6 +1582,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) {
@@ -1493,6 +1728,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;
}
}
@@ -3528,6 +3764,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.
@@ -3591,6 +3830,7 @@ Object.assign(CodemanApp.prototype, {
// Accumulate raw data (may contain DEC 2026 markers)
this.pendingWrites.push(data);
this._pendingWritesOutputSeq = this._terminalOutputSeq;
this._scheduleTerminalWriteFlush();
},
@@ -3622,6 +3862,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;
@@ -3652,6 +3893,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();
@@ -3857,6 +4107,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 {
@@ -3874,6 +4127,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;
+703
View File
@@ -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<typeof vi.fn>;
type App = Record<string, any>;
function loadMixin(globals: Record<string, unknown> = {}) {
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
const windowStub = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
...(globals.window as object),
} as Record<string, unknown>;
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<string>();
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<Record<string, unknown>>,
querySelector: (selector: string) => (selector === '.composition-view' ? compositionView : null),
appendChild(node: Record<string, unknown>) {
this.children.push(node);
},
};
const createdControllers: Array<Record<string, any>> = [];
const previewNodes: Array<Record<string, any>> = [];
const documentStub = {
addEventListener: vi.fn(),
createElement: vi.fn(() => {
const node = {
className: '',
hidden: false,
textContent: '',
dataset: {} as Record<string, string>,
style: {} as Record<string, string>,
attributes: {} as Record<string, string>,
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<string, unknown>) => {
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<string, string> }) => 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('<script defer src="mobile-ime-preview.js"></script>');
expect(at).toBeGreaterThan(indexSource.indexOf('vendor/xterm.min.js'));
expect(at).toBeLessThan(indexSource.indexOf('<script defer src="terminal-ui.js"></script>'));
expect(at).toBeLessThan(indexSource.indexOf('<script defer src="app.js"></script>'));
});
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();
});
});
+292
View File
@@ -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('<div id="t" style="width:600px;height:300px"></div>');
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(
'<html class="touch-device"><body><div id="t" style="width:600px;height:240px"></div></body></html>'
);
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<void>((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: '今日は天気' });
});
});
+672
View File
@@ -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<string, unknown>) => 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<string, RegisteredListener[]>();
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<string, unknown>) {
for (const registered of [...(this.listeners.get(type) ?? [])]) {
if (registered.capture === capture) registered.listener(event);
}
}
dispatch(type: string, event: Record<string, unknown> = {}) {
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<string, unknown> = {},
beforeCreate?: (textarea: FakeTextarea, getController: () => Record<string, any> | 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<string, unknown> | 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<typeof setTimeout>) => clearTimeout(id),
});
}
test.each([
['compositionend', (h: ReturnType<typeof harness>) => h.textarea.dispatch('compositionend', { data: '' })],
[
'a finalizing keydown',
(h: ReturnType<typeof harness>) => 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);
});
});