diff --git a/CLAUDE.md b/CLAUDE.md
index c4a910cc..6c8a8dc9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -312,7 +312,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
### Frontend
-Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
+Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run.
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on ``; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
diff --git a/scripts/build.mjs b/scripts/build.mjs
index 2ab8ecd5..3422efe0 100644
--- a/scripts/build.mjs
+++ b/scripts/build.mjs
@@ -83,6 +83,7 @@ appendFileSync(
// 4. Minify frontend assets
run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite');
+run('minify mobile-ime-preview.js', 'npx esbuild dist/web/public/mobile-ime-preview.js --minify --outfile=dist/web/public/mobile-ime-preview.js --allow-overwrite');
run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite');
run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite');
run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite');
@@ -111,6 +112,7 @@ console.log('\n[build] content-hash cache busting');
'notification-manager.js',
'keyboard-accessory.js',
'input-cjk.js',
+ 'mobile-ime-preview.js',
'terminal-keycode229-recovery.js',
'sanitize-html.js',
'app.js',
diff --git a/src/web/public/index.html b/src/web/public/index.html
index a5009b53..43560535 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -3735,6 +3735,8 @@
+
+
diff --git a/src/web/public/mobile-ime-preview.js b/src/web/public/mobile-ime-preview.js
new file mode 100644
index 00000000..c18ece58
--- /dev/null
+++ b/src/web/public/mobile-ime-preview.js
@@ -0,0 +1,260 @@
+/**
+ * @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.
+ *
+ * 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;
+ 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;
+ 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 finalizeComposition(value, fromKeydown) {
+ if (!composing) return;
+ composing = false;
+ awaitingCommit = true;
+ committed = false;
+ finalizedByKeydown = fromKeydown;
+ latestValue = value == null ? latestValue : String(value);
+ scheduleLatestPreview('provisional');
+ }
+
+ function onCompositionEnd(event) {
+ if (finalizedByKeydown) {
+ finalizedByKeydown = false;
+ return;
+ }
+ finalizeComposition(event.data, false);
+ }
+
+ function onKeydown(event) {
+ if (composing && event.isComposing === false && event.key !== 'Process' && event.key !== 'Unidentified') {
+ 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 token = { generation, id: undefined };
+ timerToken = token;
+ const callback = function () {
+ if (destroyed || timerToken !== token || token.generation !== generation || !committed) return;
+ timerToken = null;
+ cleanup();
+ };
+ const id = safely(setTimer, callback, COMMITTED_VISUAL_TTL);
+ if (timerToken === token) {
+ if (id === undefined) {
+ timerToken = null;
+ if (!destroyed && generation === owner && committed) cleanup();
+ } else {
+ token.id = id;
+ }
+ }
+ return true;
+ }
+
+ function completeCommit(result) {
+ if (destroyed || !result || result.predicted !== true || !committed) return;
+ cleanup();
+ }
+
+ function noteAuthoritativeOutput() {
+ if (destroyed || !committed) return;
+ cleanup();
+ }
+
+ const listeners = [
+ ['compositionstart', beginComposition],
+ ['compositionupdate', updateComposition],
+ ['input', onComposingInput],
+ ['compositionend', onCompositionEnd],
+ ['keydown', onKeydown, true],
+ ['blur', reset],
+ ];
+ for (const [type, listener, capture] of listeners) textarea.addEventListener(type, listener, capture);
+
+ function destroy() {
+ if (destroyed) return;
+ destroyed = true;
+ for (const [type, listener, capture] of listeners) textarea.removeEventListener(type, listener, capture);
+ cleanup();
+ }
+
+ return {
+ consumeTerminalData,
+ completeCommit,
+ noteAuthoritativeOutput,
+ reset,
+ destroy,
+ get state() {
+ return {
+ generation,
+ composing,
+ awaitingCommit,
+ committed,
+ latest: latestValue,
+ framePending: frameToken !== null,
+ timerPending: timerToken !== null,
+ };
+ },
+ };
+ }
+
+ global.MobileImePreview = { create, isIosWebKitTouch };
+})(globalThis);
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index 050671a4..0a5c7178 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -460,6 +460,31 @@ textarea:focus-visible {
font-size: 16px !important; /* prevent iOS auto-zoom on focus */
}
+.xterm-helpers .codeman-ime-preview {
+ position: absolute;
+ left: var(--xterm-helper-left, 0px);
+ top: var(--xterm-helper-top, 0px);
+ z-index: 6;
+ pointer-events: none;
+ white-space: pre;
+ color: var(--terminal-foreground, var(--text, #fff));
+ font-family: var(--font-mono, monospace);
+ font-size: 14px;
+ font-weight: 400;
+ font-style: normal;
+ line-height: 1.2;
+ height: 1.2em;
+}
+
+.xterm-helpers .codeman-ime-preview[data-phase='provisional'] {
+ text-decoration: underline;
+ text-decoration-style: dotted;
+}
+
+.touch-device .xterm-helpers.codeman-ime-preview-owned .composition-view.active {
+ display: none;
+}
+
/* Session tab focus */
.session-tab:focus-visible {
outline: 2px solid var(--accent);
diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js
index 903661e5..9413522d 100644
--- a/src/web/public/terminal-ui.js
+++ b/src/web/public/terminal-ui.js
@@ -250,6 +250,189 @@ 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; this
+ * paints it in a span inside `.xterm-helpers`, positioned by the same
+ * --xterm-helper-left/top vars as the helper textarea. 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;
+ }
+ let foreground = this.terminal?.options?.theme?.foreground;
+ if (!foreground) {
+ try {
+ foreground = window.codemanCurrentXtermTheme?.()?.foreground;
+ } catch {
+ // Theme lookup is best-effort; retain the safe terminal fallback.
+ }
+ }
+ preview.style.color = foreground || '#e0e0e0';
+ } catch {
+ // Typography matching is visual-only and must not block input.
+ }
+ };
+ const clearPreview = () => {
+ 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 controller = MobileImePreview.create({
+ textarea,
+ render: ({ text, phase }) => {
+ try {
+ 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;
+
+ this._mobileImePreviewOfflineHandler = () => {
+ try {
+ this._mobileImePreview?.reset?.();
+ } catch {
+ // Disconnect cleanup is visual-only.
+ }
+ };
+ this._mobileImePreviewPagehideHandler = () => this._destroyMobileImePreview();
+ 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.
+ }
+ },
+
+ /** Hand a committed IME chunk to the local echo overlay. False = not taken. */
+ _transferMobileImeCommitToLocalEcho(data) {
+ const overlay = this._localEchoOverlay;
+ try {
+ const update = data.length === 1 ? overlay?.addChar : overlay?.appendText;
+ if (typeof update !== 'function') return false;
+ update.call(overlay, data);
+ } catch {
+ return false;
+ }
+ this._mobileImeCommitOutputSeq = null;
+ try {
+ this._mobileImePreview?.completeCommit?.({ predicted: true });
+ } catch {
+ // Ownership transfer is visual-only.
+ }
+ return true;
+ },
+
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 +490,7 @@ Object.assign(CodemanApp.prototype, {
const container = document.getElementById('terminalContainer');
this.terminal.open(container);
+ this._initMobileImePreview();
this._installMobileTapMouseGuard();
this._installShiftDragSelection();
this._installTouchSelectionFocusGuard();
@@ -1211,6 +1395,8 @@ Object.assign(CodemanApp.prototype, {
// survives tab switches and reconnects.
const handleTerminalData = (data) => {
+ // Before anything can rewrite `data`: is this chunk the IME's commit?
+ const isImeCommit = this._consumeMobileImeTerminalData(data);
// Mouse SGR reports (tap-to-position) are NOT IME input — they must reach
// the PTY even while the CJK input field owns focus. Without this exception
// tapping to move the cursor silently does nothing whenever Chinese input
@@ -1288,7 +1474,16 @@ 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.
- if (this._localEchoEnabled && !echoPassthrough) {
+ // An IME commit moves into the overlay, which then shows it in place of
+ // the preview. The charCode check skips a commit the one-shot Ctrl above
+ // turned into a control byte. If the overlay cannot take it, the text is
+ // sent directly rather than dropped.
+ let imeCommitBypassesEcho = false;
+ if (isImeCommit && this._localEchoEnabled && !echoPassthrough && data.charCodeAt(0) >= 32) {
+ if (this._transferMobileImeCommitToLocalEcho(data)) return;
+ imeCommitBypassesEcho = true;
+ }
+ if (this._localEchoEnabled && !echoPassthrough && !imeCommitBypassesEcho) {
if (data === '\x7f') {
const source = this._localEchoOverlay?.removeChar();
if (source === 'flushed') {
@@ -3517,6 +3712,9 @@ Object.assign(CodemanApp.prototype, {
},
batchTerminalWrite(data) {
+ // Arrival order of output, so the IME preview can tell output that
+ // followed a commit from output that was already queued before it.
+ this._terminalOutputSeq = (this._terminalOutputSeq || 0) + 1;
// Feed the renderer watchdog. Recorded before the buffer-load early return
// below: a write that is queued rather than written still means the pipeline
// owes us a frame once it drains.
@@ -3580,6 +3778,7 @@ Object.assign(CodemanApp.prototype, {
// Accumulate raw data (may contain DEC 2026 markers)
this.pendingWrites.push(data);
+ this._pendingWritesOutputSeq = this._terminalOutputSeq;
this._scheduleTerminalWriteFlush();
},
@@ -3611,6 +3810,7 @@ Object.assign(CodemanApp.prototype, {
// Transfer buffered data to normal pending writes
this.pendingWrites.push(this.flickerFilterBuffer);
+ this._pendingWritesOutputSeq = this._terminalOutputSeq;
this.flickerFilterBuffer = '';
this.flickerFilterActive = false;
@@ -3641,6 +3841,15 @@ Object.assign(CodemanApp.prototype, {
* Position is tracked dynamically by _findPrompt() on every render.
*/
_updateLocalEchoState() {
+ if (this._mobileImePreviewSessionId !== this.activeSessionId) {
+ this._mobileImePreviewSessionId = this.activeSessionId;
+ this._mobileImeCommitOutputSeq = null;
+ try {
+ this._mobileImePreview?.reset?.();
+ } catch {
+ // The preview is visual-only; session switching must continue.
+ }
+ }
const settings = this.loadAppSettingsFromStorage();
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
@@ -3846,6 +4055,9 @@ Object.assign(CodemanApp.prototype, {
this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES));
deferred = true;
}
+ // Newest output this chunk fully contains, for the IME preview. A split
+ // chunk may not hold that output yet, so it reports nothing.
+ const flushedOutputSeq = deferred ? null : (this._pendingWritesOutputSeq ?? null);
this._terminalWriteInFlight = true;
this._terminalWriteInFlightBytes = writeChunk.length;
try {
@@ -3863,6 +4075,7 @@ Object.assign(CodemanApp.prototype, {
// because the test's write mock moved the viewport synchronously.)
this._restoreTerminalViewport(preserveViewportY, flushSessionId);
this._scheduleTerminalWriteFlush();
+ this._noteMobileImeAuthoritativeOutput(flushedOutputSeq, flushSessionId);
});
} catch (err) {
this._terminalWriteInFlight = false;
diff --git a/test/mobile-ime-preview-structure.test.ts b/test/mobile-ime-preview-structure.test.ts
new file mode 100644
index 00000000..5daa5c5c
--- /dev/null
+++ b/test/mobile-ime-preview-structure.test.ts
@@ -0,0 +1,595 @@
+/**
+ * @fileoverview Wiring tests for the iOS IME preview (mobile-ime-preview.js).
+ *
+ * The controller itself is covered by test/mobile-ime-preview.test.ts. These
+ * pin how terminal-ui.js and the delivery graph consume it: script order,
+ * build registration and CSS, the _init/_destroyMobileImePreview lifecycle,
+ * the onData routing of an IME commit, and the rule that only output accepted
+ * AFTER a commit may clear the committed preview.
+ */
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+import { describe, expect, it, vi } from 'vitest';
+
+const read = (path: string) => readFileSync(resolve(import.meta.dirname, '..', path), 'utf8');
+const indexSource = read('src/web/public/index.html');
+const buildSource = read('scripts/build.mjs');
+const terminalSource = read('src/web/public/terminal-ui.js');
+const cssSource = read('src/web/public/styles.css');
+
+type Fn = ReturnType;
+type App = Record;
+
+function loadMixin(globals: Record = {}) {
+ const FakeCodemanApp = function () {} as unknown as { prototype: Record };
+ const windowStub = {
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ ...(globals.window as object),
+ } as Record;
+ const context = vm.createContext({
+ console,
+ performance,
+ setTimeout,
+ clearTimeout,
+ setInterval: vi.fn(),
+ clearInterval: vi.fn(),
+ requestAnimationFrame: vi.fn(),
+ cancelAnimationFrame: vi.fn(),
+ URLSearchParams,
+ location: { search: '' },
+ localStorage: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
+ document: { addEventListener: vi.fn(), createElement: vi.fn() },
+ MobileDetection: { isTouchDevice: () => false },
+ CodemanApp: FakeCodemanApp,
+ _crashDiag: { log: vi.fn() },
+ ...globals,
+ window: windowStub,
+ });
+ vm.runInContext(terminalSource, context, { filename: 'terminal-ui.js' });
+ return { mixin: FakeCodemanApp.prototype, context, windowStub };
+}
+
+function fakeClassList() {
+ const values = new Set();
+ return {
+ add: vi.fn((value: string) => values.add(value)),
+ remove: vi.fn((value: string) => values.delete(value)),
+ toggle: vi.fn((value: string, force?: boolean) => {
+ const enabled = force === undefined ? !values.has(value) : force;
+ if (enabled) values.add(value);
+ else values.delete(value);
+ return enabled;
+ }),
+ contains: (value: string) => values.has(value),
+ };
+}
+
+function createPreviewHarness(
+ options: {
+ eligible?: boolean;
+ createThrows?: boolean;
+ omitGlobal?: boolean;
+ themeForeground?: string;
+ themeGetterThrows?: boolean;
+ } = {}
+) {
+ const compositionView = {
+ style: {
+ fontFamily: '"Fira Code"',
+ fontSize: '10px',
+ fontWeight: '500',
+ fontStyle: 'italic',
+ lineHeight: '12px',
+ height: '12px',
+ color: 'rgb(255, 255, 255)',
+ },
+ };
+ const helpers = {
+ classList: fakeClassList(),
+ children: [] as Array>,
+ querySelector: (selector: string) => (selector === '.composition-view' ? compositionView : null),
+ appendChild(node: Record) {
+ this.children.push(node);
+ },
+ };
+ const createdControllers: Array> = [];
+ const previewNodes: Array> = [];
+ const documentStub = {
+ addEventListener: vi.fn(),
+ createElement: vi.fn(() => {
+ const node = {
+ className: '',
+ hidden: false,
+ textContent: '',
+ dataset: {} as Record,
+ style: {} as Record,
+ attributes: {} as Record,
+ setAttribute(name: string, value: string) {
+ this.attributes[name] = value;
+ },
+ remove: vi.fn(),
+ };
+ previewNodes.push(node);
+ return node;
+ }),
+ };
+ const mobileImePreview = options.omitGlobal
+ ? undefined
+ : {
+ isIosWebKitTouch: vi.fn(() => options.eligible ?? true),
+ create: vi.fn((callbacks: Record) => {
+ if (options.createThrows) throw new Error('controller unavailable');
+ const controller = {
+ destroy: vi.fn(),
+ reset: vi.fn(),
+ consumeTerminalData: vi.fn(() => false),
+ completeCommit: vi.fn(),
+ noteAuthoritativeOutput: vi.fn(),
+ callbacks,
+ };
+ createdControllers.push(controller);
+ return controller;
+ }),
+ };
+ const { mixin, windowStub } = loadMixin({
+ document: documentStub,
+ MobileImePreview: mobileImePreview,
+ getComputedStyle: (node: { style: Record }) => node.style,
+ });
+ windowStub.codemanCurrentXtermTheme = () => {
+ if (options.themeGetterThrows) throw new Error('theme unavailable');
+ return { foreground: '#334455' };
+ };
+ const app: App = Object.assign(Object.create(mixin), {
+ terminal: {
+ textarea: {},
+ options: { theme: options.themeForeground ? { foreground: options.themeForeground } : undefined },
+ element: { querySelector: (selector: string) => (selector === '.xterm-helpers' ? helpers : null) },
+ },
+ activeSessionId: 'session-a',
+ });
+ return { app, helpers, compositionView, previewNodes, createdControllers, mobileImePreview, windowStub };
+}
+
+describe('mobile IME preview delivery graph', () => {
+ it('loads the controller after xterm and before terminal wiring', () => {
+ const at = indexSource.indexOf('');
+ expect(at).toBeGreaterThan(indexSource.indexOf('vendor/xterm.min.js'));
+ expect(at).toBeLessThan(indexSource.indexOf(''));
+ expect(at).toBeLessThan(indexSource.indexOf(''));
+ });
+
+ it('is minified and content-hashed by the build', () => {
+ expect(buildSource).toContain("run('minify mobile-ime-preview.js'");
+ expect(buildSource).toMatch(/const HASHABLE = \[[^\]]*'mobile-ime-preview\.js'/);
+ });
+
+ it('scopes preview presentation and native composition suppression to touch ownership', () => {
+ expect(cssSource).toContain('.xterm-helpers .codeman-ime-preview {');
+ expect(cssSource).toContain(".xterm-helpers .codeman-ime-preview[data-phase='provisional'] {");
+ expect(cssSource).toContain('.touch-device .xterm-helpers.codeman-ime-preview-owned .composition-view.active {');
+ const rule = cssSource.slice(cssSource.indexOf('.xterm-helpers .codeman-ime-preview {'));
+ expect(rule.slice(0, rule.indexOf('}'))).toContain('left: var(--xterm-helper-left, 0px)');
+ expect(rule.slice(0, rule.indexOf('}'))).toContain('top: var(--xterm-helper-top, 0px)');
+ });
+
+ it('initializes the preview right after the terminal opens', () => {
+ expect(terminalSource).toMatch(/this\.terminal\.open\(container\);\s*this\._initMobileImePreview\(\);/);
+ });
+});
+
+describe('mobile IME preview lifecycle', () => {
+ it('fails open when the global is absent or create throws', () => {
+ for (const options of [{ omitGlobal: true }, { createThrows: true }]) {
+ const { app } = createPreviewHarness(options);
+ expect(() => app._initMobileImePreview()).not.toThrow();
+ expect(app._mobileImePreview).toBeNull();
+ }
+ });
+
+ it('does not create a controller on unsupported input platforms', () => {
+ const { app, mobileImePreview, previewNodes } = createPreviewHarness({ eligible: false });
+ app._initMobileImePreview();
+ expect(mobileImePreview?.create).not.toHaveBeenCalled();
+ expect(previewNodes).toHaveLength(0);
+ expect(app._mobileImePreview).toBeNull();
+ });
+
+ it('creates one controller bound to the terminal textarea and one hidden preview node', () => {
+ const { app, helpers, previewNodes, mobileImePreview } = createPreviewHarness();
+ app._initMobileImePreview();
+ expect(mobileImePreview?.create).toHaveBeenCalledOnce();
+ expect(mobileImePreview?.create.mock.calls[0][0].textarea).toBe(app.terminal.textarea);
+ 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('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);
+ });
+
+ it('uses the terminal foreground while mirroring native composition font metrics', () => {
+ const { app, compositionView, previewNodes, createdControllers } = createPreviewHarness({
+ themeForeground: '#1f2328',
+ });
+ 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',
+ });
+ });
+
+ 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');
+ });
+
+ 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([
+ ['the overlay is missing', { overlayMissing: true }, '日本'],
+ ['appendText throws', { appendThrows: true }, '失敗'],
+ ['addChar throws', { addThrows: true }, '字'],
+ ])('sends the committed text exactly once when %s', (_label, extra, text) => {
+ const { controller, sent, handle } = onDataApp({ localEcho: true, ...extra });
+ expect(() => handle(text)).not.toThrow();
+ expect(sent).toEqual([text]);
+ // Nothing else shows the text yet, so the preview keeps it.
+ expect(controller.completeCommit).not.toHaveBeenCalled();
+ });
+
+ it('keeps an untagged paste on the existing local echo path', () => {
+ const { controller, overlay, sent, handle } = onDataApp({ localEcho: true, tagged: false });
+ handle('plain paste');
+ expect(overlay.pendingText).toBe('plain paste');
+ expect(controller.completeCommit).not.toHaveBeenCalled();
+ expect(sent).toEqual([]);
+ });
+
+ it('sends a commit once without local echo and holds the preview until output arrives', () => {
+ const { app, controller, sent, handle } = onDataApp({ localEcho: false });
+ handle('日本語');
+ expect(controller.consumeTerminalData).toHaveBeenCalledOnce();
+ expect(sent).toEqual(['日本語']);
+ expect(controller.completeCommit).not.toHaveBeenCalled();
+ expect(app._mobileImeCommitOutputSeq).toBe(5);
+ });
+
+ it('sends the original bytes exactly once when the controller throws', () => {
+ const { sent, handle } = onDataApp({ localEcho: false, consumeThrows: true });
+ expect(() => handle('你好')).not.toThrow();
+ expect(sent).toEqual(['你好']);
+ });
+});
+
+describe('mobile IME commit and authoritative terminal output', () => {
+ function outputHarness(mode = 'claude') {
+ const { mixin } = loadMixin();
+ const parses: Array<() => void> = [];
+ const written: string[] = [];
+ const controller = {
+ consumeTerminalData: vi.fn(() => true),
+ noteAuthoritativeOutput: vi.fn(),
+ };
+ const app: App = Object.assign(Object.create(mixin), {
+ pendingWrites: [],
+ terminal: {
+ rows: 24,
+ write: vi.fn((data: string, callback?: () => void) => {
+ written.push(data);
+ if (callback) parses.push(callback);
+ }),
+ buffer: { active: { viewportY: 0 } },
+ scrollToBottom: vi.fn(),
+ },
+ activeSessionId: 'session-a',
+ sessions: new Map([['session-a', { mode }]]),
+ isTerminalAtBottom: () => true,
+ _hasRecentUserScrollUp: () => false,
+ _safeYield: vi.fn(),
+ _localEchoOverlay: null,
+ _mobileImePreview: controller,
+ });
+ const flush = () => {
+ app.writeFrameScheduled = false;
+ app.flushPendingWrites();
+ };
+ const parseNext = () => parses.shift()?.();
+ return { app, controller, written, flush, parseNext };
+ }
+
+ it('clears the committed preview once output accepted after the commit is parsed', () => {
+ const { app, controller, flush, parseNext } = outputHarness();
+ app._consumeMobileImeTerminalData('你好');
+ app.batchTerminalWrite('echo');
+ flush();
+ expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
+ parseNext();
+ expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
+ });
+
+ it('does not let output queued before the commit clear it, even when it parses after', () => {
+ const { app, controller, flush, parseNext } = outputHarness();
+ app.batchTerminalWrite('before');
+ flush();
+ app._consumeMobileImeTerminalData('你好');
+ parseNext();
+ expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
+
+ app.batchTerminalWrite('after');
+ flush();
+ parseNext();
+ expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
+ });
+
+ it('does not let a split chunk clear the commit before its remainder is written', () => {
+ const { app, controller, written, flush, parseNext } = outputHarness('codex');
+ app._consumeMobileImeTerminalData('你好');
+ app.batchTerminalWrite('x'.repeat(40000));
+ flush();
+ parseNext();
+ expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
+ flush();
+ parseNext();
+ expect(written.join('')).toBe('x'.repeat(40000));
+ expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
+ });
+
+ it('does not let output parsed after a session switch clear the new session preview', () => {
+ const { app, controller, flush, parseNext } = outputHarness();
+ app._consumeMobileImeTerminalData('你好');
+ app.batchTerminalWrite('echo');
+ flush();
+ app.activeSessionId = 'session-b';
+ parseNext();
+ expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled();
+ });
+
+ it('notifies once per commit, never for later output', () => {
+ const { app, controller, flush, parseNext } = outputHarness();
+ app._consumeMobileImeTerminalData('你好');
+ for (const chunk of ['a', 'b']) {
+ app.batchTerminalWrite(chunk);
+ flush();
+ parseNext();
+ }
+ expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce();
+ });
+});
diff --git a/test/mobile-ime-preview.test.ts b/test/mobile-ime-preview.test.ts
new file mode 100644
index 00000000..8b52b35a
--- /dev/null
+++ b/test/mobile-ime-preview.test.ts
@@ -0,0 +1,502 @@
+import { readFileSync } from 'node:fs';
+import vm from 'node:vm';
+
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+
+type Listener = (event: Record) => void;
+type ListenerOptions = boolean | { capture?: boolean };
+type RegisteredListener = { listener: Listener; capture: boolean };
+
+class FakeTextarea {
+ value = 'unchanged';
+ private listeners = new Map();
+
+ addEventListener(type: string, listener: Listener, options?: ListenerOptions) {
+ const listeners = this.listeners.get(type) ?? [];
+ listeners.push({ listener, capture: options === true || options?.capture === true });
+ this.listeners.set(type, listeners);
+ }
+
+ removeEventListener(type: string, listener: Listener, options?: ListenerOptions) {
+ const capture = options === true || options?.capture === true;
+ const listeners = this.listeners.get(type) ?? [];
+ const index = listeners.findIndex(
+ (registered) => registered.listener === listener && registered.capture === capture
+ );
+ if (index >= 0) listeners.splice(index, 1);
+ }
+
+ dispatch(type: string, event: Record = {}) {
+ const listeners = [...(this.listeners.get(type) ?? [])];
+ for (const phase of [true, false]) {
+ for (const registered of listeners) {
+ if (registered.capture === phase) registered.listener({ type, ...event });
+ }
+ }
+ }
+
+ listenerCount() {
+ return [...this.listeners.values()].reduce((total, listeners) => total + listeners.length, 0);
+ }
+}
+
+type Scheduled = { id: number; callback: () => void; delay?: number };
+
+function harness(
+ overrides: Record = {},
+ beforeCreate?: (textarea: FakeTextarea, getController: () => Record | undefined) => void
+) {
+ const source = readFileSync(new URL('../src/web/public/mobile-ime-preview.js', import.meta.url), 'utf8');
+ const context = vm.createContext({ navigator: {} });
+ vm.runInContext(source, context, { filename: 'mobile-ime-preview.js' });
+ const api = vm.runInContext('MobileImePreview', context);
+ const textarea = new FakeTextarea();
+ const frames: Scheduled[] = [];
+ const timers: Scheduled[] = [];
+ let nextId = 1;
+ const render = vi.fn();
+ const clear = vi.fn();
+ const onCommit = vi.fn();
+ const scheduleFrame = vi.fn((callback: () => void) => {
+ const id = nextId++;
+ frames.push({ id, callback });
+ return id;
+ });
+ const cancelFrame = vi.fn((id: number) => {
+ const index = frames.findIndex((frame) => frame.id === id);
+ if (index >= 0) frames.splice(index, 1);
+ });
+ const setTimer = vi.fn((callback: () => void, delay: number) => {
+ const id = nextId++;
+ timers.push({ id, callback, delay });
+ return id;
+ });
+ const clearTimer = vi.fn((id: number) => {
+ const index = timers.findIndex((timer) => timer.id === id);
+ if (index >= 0) timers.splice(index, 1);
+ });
+ let controller: Record | undefined;
+ beforeCreate?.(textarea, () => controller);
+ controller = api.create({
+ textarea,
+ render,
+ clear,
+ onCommit,
+ scheduleFrame,
+ cancelFrame,
+ setTimer,
+ clearTimer,
+ ...overrides,
+ });
+ const flushFrame = () => frames.shift()?.callback();
+ const flushTimer = () => timers.shift()?.callback();
+
+ return {
+ api,
+ 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', isComposing: false });
+ expect(h.controller.consumeTerminalData('確定')).toBe(true);
+ h.textarea.dispatch('compositionend', { data: 'stale' });
+ expect(h.controller.consumeTerminalData('stale')).toBe(false);
+ });
+
+ test('capture keydown finalization precedes an earlier xterm bubble onData listener', () => {
+ const consumed: boolean[] = [];
+ const h = harness({}, (textarea, getController) => {
+ textarea.addEventListener('keydown', () => {
+ const controller = getController() as { consumeTerminalData(data: string): boolean };
+ consumed.push(controller.consumeTerminalData('確定'));
+ });
+ });
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionupdate', { data: '確定' });
+ h.textarea.dispatch('keydown', { key: 'Enter', isComposing: false });
+
+ expect(consumed).toEqual([true]);
+ expect(h.controller.consumeTerminalData('確定')).toBe(false);
+ expect(h.onCommit).toHaveBeenCalledOnce();
+
+ h.controller.destroy();
+ h.textarea.dispatch('compositionstart');
+ h.textarea.dispatch('compositionupdate', { data: 'later' });
+ h.textarea.dispatch('keydown', { key: 'Enter', isComposing: false });
+ expect(consumed).toEqual([true, false]);
+ expect(h.onCommit).toHaveBeenCalledOnce();
+ });
+
+ 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.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.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(6);
+ h.controller.destroy();
+ h.controller.destroy();
+ expect(h.textarea.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);
+ });
+});