From e0542bb1725a166e3e043d96e6299d115490fa71 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 26 Sep 2026 17:06:11 -0400 Subject: [PATCH 1/3] feat(terminal): preview IME composition text on iOS Safari WebKit on iOS does not show text being composed by an IME inside the terminal, so users type blind until it commits. Add mobile-ime-preview.js, a visual-only controller that renders the composition in the xterm helper layer and holds a committed chunk until local echo, parsed terminal output or a 2s fallback shows it. Wire it into terminal-ui.js, the script order, the build minify/hash lists and styles, with unit and wiring tests. --- CLAUDE.md | 2 +- scripts/build.mjs | 2 + src/web/public/index.html | 2 + src/web/public/mobile-ime-preview.js | 260 ++++++++++ src/web/public/styles.css | 25 + src/web/public/terminal-ui.js | 215 +++++++- test/mobile-ime-preview-structure.test.ts | 595 ++++++++++++++++++++++ test/mobile-ime-preview.test.ts | 502 ++++++++++++++++++ 8 files changed, 1601 insertions(+), 2 deletions(-) create mode 100644 src/web/public/mobile-ime-preview.js create mode 100644 test/mobile-ime-preview-structure.test.ts create mode 100644 test/mobile-ime-preview.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index c4a910ccf..6c8a8dc9f 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 2ab8ecd5a..3422efe0c 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -83,6 +83,7 @@ appendFileSync( // 4. Minify frontend assets run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite'); +run('minify mobile-ime-preview.js', 'npx esbuild dist/web/public/mobile-ime-preview.js --minify --outfile=dist/web/public/mobile-ime-preview.js --allow-overwrite'); run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite'); run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite'); run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite'); @@ -111,6 +112,7 @@ console.log('\n[build] content-hash cache busting'); 'notification-manager.js', 'keyboard-accessory.js', 'input-cjk.js', + 'mobile-ime-preview.js', 'terminal-keycode229-recovery.js', 'sanitize-html.js', 'app.js', diff --git a/src/web/public/index.html b/src/web/public/index.html index a5009b53f..435605354 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3735,6 +3735,8 @@

Read My Mind

+ + diff --git a/src/web/public/mobile-ime-preview.js b/src/web/public/mobile-ime-preview.js new file mode 100644 index 000000000..c18ece584 --- /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 050671a48..0a5c71782 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -460,6 +460,31 @@ textarea:focus-visible { font-size: 16px !important; /* prevent iOS auto-zoom on focus */ } +.xterm-helpers .codeman-ime-preview { + position: absolute; + left: var(--xterm-helper-left, 0px); + top: var(--xterm-helper-top, 0px); + z-index: 6; + pointer-events: none; + white-space: pre; + color: var(--terminal-foreground, var(--text, #fff)); + font-family: var(--font-mono, monospace); + font-size: 14px; + font-weight: 400; + font-style: normal; + line-height: 1.2; + height: 1.2em; +} + +.xterm-helpers .codeman-ime-preview[data-phase='provisional'] { + text-decoration: underline; + text-decoration-style: dotted; +} + +.touch-device .xterm-helpers.codeman-ime-preview-owned .composition-view.active { + display: none; +} + /* Session tab focus */ .session-tab:focus-visible { outline: 2px solid var(--accent); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 903661e52..9413522d0 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 000000000..5daa5c5c7 --- /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 000000000..8b52b35a3 --- /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); + }); +}); From 2d96472dbe5f19e90bfd7db3075969fcdcb268cd Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 26 Sep 2026 22:47:48 -0400 Subject: [PATCH 2/3] fix(terminal): address review of the iOS IME preview - Observe keydown in the capture phase on terminal.element, an ancestor of the helper textarea, so the controller sees it before xterm's own capture listener finalizes the composition and emits the commit through onData. Finalize on exactly the keys CompositionHelper.keydown does (every keyCode except 20/229/16/17/18), ignoring isComposing and key as xterm does. - Bound awaitingCommit with the same 2 s fallback as the committed phase, so a composition whose commit never reaches onData cannot turn the next unrelated keystroke or paste into an IME commit. - pagehide resets the controller instead of destroying it, so a back-forward cache restore keeps the preview working. - Give the preview an opaque background from the terminal theme. - Route an IME commit through the ordinary printable/paste local echo branch and complete the commit afterwards; drop the send-on-throw fallback. - Pin the event order with an xterm stand-in registered in the capture phase ahead of the controller, and against real xterm in a browser test. - CLAUDE.md: note the IME commit routing and the z-index 6 preview layer. --- CLAUDE.md | 4 +- config/test-suites.ts | 1 + src/web/public/mobile-ime-preview.js | 93 ++++++--- src/web/public/terminal-ui.js | 54 +++--- test/mobile-ime-preview-structure.test.ts | 67 ++++++- test/mobile-ime-preview.browser.test.ts | 128 +++++++++++++ test/mobile-ime-preview.test.ts | 220 +++++++++++++++++++--- 7 files changed, 479 insertions(+), 88 deletions(-) create mode 100644 test/mobile-ime-preview.browser.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6c8a8dc9f..02cde9065 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) → `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. +Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea. **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on ``; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations) @@ -372,7 +372,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](docs/architecture-invariants.md#sse-staleness-watchdog) -**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers) +**Z-index layers** (keep new overlays consistent with this stack): iOS IME composition preview (6, inside `.xterm-helpers`, just under the local echo overlay), 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) **Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min). diff --git a/config/test-suites.ts b/config/test-suites.ts index 9f3ed295a..0d1eac00d 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -33,6 +33,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/split-pane-terminal.browser.test.ts', 'test/split-pane-orchestration.browser.test.ts', 'test/split-pane-auto-collapse.browser.test.ts', + 'test/mobile-ime-preview.browser.test.ts', ]; /** diff --git a/src/web/public/mobile-ime-preview.js b/src/web/public/mobile-ime-preview.js index c18ece584..42826c8a1 100644 --- a/src/web/public/mobile-ime-preview.js +++ b/src/web/public/mobile-ime-preview.js @@ -10,7 +10,18 @@ * 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. + * 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. @@ -25,6 +36,10 @@ 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) { @@ -38,6 +53,9 @@ 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 () {}; @@ -135,6 +153,25 @@ 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; @@ -143,6 +180,13 @@ 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) { @@ -153,10 +197,15 @@ 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 (composing && event.isComposing === false && event.key !== 'Process' && event.key !== 'Unidentified') { - finalizeComposition(latestValue, true); - } + if (keydownTarget !== textarea && event.target !== textarea) return; + if (!composing || KEEP_COMPOSING_KEYCODES.has(event.keyCode)) return; + finalizeComposition(latestValue, true); } function reset() { @@ -190,22 +239,10 @@ 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; - } - } + const armed = armFallbackTimer(function () { + return committed; + }); + if (!armed && !destroyed && generation === owner && committed) cleanup(); return true; } @@ -220,19 +257,19 @@ } const listeners = [ - ['compositionstart', beginComposition], - ['compositionupdate', updateComposition], - ['input', onComposingInput], - ['compositionend', onCompositionEnd], - ['keydown', onKeydown, true], - ['blur', reset], + [textarea, 'compositionstart', beginComposition], + [textarea, 'compositionupdate', updateComposition], + [textarea, 'input', onComposingInput], + [textarea, 'compositionend', onCompositionEnd], + [keydownTarget, 'keydown', onKeydown, true], + [textarea, 'blur', reset], ]; - for (const [type, listener, capture] of listeners) textarea.addEventListener(type, listener, capture); + for (const [target, type, listener, capture] of listeners) target.addEventListener(type, listener, capture); function destroy() { if (destroyed) return; destroyed = true; - for (const [type, listener, capture] of listeners) textarea.removeEventListener(type, listener, capture); + for (const [target, type, listener, capture] of listeners) target.removeEventListener(type, listener, capture); cleanup(); } diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 9413522d0..7942255a7 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -319,15 +319,22 @@ Object.assign(CodemanApp.prototype, { const value = style?.[property] || compositionView.style?.[property]; if (value) preview.style[property] = value; } - let foreground = this.terminal?.options?.theme?.foreground; - if (!foreground) { + const theme = this.terminal?.options?.theme; + let foreground = theme?.foreground; + let background = theme?.background; + if (!foreground || !background) { try { - foreground = window.codemanCurrentXtermTheme?.()?.foreground; + 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. } @@ -348,6 +355,10 @@ Object.assign(CodemanApp.prototype, { }; 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 { syncPreviewTypography(); @@ -364,6 +375,9 @@ Object.assign(CodemanApp.prototype, { 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?.(); @@ -371,7 +385,7 @@ Object.assign(CodemanApp.prototype, { // Disconnect cleanup is visual-only. } }; - this._mobileImePreviewPagehideHandler = () => this._destroyMobileImePreview(); + this._mobileImePreviewPagehideHandler = this._mobileImePreviewOfflineHandler; window.addEventListener('offline', this._mobileImePreviewOfflineHandler); window.addEventListener('pagehide', this._mobileImePreviewPagehideHandler); } catch { @@ -414,23 +428,18 @@ Object.assign(CodemanApp.prototype, { } }, - /** 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; - } + /** + * 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. } - return true; }, initTerminal() { @@ -1474,16 +1483,9 @@ 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 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) { + // 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(); if (source === 'flushed') { @@ -1539,6 +1541,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) { @@ -1684,6 +1687,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; } } diff --git a/test/mobile-ime-preview-structure.test.ts b/test/mobile-ime-preview-structure.test.ts index 5daa5c5c7..d673fa67d 100644 --- a/test/mobile-ime-preview-structure.test.ts +++ b/test/mobile-ime-preview-structure.test.ts @@ -72,6 +72,7 @@ function createPreviewHarness( createThrows?: boolean; omitGlobal?: boolean; themeForeground?: string; + themeBackground?: string; themeGetterThrows?: boolean; } = {} ) { @@ -140,12 +141,17 @@ function createPreviewHarness( }); windowStub.codemanCurrentXtermTheme = () => { if (options.themeGetterThrows) throw new Error('theme unavailable'); - return { foreground: '#334455' }; + return { foreground: '#334455', background: '#223344' }; }; const app: App = Object.assign(Object.create(mixin), { terminal: { textarea: {}, - options: { theme: options.themeForeground ? { foreground: options.themeForeground } : undefined }, + 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', @@ -202,6 +208,9 @@ describe('mobile IME preview lifecycle', () => { 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'); @@ -233,6 +242,21 @@ describe('mobile IME preview lifecycle', () => { 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(); @@ -256,9 +280,10 @@ describe('mobile IME preview lifecycle', () => { expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(false); }); - it('uses the terminal foreground while mirroring native composition font metrics', () => { + 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' }); @@ -270,15 +295,24 @@ describe('mobile IME preview lifecycle', () => { 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)( @@ -464,17 +498,34 @@ describe('mobile IME commit onData routing', () => { }); 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) => { + ])('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)).not.toThrow(); - expect(sent).toEqual([text]); - // Nothing else shows the text yet, so the preview keeps it. + 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'); diff --git a/test/mobile-ime-preview.browser.test.ts b/test/mobile-ime-preview.browser.test.ts new file mode 100644 index 000000000..504282d13 --- /dev/null +++ b/test/mobile-ime-preview.browser.test.ts @@ -0,0 +1,128 @@ +/** + * 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 { resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; + +const root = resolve(import.meta.dirname, '..'); + +type Step = + | ['start'] + | ['update', string] + | ['end', string] + | ['key', number, string, boolean] + | ['wait', number] + | ['consume', string]; + +describe('mobile IME preview with real xterm', () => { + let browser: Browser; + let page: Page; + + beforeAll(async () => { + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + await page.setContent('
'); + await page.addScriptTag({ path: resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js') }); + await page.addScriptTag({ path: resolve(root, 'src/web/public/mobile-ime-preview.js') }); + }, 60000); + + afterAll(async () => { + if (browser) await browser.close(); + }); + + async function drive(steps: Step[]) { + return page.evaluate(async (steps: Step[]) => { + const w = window as any; + const host = document.getElementById('t') as HTMLElement; + host.innerHTML = ''; + const term = new w.Terminal(); + term.open(host); + const textarea = term.textarea as HTMLTextAreaElement; + const renders: Array<{ text: string; phase: string }> = []; + const onData: Array<{ data: string; consumed: boolean }> = []; + const controller = w.MobileImePreview.create({ + textarea, + keydownTarget: term.element, + render: (r: { text: string; phase: string }) => renders.push(r), + clear: () => {}, + }); + term.onData((data: string) => onData.push({ data, consumed: controller.consumeTerminalData(data) })); + textarea.focus(); + const tick = (ms: number) => new Promise((r) => setTimeout(r, ms)); + for (const step of steps) { + if (step[0] === 'start') textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' })); + if (step[0] === 'update') { + textarea.value = step[1]; + textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: step[1] })); + } + if (step[0] === 'end') textarea.dispatchEvent(new CompositionEvent('compositionend', { data: step[1] })); + if (step[0] === 'key') { + const [, keyCode, key, isComposing] = step; + const event = new KeyboardEvent('keydown', { key, isComposing, bubbles: true, cancelable: true }); + Object.defineProperty(event, 'keyCode', { get: () => keyCode }); + textarea.dispatchEvent(event); + } + if (step[0] === 'wait') await tick(step[1]); + if (step[0] === 'consume') onData.push({ data: step[1], consumed: controller.consumeTerminalData(step[1]) }); + } + await tick(20); + const { composing, awaitingCommit, committed, latest } = controller.state; + const result = { onData, state: { composing, awaitingCommit, committed, latest }, lastRender: renders.at(-1) }; + controller.destroy(); + term.dispose(); + return result; + }, steps); + } + + it('Enter mid-composition: xterm emits the commit and the controller takes it as committed', async () => { + // compositionupdate's textarea end offset is recorded by xterm on a 0 ms timer. + const result = await drive([['start'], ['update', '確定'], ['wait', 10], ['key', 13, 'Enter', false]]); + expect(result.onData).toEqual([ + { data: '確定', consumed: true }, + { data: '\r', consumed: false }, + ]); + expect(result.state).toMatchObject({ awaitingCommit: false, committed: true, latest: '確定' }); + expect(result.lastRender).toEqual({ text: '確定', phase: 'committed' }); + }); + + it('keyCode 229 with isComposing false: xterm keeps composing, so the preview keeps following', async () => { + const result = await drive([ + ['start'], + ['update', 'か'], + ['wait', 10], + ['key', 229, 'k', false], + ['update', 'かな'], + ]); + expect(result.onData).toEqual([]); + expect(result.state).toMatchObject({ composing: true, awaitingCommit: false, latest: 'かな' }); + expect(result.lastRender).toEqual({ text: 'かな', phase: 'provisional' }); + }); + + it('a deleted composition stops waiting after 2 s, so a later paste is not taken as its commit', async () => { + const result = await drive([ + ['start'], + ['update', 'abc'], + ['update', ''], + ['end', ''], + ['wait', 2100], + ['consume', 'pasted'], + ]); + expect(result.onData).toEqual([{ data: 'pasted', consumed: false }]); + expect(result.state).toMatchObject({ awaitingCommit: false, committed: false }); + }); +}); diff --git a/test/mobile-ime-preview.test.ts b/test/mobile-ime-preview.test.ts index 8b52b35a3..cfdec9a3e 100644 --- a/test/mobile-ime-preview.test.ts +++ b/test/mobile-ime-preview.test.ts @@ -1,15 +1,21 @@ import { readFileSync } from 'node:fs'; import vm from 'node:vm'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; type Listener = (event: Record) => void; type ListenerOptions = boolean | { capture?: boolean }; type RegisteredListener = { listener: Listener; capture: boolean }; -class FakeTextarea { - value = 'unchanged'; - private listeners = new Map(); +/** + * A DOM node with just enough event dispatch to reproduce listener ORDER: an + * ancestor's capture listeners, then the target's capture listeners, then the + * target's bubble listeners (at-target capture-first, as in Chromium 89+ and + * WebKit), then the ancestor's bubble listeners. + */ +class FakeNode { + parent: FakeNode | null = null; + protected listeners = new Map(); addEventListener(type: string, listener: Listener, options?: ListenerOptions) { const listeners = this.listeners.get(type) ?? []; @@ -26,31 +32,46 @@ class FakeTextarea { 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 }); - } + run(type: string, capture: boolean, event: Record) { + for (const registered of [...(this.listeners.get(type) ?? [])]) { + if (registered.capture === capture) registered.listener(event); } } + dispatch(type: string, event: Record = {}) { + const full = { type, target: this, ...event }; + const ancestors: FakeNode[] = []; + for (let node = this.parent; node; node = node.parent) ancestors.unshift(node); + for (const ancestor of ancestors) ancestor.run(type, true, full); + this.run(type, true, full); + this.run(type, false, full); + for (const ancestor of [...ancestors].reverse()) ancestor.run(type, false, full); + } + listenerCount() { return [...this.listeners.values()].reduce((total, listeners) => total + listeners.length, 0); } } +class FakeTextarea extends FakeNode { + value = 'unchanged'; +} + type Scheduled = { id: number; callback: () => void; delay?: number }; function harness( overrides: Record = {}, - beforeCreate?: (textarea: FakeTextarea, getController: () => Record | undefined) => void + beforeCreate?: (textarea: FakeTextarea, getController: () => Record | undefined) => void ) { const source = readFileSync(new URL('../src/web/public/mobile-ime-preview.js', import.meta.url), 'utf8'); const context = vm.createContext({ navigator: {} }); vm.runInContext(source, context, { filename: 'mobile-ime-preview.js' }); const api = vm.runInContext('MobileImePreview', context); + // The terminal element: an ancestor of the helper textarea, like xterm's + // `.xterm` root is of `.xterm-helper-textarea`. + const element = new FakeNode(); const textarea = new FakeTextarea(); + textarea.parent = element; const frames: Scheduled[] = []; const timers: Scheduled[] = []; let nextId = 1; @@ -79,6 +100,7 @@ function harness( beforeCreate?.(textarea, () => controller); controller = api.create({ textarea, + keydownTarget: element, render, clear, onCommit, @@ -93,6 +115,7 @@ function harness( return { api, + element, textarea, frames, timers, @@ -222,34 +245,177 @@ describe('MobileImePreview', () => { const h = harness(); h.textarea.dispatch('compositionstart'); h.textarea.dispatch('compositionupdate', { data: '確定' }); - h.textarea.dispatch('keydown', { key: 'Enter', isComposing: false }); + 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); }); - test('capture keydown finalization precedes an earlier xterm bubble onData listener', () => { - const consumed: boolean[] = []; + /** + * 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) => { - textarea.addEventListener('keydown', () => { - const controller = getController() as { consumeTerminalData(data: string): boolean }; - consumed.push(controller.consumeTerminalData('確定')); + 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.textarea.dispatch('keydown', { key: 'Enter', isComposing: false }); + 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' }); - expect(consumed).toEqual([true]); - expect(h.controller.consumeTerminalData('確定')).toBe(false); - expect(h.onCommit).toHaveBeenCalledOnce(); + // 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', isComposing: false }); - expect(consumed).toEqual([true, false]); - expect(h.onCommit).toHaveBeenCalledOnce(); + h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false }); + expect(h.emitted).toEqual([ + { data: 'later', consumed: false }, + { data: '\r', consumed: false }, + ]); + expect(h.onCommit).not.toHaveBeenCalled(); + expect(h.element.listenerCount()).toBe(0); + }); + + describe('a commit that never reaches onData', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + function realTimerHarness() { + return harness({ + setTimer: (callback: () => void, delay: number) => setTimeout(callback, delay), + clearTimer: (id: ReturnType) => clearTimeout(id), + }); + } + + test.each([ + ['compositionend', (h: ReturnType) => h.textarea.dispatch('compositionend', { data: '' })], + [ + 'a finalizing keydown', + (h: ReturnType) => h.textarea.dispatch('keydown', { key: 'Enter', keyCode: 13 }), + ], + ])('stops waiting after the same 2 s bound when finalized by %s', (_label, finalize) => { + const h = realTimerHarness(); + h.textarea.dispatch('compositionstart'); + h.textarea.dispatch('compositionupdate', { data: 'deleted' }); + finalize(h); + h.clear.mockClear(); + expect(h.controller.state).toMatchObject({ awaitingCommit: true, timerPending: true }); + + vi.advanceTimersByTime(1999); + expect(h.controller.state.awaitingCommit).toBe(true); + vi.advanceTimersByTime(1); + expect(h.controller.state).toMatchObject({ awaitingCommit: false, latest: '', timerPending: false }); + expect(h.clear).toHaveBeenCalledOnce(); + + // The next unrelated keystroke or paste is not adopted as the IME commit. + expect(h.controller.consumeTerminalData('x')).toBe(false); + expect(h.controller.consumeTerminalData('pasted line')).toBe(false); + expect(h.onCommit).not.toHaveBeenCalled(); + }); + + test('a commit that arrives in time replaces the wait bound with the committed one', () => { + const h = realTimerHarness(); + h.textarea.dispatch('compositionstart'); + h.textarea.dispatch('compositionend', { data: '日本' }); + vi.advanceTimersByTime(1500); + expect(h.controller.consumeTerminalData('日本')).toBe(true); + // The wait bound would have fired at 2000 ms; the committed bound runs + // a full 2 s from the commit instead. + vi.advanceTimersByTime(1000); + expect(h.controller.state.committed).toBe(true); + vi.advanceTimersByTime(1000); + expect(h.controller.state.committed).toBe(false); + }); + + test('a new composition cancels the previous wait bound', () => { + const h = realTimerHarness(); + h.textarea.dispatch('compositionstart'); + h.textarea.dispatch('compositionend', { data: '' }); + vi.advanceTimersByTime(1500); + h.textarea.dispatch('compositionstart'); + h.textarea.dispatch('compositionupdate', { data: 'next' }); + vi.advanceTimersByTime(1000); + expect(h.controller.state).toMatchObject({ composing: true, latest: 'next' }); + }); }); test('generation fences stale frames and timers', () => { @@ -332,6 +498,7 @@ describe('MobileImePreview', () => { 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(''); @@ -353,6 +520,7 @@ describe('MobileImePreview', () => { 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); }); @@ -453,10 +621,12 @@ describe('MobileImePreview', () => { const h = harness(); h.textarea.dispatch('compositionstart'); h.textarea.dispatch('compositionupdate', { data: 'pending' }); - expect(h.textarea.listenerCount()).toBe(6); + 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(); From ee1a155e2cc22d3fb2abf40cdd079fe6d89dc73e Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 27 Sep 2026 07:56:22 -0400 Subject: [PATCH 3/3] fix(terminal): draw IME preview after local-echo text With local echo on, committed text sits in the LocalEchoOverlay and does not reach the PTY before Enter, so the PTY cursor that places the preview span stays at the prompt start. The span's z-index 6 only counts inside .xterm-helpers (its own z-index 5 stacking context), and the overlay is a z-index 7 layer whose first line is opaque from the prompt column, so every composition after the first one in a prompt was drawn under the overlay. - xterm-zerolag-input: add setComposition(text) and a composition getter. The overlay draws the composition as an underlined, aria-hidden tail after its pending text, through the same wrapping and grow-upward layout. It is never part of pendingText, hasPending or anything sent; clear() and removeChar() drop it, and rerender()/refreshFont() keep it. - terminal-ui.js: while local echo shows typed text (on, and not handed back to PTY echo by a nav key), render and clear the preview through setComposition. The helper span stays for local echo off, and as the fallback when the overlay cannot place the text (no prompt found). - Browser test against real xterm 6, the overlay bundled from its source and styles.css: a second composition after pending text is the topmost element after that text, and the commit lands in the overlay once. Unit tests for setComposition in the package and for the routing in the structure test. - CLAUDE.md and architecture-invariants: state the preview's effective layer. --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- .../src/overlay-renderer.ts | 46 +++- packages/xterm-zerolag-input/src/types.ts | 6 + .../src/zerolag-input-addon.ts | 58 ++++- .../test/composition.test.ts | 215 ++++++++++++++++++ src/web/public/terminal-ui.js | 47 +++- test/mobile-ime-preview-structure.test.ts | 57 +++++ test/mobile-ime-preview.browser.test.ts | 164 +++++++++++++ 9 files changed, 579 insertions(+), 18 deletions(-) create mode 100644 packages/xterm-zerolag-input/test/composition.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 02cde9065..0fa36dfaa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -372,7 +372,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` can stop delivering without erroring, so the client forces a reconnect when nothing arrives. ⚠️ The server keepalive must stay the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), never an SSE comment, which `EventSource` cannot observe; its no-op client listener must stay registered. ⚠️ Judge staleness only while `connected` and online (the loop breaker). ⚠️ The liveness stamp lives inside `addListener`. ⚠️ Clear the interval only at the top of `connectSSE()`, or intervals stack. → [architecture-invariants#sse-staleness-watchdog](docs/architecture-invariants.md#sse-staleness-watchdog) -**Z-index layers** (keep new overlays consistent with this stack): iOS IME composition preview (6, inside `.xterm-helpers`, just under the local echo overlay), local echo overlay (7), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers) +**Z-index layers** (keep new overlays consistent with this stack): local echo overlay (7; with local echo on it also draws the iOS IME composition preview, as an underlined tail after its pending text via `setComposition`), iOS IME composition preview span when local echo is off (6 inside `.xterm-helpers`, whose own z-index 5 is its EFFECTIVE layer, so it sits UNDER the overlay and must never be used while the overlay shows text), terminal touch-selection bar (900, below floating agent windows), subagent windows + split picker menu (1000), plan agents (1100), mobile/tablet fixed header (1200), modals on ≤768px (1300, must beat the fixed header), log viewers (2000), connection-loss overlay (2500), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100, must outrank the response viewer that launches it), toasts/path picker (10000+), custom-model center-status banner (10001; its `[hidden]` must re-assert `display: none` or `dismiss()` leaves an invisible click-blocker), custom-model swap-confirm/context-warning modals (10010). → [architecture-invariants#z-index-layers](docs/architecture-invariants.md#z-index-layers) **Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min). diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 6a25c5b34..402a5df59 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -949,7 +949,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u ### Z-index layers -**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7). +**Z-index layers**: subagent windows (1000), split picker menu (1000, `.split-picker-menu`), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), the custom-model center-status banner (10001, `.center-status-banner` — `[hidden]` must re-assert `display: none` over its own `display: flex`, same trap as `.home-sessions[hidden]`, or `dismiss()` leaves an invisible click-blocker dead centre on screen), the swap-confirm and context-warning modals (10010, `#customModelSwapConfirmModal`/`#customModelContextWarningModal` — must clear both the plain `.modal` z-index of 1000 and the center-status banner it can appear over), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7), iOS IME composition preview (EFFECTIVE layer depends on its home: with local echo on it is part of the local echo overlay at 7, drawn by `setComposition()` as an underlined tail after the pending text; with local echo off it is a span at z-index 6 inside `.xterm-helpers`, but `.xterm-helpers` is its own z-index 5 stacking context, so the span's effective layer is 5. That is below the overlay's 7, which is why the span cannot be used while the overlay holds text: typed text never reaches the PTY before Enter, the PTY cursor that places the span stays at the prompt start, and the overlay's opaque line div covers it). ## Security layers diff --git a/packages/xterm-zerolag-input/src/overlay-renderer.ts b/packages/xterm-zerolag-input/src/overlay-renderer.ts index d88c87430..7e8d7a07b 100644 --- a/packages/xterm-zerolag-input/src/overlay-renderer.ts +++ b/packages/xterm-zerolag-input/src/overlay-renderer.ts @@ -58,6 +58,7 @@ export function stringCellWidth(terminal: XtermTerminal | null | undefined, str: export function renderOverlay(container: HTMLDivElement, params: RenderParams): void { const { lines, + compositionStart, startCol, totalCols, cellW, @@ -90,12 +91,24 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams): // `startCol` indents only the line that begins at the prompt marker, so it is // dropped along with that line when the tail is all that fits. const rows = totalRows && totalRows > 0 ? totalRows : terminal?.rows; + // Code-point offset of each line in the whole text, so the composition + // styling survives the tail slice below. + const lineOffsets: number[] = []; + { + let offset = 0; + for (const line of lines) { + lineOffsets.push(offset); + offset += [...line].length; + } + } let visibleLines = lines; + let firstVisible = 0; let keepsPromptLine = true; let topRow = promptRow; if (rows && rows > 0) { if (lines.length > rows) { - visibleLines = lines.slice(lines.length - rows); + firstVisible = lines.length - rows; + visibleLines = lines.slice(firstVisible); keepsPromptLine = false; topRow = 0; } else if (promptRow + lines.length > rows) { @@ -116,7 +129,21 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams): const leftPx = indents ? startCol * cellW : 0; const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx; const topPx = i * cellH; - const lineEl = makeLine(visibleLines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal); + const lineCompositionFrom = + compositionStart === undefined ? undefined : compositionStart - lineOffsets[firstVisible + i]; + const lineEl = makeLine( + visibleLines[i], + leftPx, + topPx, + widthPx, + cellH, + cellW, + charTop, + charHeight, + font, + terminal, + lineCompositionFrom + ); container.appendChild(lineEl); } @@ -144,7 +171,10 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams): * Create a styled line `
` with per-character grid positioning. * * Each character gets its own `` positioned by visual column offset. - * CJK wide characters occupy 2 cell widths. + * CJK wide characters occupy 2 cell widths. Characters at or after + * `compositionFrom` (a code-point index into `text`, may be negative) are IME + * composition text: underlined, like xterm's own composition view, and marked + * `data-zerolag-composition` + `aria-hidden` since they are provisional. */ function makeLine( text: string, @@ -156,7 +186,8 @@ function makeLine( _charTop: number, _charHeight: number, font: FontStyle, - terminal?: XtermTerminal | null + terminal?: XtermTerminal | null, + compositionFrom?: number ): HTMLDivElement { const el = document.createElement('div'); el.style.cssText = 'position:absolute;pointer-events:none'; @@ -172,6 +203,7 @@ function makeLine( // CJK wide chars occupy 2 cells — position by visual column offset let colOffset = 0; + let index = 0; for (const ch of text) { const cw = charCellWidth(terminal, ch); const span = document.createElement('span'); @@ -189,9 +221,15 @@ function makeLine( span.style.fontWeight = font.fontWeight; span.style.color = font.color; if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing; + if (compositionFrom !== undefined && index >= compositionFrom) { + span.style.textDecoration = 'underline'; + span.setAttribute('data-zerolag-composition', ''); + span.setAttribute('aria-hidden', 'true'); + } span.textContent = ch; el.appendChild(span); colOffset += cw; + index++; } return el; diff --git a/packages/xterm-zerolag-input/src/types.ts b/packages/xterm-zerolag-input/src/types.ts index bb9f7fe19..e81d610dd 100644 --- a/packages/xterm-zerolag-input/src/types.ts +++ b/packages/xterm-zerolag-input/src/types.ts @@ -163,6 +163,12 @@ export interface CellDimensions { /** Parameters for the overlay renderer. */ export interface RenderParams { lines: string[]; + /** + * Index (in code points, across all `lines`) where IME composition text + * begins. Characters from there on are drawn underlined and marked + * `data-zerolag-composition`. Omit when nothing is being composed. + */ + compositionStart?: number; startCol: number; totalCols: number; cellW: number; diff --git a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts index b1e4105b3..ce7831cb9 100644 --- a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts +++ b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts @@ -67,6 +67,8 @@ export class ZerolagInputAddon implements XtermAddon { private _flushedOffset = 0; private _flushedText = ''; private _bufferDetectDone = false; + // IME text still being composed: drawn after the pending text, never sent. + private _composition = ''; // Render cache private _lastRenderKey = ''; @@ -130,7 +132,7 @@ export class ZerolagInputAddon implements XtermAddon { clearTimeout(this._scrollTimer); this._scrollTimer = null; } - } else if (this._pendingText || this._flushedOffset > 0) { + } else if (this._hasContent()) { if (this._scrollTimer) clearTimeout(this._scrollTimer); this._scrollTimer = setTimeout(() => { this._scrollTimer = null; @@ -208,6 +210,8 @@ export class ZerolagInputAddon implements XtermAddon { * - `false`: Nothing to remove. The consumer should NOT send backspace. */ removeChar(): 'pending' | 'flushed' | false { + // A backspace that reaches the overlay means no composition is open. + this._composition = ''; if (this._pendingText.length > 0) { this._pendingText = this._pendingText.slice(0, -1); if (this._pendingText.length > 0 || this._flushedOffset > 0) { @@ -252,6 +256,7 @@ export class ZerolagInputAddon implements XtermAddon { */ clear(): void { this._pendingText = ''; + this._composition = ''; this._flushedOffset = 0; this._flushedText = ''; this._bufferDetectDone = false; @@ -297,7 +302,7 @@ export class ZerolagInputAddon implements XtermAddon { clearFlushed(): void { this._flushedOffset = 0; this._flushedText = ''; - if (this._pendingText) { + if (this._pendingText || this._composition) { this._render(); } else { this._hide(); @@ -312,7 +317,7 @@ export class ZerolagInputAddon implements XtermAddon { * that move the prompt. */ rerender(): void { - if (this._pendingText || this._flushedOffset > 0) { + if (this._hasContent()) { this._lastRenderKey = ''; this._render(); } @@ -325,7 +330,7 @@ export class ZerolagInputAddon implements XtermAddon { refreshFont(): void { this._cacheFont(); this._lastRenderKey = ''; - if (this._pendingText || this._flushedOffset > 0) this._render(); + if (this._hasContent()) this._render(); } // ─── Buffer detection ───────────────────────────────────────────── @@ -391,7 +396,37 @@ export class ZerolagInputAddon implements XtermAddon { this._options.prompt = finder; this._lastPromptPos = null; this._lastRenderKey = ''; - if (this._pendingText || this._flushedOffset > 0) this._render(); + if (this._hasContent()) this._render(); + } + + // ─── IME composition ────────────────────────────────────────────── + + /** + * Show text an IME is still composing as an underlined tail after the + * pending text, wrapped and kept on screen like the rest of the overlay. + * Pass `''` to remove it. + * + * Visual only: the composition is never part of `pendingText`, `hasPending` + * or anything a consumer sends. When the IME commits, the consumer adds the + * committed text the usual way (`addChar`/`appendText`) and clears the + * composition. `clear()` and `removeChar()` drop it too. + */ + setComposition(text: string): void { + // One visual line of provisional text: control characters and line breaks + // would break the cell grid. + const next = typeof text === 'string' ? text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, '') : ''; + if (next === this._composition) return; + this._composition = next; + if (this._hasContent()) { + this._render(); + } else { + this._hide(); + } + } + + /** Text an IME is still composing, drawn after `pendingText` (never sent). */ + get composition(): string { + return this._composition; } // ─── Prompt utilities ───────────────────────────────────────────── @@ -443,6 +478,10 @@ export class ZerolagInputAddon implements XtermAddon { // ─── Private methods ────────────────────────────────────────────── + private _hasContent(): boolean { + return this._pendingText.length > 0 || this._flushedOffset > 0 || this._composition.length > 0; + } + private _getPromptOffset(): number { const prompt = this._options.prompt ?? DEFAULT_PROMPT; return prompt.offset ?? 2; @@ -505,7 +544,7 @@ export class ZerolagInputAddon implements XtermAddon { private _render(): void { if (!this._terminal || !this._overlay) return; - if (!this._pendingText && !(this._flushedOffset > 0)) { + if (!this._hasContent()) { this._overlay.style.display = 'none'; return; } @@ -563,12 +602,16 @@ export class ZerolagInputAddon implements XtermAddon { } } + // The composition is a styled tail after everything the user has typed. + const compositionStart = [...displayText].length; + displayText += this._composition; + // Skip redundant re-renders — include text content to detect // same-length changes (e.g., setFlushed with different text) // `rows` is part of the key: the layout is clamped to the visible rows // (see renderOverlay), so a keyboard opening — which changes rows without // changing the text — must not be skipped as a redundant render. - const renderKey = `${displayText}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`; + const renderKey = `${displayText}:${compositionStart}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`; if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return; this._lastRenderKey = renderKey; @@ -608,6 +651,7 @@ export class ZerolagInputAddon implements XtermAddon { renderOverlay(this._overlay, { lines, + compositionStart: this._composition ? compositionStart : undefined, startCol, totalCols, cellW, diff --git a/packages/xterm-zerolag-input/test/composition.test.ts b/packages/xterm-zerolag-input/test/composition.test.ts new file mode 100644 index 000000000..4f7490d12 --- /dev/null +++ b/packages/xterm-zerolag-input/test/composition.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { createMockTerminal } from './helpers.js'; +import { ZerolagInputAddon } from '../src/zerolag-input-addon.js'; + +// setComposition(): IME text still being composed, drawn as an underlined tail +// after the pending text. Visual only, never part of what a consumer sends. + +const CELL_W = 10; + +let cleanups: (() => void)[] = []; + +afterEach(() => { + for (const fn of cleanups) fn(); + cleanups = []; +}); + +function setup(opts: { lines?: string[]; cols?: number; rows?: number } = {}) { + const mock = createMockTerminal({ + buffer: { lines: opts.lines ?? ['$ '] }, + cols: opts.cols, + rows: opts.rows, + cellWidth: CELL_W, + cellHeight: 20, + }); + const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 } }); + mock.terminal.loadAddon(addon); + cleanups.push(() => { + addon.dispose(); + mock.cleanup(); + }); + const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement; + return { addon, mock, overlay }; +} + +/** Line divs of the overlay (the block cursor is a bare span, not a div). */ +function lineDivs(overlay: HTMLDivElement): HTMLDivElement[] { + return Array.from(overlay.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[]; +} + +function lineText(line: HTMLDivElement): string { + return Array.from(line.children) + .map((s) => s.textContent) + .join(''); +} + +function compositionText(overlay: HTMLDivElement): string { + return Array.from(overlay.querySelectorAll('[data-zerolag-composition]')) + .map((s) => s.textContent) + .join(''); +} + +describe('setComposition', () => { + it('renders the composition after pendingText, underlined and aria-hidden', () => { + const { addon, overlay } = setup(); + addon.appendText('abc'); + addon.setComposition('xy'); + + const [line] = lineDivs(overlay); + expect(lineText(line)).toBe('abcxy'); + const spans = Array.from(line.children) as HTMLSpanElement[]; + for (const span of spans.slice(0, 3)) { + expect(span.hasAttribute('data-zerolag-composition')).toBe(false); + expect(span.style.textDecoration).toBe(''); + } + for (const span of spans.slice(3)) { + expect(span.hasAttribute('data-zerolag-composition')).toBe(true); + expect(span.getAttribute('aria-hidden')).toBe('true'); + expect(span.style.textDecoration).toBe('underline'); + } + // Grid positions continue straight on from the pending text. + expect(spans[3].style.left).toBe(3 * CELL_W + 'px'); + expect(spans[4].style.left).toBe(4 * CELL_W + 'px'); + expect(overlay.style.display).toBe(''); + }); + + it('places a wide composition by cell width after wide pending text', () => { + const { addon, overlay } = setup(); + addon.appendText('今日は'); + addon.setComposition('天気'); + const spans = Array.from(lineDivs(overlay)[0].children) as HTMLSpanElement[]; + expect(spans.map((s) => s.textContent).join('')).toBe('今日は天気'); + expect(spans[3].style.left).toBe(6 * CELL_W + 'px'); + expect(spans[3].style.width).toBe(2 * CELL_W + 'px'); + expect(spans[4].style.left).toBe(8 * CELL_W + 'px'); + }); + + it('does not touch pendingText, hasPending, flushed state or the state snapshot', () => { + const { addon } = setup(); + addon.appendText('abc'); + addon.setFlushed(2, 'zz'); + addon.setComposition('xy'); + expect(addon.pendingText).toBe('abc'); + expect(addon.getFlushed()).toEqual({ count: 2, text: 'zz' }); + expect(addon.composition).toBe('xy'); + expect(addon.state.pendingText).toBe('abc'); + expect(addon.state.flushedText).toBe('zz'); + }); + + it('shows on an empty prompt without making anything pending', () => { + const { addon, overlay } = setup(); + addon.setComposition('かな'); + expect(addon.pendingText).toBe(''); + expect(addon.hasPending).toBe(false); + expect(addon.state.visible).toBe(true); + expect(compositionText(overlay)).toBe('かな'); + }); + + it('wraps with the pending text: the tail continues onto the next line', () => { + // 12 cols, prompt at col 0 + offset 2 = 10 cells on the first line. + const { addon, overlay } = setup({ cols: 12 }); + addon.appendText('abcdefgh'); + addon.setComposition('WXYZ'); + const lines = lineDivs(overlay); + expect(lines.map(lineText)).toEqual(['abcdefghWX', 'YZ']); + expect(compositionText(overlay)).toBe('WXYZ'); + const second = Array.from(lines[1].children) as HTMLSpanElement[]; + expect(second.every((s) => s.hasAttribute('data-zerolag-composition'))).toBe(true); + expect(second[0].style.left).toBe('0px'); + }); + + it('keeps the composition styling when only the tail of a tall prompt fits', () => { + // 2 visible rows, 3 lines of text: the first line is dropped. + const { addon, overlay } = setup({ cols: 6, rows: 2 }); + addon.appendText('abcdefghij'); + addon.setComposition('XYZ'); + const lines = lineDivs(overlay); + expect(lines.map(lineText)).toEqual(['efghij', 'XYZ']); + expect(compositionText(overlay)).toBe('XYZ'); + const first = Array.from(lines[0].children); + expect(first.some((s) => s.hasAttribute('data-zerolag-composition'))).toBe(false); + }); + + it("setComposition('') removes the tail and keeps the pending text", () => { + const { addon, overlay } = setup(); + addon.appendText('abc'); + addon.setComposition('xy'); + addon.setComposition(''); + expect(lineText(lineDivs(overlay)[0])).toBe('abc'); + expect(compositionText(overlay)).toBe(''); + expect(addon.pendingText).toBe('abc'); + }); + + it("setComposition('') on an otherwise empty overlay hides it", () => { + const { addon, overlay } = setup(); + addon.setComposition('xy'); + addon.setComposition(''); + expect(overlay.style.display).toBe('none'); + expect(overlay.innerHTML).toBe(''); + }); + + it('clear() (Enter, Ctrl+C) drops the composition with everything else', () => { + const { addon, overlay } = setup(); + addon.appendText('abc'); + addon.setComposition('xy'); + addon.clear(); + expect(addon.composition).toBe(''); + expect(overlay.style.display).toBe('none'); + addon.addChar('q'); + expect(lineText(lineDivs(overlay)[0])).toBe('q'); + }); + + it('removeChar() drops the composition and removes a pending char, not a composed one', () => { + const { addon, overlay } = setup(); + addon.appendText('abc'); + addon.setComposition('xy'); + expect(addon.removeChar()).toBe('pending'); + expect(addon.pendingText).toBe('ab'); + expect(addon.composition).toBe(''); + expect(lineText(lineDivs(overlay)[0])).toBe('ab'); + }); + + it('text appended while composing lands before the tail', () => { + const { addon, overlay } = setup(); + addon.appendText('ab'); + addon.setComposition('xy'); + addon.addChar('c'); + expect(lineText(lineDivs(overlay)[0])).toBe('abcxy'); + expect(compositionText(overlay)).toBe('xy'); + }); + + it('rerender() and refreshFont() keep the composition', () => { + const { addon, overlay } = setup(); + addon.appendText('abc'); + addon.setComposition('xy'); + addon.rerender(); + expect(compositionText(overlay)).toBe('xy'); + addon.refreshFont(); + expect(compositionText(overlay)).toBe('xy'); + expect(lineText(lineDivs(overlay)[0])).toBe('abcxy'); + }); + + it('re-renders when only the composition changes', () => { + const { addon, overlay } = setup(); + addon.appendText('abc'); + addon.setComposition('x'); + addon.setComposition('xy'); + expect(lineText(lineDivs(overlay)[0])).toBe('abcxy'); + }); + + it('strips control characters and line breaks from the composition', () => { + const { addon, overlay } = setup(); + addon.setComposition('a\nb\u0007c
'); + expect(addon.composition).toBe('abc'); + expect(compositionText(overlay)).toBe('abc'); + }); + + it('draws the block cursor after the composition', () => { + const { addon, overlay } = setup(); + addon.appendText('ab'); + addon.setComposition('xy'); + const cursor = Array.from(overlay.children).find((el) => el.tagName === 'SPAN') as HTMLSpanElement; + // prompt col 0 + offset 2 + 4 cells + expect(cursor.style.left).toBe(6 * CELL_W + 'px'); + }); +}); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 7942255a7..5c48d5561 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -287,10 +287,19 @@ Object.assign(CodemanApp.prototype, { /** * 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. + * 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(); @@ -339,7 +348,18 @@ Object.assign(CodemanApp.prototype, { // Typography matching is visual-only and must not block input. } }; - const clearPreview = () => { + // 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 {} @@ -353,6 +373,10 @@ Object.assign(CodemanApp.prototype, { 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 @@ -361,6 +385,19 @@ Object.assign(CodemanApp.prototype, { 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; diff --git a/test/mobile-ime-preview-structure.test.ts b/test/mobile-ime-preview-structure.test.ts index d673fa67d..62f4db343 100644 --- a/test/mobile-ime-preview-structure.test.ts +++ b/test/mobile-ime-preview-structure.test.ts @@ -280,6 +280,63 @@ describe('mobile IME preview lifecycle', () => { 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', diff --git a/test/mobile-ime-preview.browser.test.ts b/test/mobile-ime-preview.browser.test.ts index 504282d13..864683adf 100644 --- a/test/mobile-ime-preview.browser.test.ts +++ b/test/mobile-ime-preview.browser.test.ts @@ -15,7 +15,9 @@ * 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'; @@ -126,3 +128,165 @@ describe('mobile IME preview with real xterm', () => { expect(result.state).toMatchObject({ awaitingCommit: false, committed: false }); }); }); + +/** + * The preview with local echo ON, the default for Claude sessions on phones. + * Committed text then sits in the LocalEchoOverlay (a z-index 7 layer in + * `.xterm-screen`) and never reaches the PTY before Enter, so the PTY cursor, + * which is where the helper span sits, stays at the prompt start: under the + * overlay's own opaque text. So a composition that follows text already in the + * overlay must be drawn by the overlay itself, after that text. + * + * Loads the real pieces: xterm 6, the overlay bundled from its package source + * exactly as scripts/postinstall.js bundles it (plus the same LocalEchoOverlay + * alias), styles.css, mobile-ime-preview.js, and terminal-ui.js's own + * `_initMobileImePreview` on a bare CodemanApp prototype. + */ +describe('mobile IME preview over the local echo overlay', () => { + let browser: Browser; + let page: Page; + + beforeAll(async () => { + const bundled = await build({ + entryPoints: [resolve(root, 'packages/xterm-zerolag-input/src/zerolag-input-addon.ts')], + bundle: true, + format: 'iife', + globalName: 'XtermZerolagInput', + write: false, + logLevel: 'silent', + }); + const overlayBundle = + bundled.outputFiles[0].text + + '\nwindow.ZerolagInputAddon=XtermZerolagInput.ZerolagInputAddon;' + + 'window.LocalEchoOverlay=class extends XtermZerolagInput.ZerolagInputAddon{' + + 'constructor(terminal){super({prompt:{type:"character",char:"\\u276f",offset:2}});this.activate(terminal);}};\n'; + + browser = await chromium.launch({ headless: true }); + page = await browser.newPage({ viewport: { width: 800, height: 400 }, deviceScaleFactor: 1 }); + await page.setContent( + '
' + ); + await page.addStyleTag({ path: resolve(root, 'node_modules/@xterm/xterm/css/xterm.css') }); + await page.addStyleTag({ content: readFileSync(resolve(root, 'src/web/public/styles.css'), 'utf8') }); + await page.addScriptTag({ path: resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js') }); + await page.addScriptTag({ content: overlayBundle }); + await page.addScriptTag({ path: resolve(root, 'src/web/public/mobile-ime-preview.js') }); + await page.addScriptTag({ content: 'window.CodemanApp = class CodemanApp {};' }); + await page.addScriptTag({ path: resolve(root, 'src/web/public/terminal-ui.js') }); + }, 60000); + + afterAll(async () => { + if (browser) await browser.close(); + }); + + /** + * Types `pending` into the overlay (as the printable/paste branch does), then + * composes `composing` and reports what is PAINTED at the cell right after + * the pending text and at the PTY cursor. Painted = topmost by hit-testing + * with pointer-events forced on, since the overlay and the preview are + * pointer-events:none. + */ + async function composeAfter(pending: string, composing: string, commit: boolean) { + return page.evaluate( + async ({ pending, composing, commit }) => { + const w = window as any; + const host = document.getElementById('t') as HTMLElement; + host.innerHTML = ''; + const term = new w.Terminal({ + cols: 40, + rows: 8, + fontSize: 14, + fontFamily: 'monospace', + allowProposedApi: true, + }); + term.open(host); + await new Promise((r) => term.write('\u276f ', () => r())); + const app = new w.CodemanApp(); + app.terminal = term; + app._localEchoEnabled = true; + app._localEchoOverlay = new w.LocalEchoOverlay(term); + w.MobileImePreview.isIosWebKitTouch = () => true; + app._initMobileImePreview(); + + // The helper textarea and span follow the PTY cursor (col 2, row 0), as + // _syncMobileHelperTextareaToCursor places them. + const screen = term.element.querySelector('.xterm-screen') as HTMLElement; + const dims = term._core._renderService.dimensions.css.cell; + term.element.style.setProperty('--xterm-helper-left', 2 * dims.width + 'px'); + term.element.style.setProperty('--xterm-helper-top', '0px'); + + if (pending) app._localEchoOverlay.appendText(pending); + const textarea = term.textarea as HTMLTextAreaElement; + textarea.focus(); + textarea.dispatchEvent(new CompositionEvent('compositionstart', { data: '' })); + textarea.value = composing; + textarea.dispatchEvent(new CompositionEvent('compositionupdate', { data: composing })); + await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20))); + + const force = document.createElement('style'); + force.textContent = '.xterm * { pointer-events: auto !important; }'; + document.head.appendChild(force); + const rect = screen.getBoundingClientRect(); + const widthOf = (s: string) => term._core.unicodeService.getStringCellWidth(s); + const paintedAt = (col: number) => { + const el = document.elementFromPoint( + rect.left + (col + 0.5) * dims.width, + rect.top + 0.5 * dims.height + ) as HTMLElement | null; + return { + text: el?.textContent ?? null, + composition: !!el?.closest?.('[data-zerolag-composition]'), + preview: !!el?.closest?.('.codeman-ime-preview'), + }; + }; + const afterPending = paintedAt(2 + widthOf(pending)); + force.remove(); + + let afterCommit = null; + if (commit) { + textarea.dispatchEvent(new CompositionEvent('compositionend', { data: composing })); + // What the printable/paste branch of terminal-ui.js's onData does. + if (app._consumeMobileImeTerminalData(composing)) { + app._localEchoOverlay.appendText(composing); + app._transferMobileImeCommitToLocalEcho(); + } + await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 20))); + afterCommit = { + pendingText: app._localEchoOverlay.pendingText, + compositionSpans: term.element.querySelectorAll('[data-zerolag-composition]').length, + overlayText: app._localEchoOverlay._overlay?.textContent, + }; + } + const result = { + afterPending, + pendingText: app._localEchoOverlay.pendingText, + afterCommit, + }; + app._destroyMobileImePreview(); + app._localEchoOverlay.dispose(); + term.dispose(); + return result; + }, + { pending, composing, commit } + ); + } + + it('first composition on an empty prompt: the overlay draws it at the prompt', async () => { + const result = await composeAfter('', '今日は', false); + expect(result.afterPending).toEqual({ text: '今', composition: true, preview: false }); + expect(result.pendingText).toBe(''); + }); + + it('a second composition is painted after the text already in the overlay, not under it', async () => { + const result = await composeAfter('今日は', '天気', false); + expect(result.afterPending.text).toBe('天'); + expect(result.afterPending.composition).toBe(true); + // Provisional text is never taken into the overlay's pending (unsent) text. + expect(result.pendingText).toBe('今日は'); + }); + + it('the commit lands once in the overlay and the composition tail is gone', async () => { + const result = await composeAfter('今日は', '天気', true); + expect(result.afterCommit).toEqual({ pendingText: '今日は天気', compositionSpans: 0, overlayText: '今日は天気' }); + }); +});