diff --git a/CLAUDE.md b/CLAUDE.md index c4a910cc..6c8a8dc9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -312,7 +312,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ### Frontend -Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. +Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on ``; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations) diff --git a/scripts/build.mjs b/scripts/build.mjs index 2ab8ecd5..3422efe0 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -83,6 +83,7 @@ appendFileSync( // 4. Minify frontend assets run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite'); +run('minify mobile-ime-preview.js', 'npx esbuild dist/web/public/mobile-ime-preview.js --minify --outfile=dist/web/public/mobile-ime-preview.js --allow-overwrite'); run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite'); run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite'); run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite'); @@ -111,6 +112,7 @@ console.log('\n[build] content-hash cache busting'); 'notification-manager.js', 'keyboard-accessory.js', 'input-cjk.js', + 'mobile-ime-preview.js', 'terminal-keycode229-recovery.js', 'sanitize-html.js', 'app.js', diff --git a/src/web/public/index.html b/src/web/public/index.html index a5009b53..43560535 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3735,6 +3735,8 @@ + + diff --git a/src/web/public/mobile-ime-preview.js b/src/web/public/mobile-ime-preview.js new file mode 100644 index 00000000..c18ece58 --- /dev/null +++ b/src/web/public/mobile-ime-preview.js @@ -0,0 +1,260 @@ +/** + * @fileoverview In-terminal preview of IME composition text on iOS Safari. + * + * On iOS WebKit touch devices the text an IME is composing (Japanese, Chinese, + * Korean, dictation) is not visible inside the terminal until it commits, so + * the user types blind. The controller listens to the helper textarea's + * composition events and asks the caller to render the latest composition + * (`phase: 'provisional'`), coalesced to one render per animation frame and + * capped at 2048 characters. When xterm emits the committed text through + * onData, the caller hands it to `consumeTerminalData()`, which switches the + * preview to `phase: 'committed'` until something else shows the text: the + * local echo overlay or a prediction (`completeCommit`), authoritative + * terminal output (`noteAuthoritativeOutput`), or a 2 s fallback timer. + * + * VISUAL ONLY: the controller never sends, consumes or reorders input bytes, + * and every callback is wrapped so a failing render cannot block the wire. + * `isIosWebKitTouch()` gates creation; other platforms keep xterm's own + * composition view untouched. + * + * @dependency none (standalone IIFE; consumed by terminal-ui.js) + * @loadorder 5.52 (before app.js/terminal-ui.js, which create the controller) + */ +(function (global) { + 'use strict'; + + const COMMITTED_VISUAL_TTL = 2000; + const PREVIEW_CAP = 2048; + const CONTROL_OR_LINE_BREAK = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/; + + function isIosWebKitTouch(nav = navigator) { + const userAgent = String(nav && nav.userAgent ? nav.userAgent : ''); + const platform = String(nav && nav.platform ? nav.platform : ''); + const touchPoints = Number(nav && nav.maxTouchPoints ? nav.maxTouchPoints : 0); + const iosDevice = /iPhone|iPad|iPod/.test(userAgent); + const desktopIpad = platform === 'MacIntel' && touchPoints > 1; + return touchPoints > 0 && /AppleWebKit/.test(userAgent) && (iosDevice || desktopIpad); + } + + function create(options) { + const textarea = options.textarea; + const render = typeof options.render === 'function' ? options.render : function () {}; + const clear = typeof options.clear === 'function' ? options.clear : function () {}; + const onCommit = typeof options.onCommit === 'function' ? options.onCommit : function () {}; + const scheduleFrame = options.scheduleFrame || global.requestAnimationFrame.bind(global); + const cancelFrame = options.cancelFrame || global.cancelAnimationFrame.bind(global); + const setTimer = options.setTimer || global.setTimeout.bind(global); + const clearTimer = options.clearTimer || global.clearTimeout.bind(global); + + let generation = 0; + let composing = false; + let awaitingCommit = false; + let committed = false; + let latestValue = ''; + let renderPhase = null; + let frameToken = null; + let timerToken = null; + let finalizedByKeydown = false; + let destroyed = false; + let invokingClear = false; + + function safely(callback, ...args) { + try { + return callback(...args); + } catch (_error) { + return undefined; + } + } + + function cancelScheduledFrame() { + const token = frameToken; + frameToken = null; + if (token && token.id !== undefined) safely(cancelFrame, token.id); + } + + function cancelCommittedTimer() { + const token = timerToken; + timerToken = null; + if (token && token.id !== undefined) safely(clearTimer, token.id); + } + + function clearVisual() { + if (invokingClear) return; + invokingClear = true; + safely(clear); + invokingClear = false; + } + + function cleanup() { + generation += 1; + cancelScheduledFrame(); + cancelCommittedTimer(); + composing = false; + awaitingCommit = false; + committed = false; + latestValue = ''; + renderPhase = null; + finalizedByKeydown = false; + clearVisual(); + } + + function scheduleLatestPreview(phase) { + if (destroyed) return; + renderPhase = phase; + if (frameToken) return; + const token = { generation, id: undefined }; + frameToken = token; + const callback = function () { + if (destroyed || frameToken !== token || token.generation !== generation || renderPhase === null) return; + frameToken = null; + const value = latestValue.slice(0, PREVIEW_CAP); + const phaseToRender = renderPhase; + safely(render, { text: value, phase: phaseToRender }); + }; + const id = safely(scheduleFrame, callback); + if (frameToken === token) { + if (id === undefined) frameToken = null; + else token.id = id; + } + } + + function beginComposition() { + cleanup(); + if (destroyed) return; + composing = true; + } + + function updateComposition(event) { + if (!composing) return; + latestValue = event.data == null ? '' : String(event.data); + scheduleLatestPreview('provisional'); + } + + function onComposingInput(event) { + if (!event.isComposing) return; + updateComposition({ data: event.data == null ? textarea.value : event.data }); + } + + function finalizeComposition(value, fromKeydown) { + if (!composing) return; + composing = false; + awaitingCommit = true; + committed = false; + finalizedByKeydown = fromKeydown; + latestValue = value == null ? latestValue : String(value); + scheduleLatestPreview('provisional'); + } + + function onCompositionEnd(event) { + if (finalizedByKeydown) { + finalizedByKeydown = false; + return; + } + finalizeComposition(event.data, false); + } + + function onKeydown(event) { + if (composing && event.isComposing === false && event.key !== 'Process' && event.key !== 'Unidentified') { + finalizeComposition(latestValue, true); + } + } + + function reset() { + if (destroyed) return; + cleanup(); + } + + function consumeTerminalData(data) { + if ( + destroyed || + !awaitingCommit || + typeof data !== 'string' || + data.length === 0 || + CONTROL_OR_LINE_BREAK.test(data) + ) { + return false; + } + + generation += 1; + const owner = generation; + cancelScheduledFrame(); + cancelCommittedTimer(); + composing = false; + awaitingCommit = false; + committed = true; + latestValue = data; + renderPhase = 'committed'; + safely(onCommit, data); + if (destroyed || generation !== owner || !committed) return true; + + scheduleLatestPreview('committed'); + if (destroyed || generation !== owner || !committed) return true; + + const token = { generation, id: undefined }; + timerToken = token; + const callback = function () { + if (destroyed || timerToken !== token || token.generation !== generation || !committed) return; + timerToken = null; + cleanup(); + }; + const id = safely(setTimer, callback, COMMITTED_VISUAL_TTL); + if (timerToken === token) { + if (id === undefined) { + timerToken = null; + if (!destroyed && generation === owner && committed) cleanup(); + } else { + token.id = id; + } + } + return true; + } + + function completeCommit(result) { + if (destroyed || !result || result.predicted !== true || !committed) return; + cleanup(); + } + + function noteAuthoritativeOutput() { + if (destroyed || !committed) return; + cleanup(); + } + + const listeners = [ + ['compositionstart', beginComposition], + ['compositionupdate', updateComposition], + ['input', onComposingInput], + ['compositionend', onCompositionEnd], + ['keydown', onKeydown, true], + ['blur', reset], + ]; + for (const [type, listener, capture] of listeners) textarea.addEventListener(type, listener, capture); + + function destroy() { + if (destroyed) return; + destroyed = true; + for (const [type, listener, capture] of listeners) textarea.removeEventListener(type, listener, capture); + cleanup(); + } + + return { + consumeTerminalData, + completeCommit, + noteAuthoritativeOutput, + reset, + destroy, + get state() { + return { + generation, + composing, + awaitingCommit, + committed, + latest: latestValue, + framePending: frameToken !== null, + timerPending: timerToken !== null, + }; + }, + }; + } + + global.MobileImePreview = { create, isIosWebKitTouch }; +})(globalThis); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 050671a4..0a5c7178 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -460,6 +460,31 @@ textarea:focus-visible { font-size: 16px !important; /* prevent iOS auto-zoom on focus */ } +.xterm-helpers .codeman-ime-preview { + position: absolute; + left: var(--xterm-helper-left, 0px); + top: var(--xterm-helper-top, 0px); + z-index: 6; + pointer-events: none; + white-space: pre; + color: var(--terminal-foreground, var(--text, #fff)); + font-family: var(--font-mono, monospace); + font-size: 14px; + font-weight: 400; + font-style: normal; + line-height: 1.2; + height: 1.2em; +} + +.xterm-helpers .codeman-ime-preview[data-phase='provisional'] { + text-decoration: underline; + text-decoration-style: dotted; +} + +.touch-device .xterm-helpers.codeman-ime-preview-owned .composition-view.active { + display: none; +} + /* Session tab focus */ .session-tab:focus-visible { outline: 2px solid var(--accent); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 903661e5..9413522d 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -250,6 +250,189 @@ Object.assign(CodemanApp.prototype, { this._keyCode229Recovery = null; }, + _destroyMobileImePreview() { + try { + this._mobileImePreview?.destroy?.(); + } catch { + // The preview is visual-only; terminal replacement must continue. + } + this._mobileImePreview = null; + this._mobileImePreviewSessionId = null; + this._mobileImeCommitOutputSeq = null; + try { + this._mobileImePreviewNode?.remove?.(); + } catch { + // Best-effort node cleanup only. + } + try { + this._mobileImePreviewHelpers?.classList?.remove('codeman-ime-preview-owned'); + } catch { + // Best-effort ownership cleanup only. + } + this._mobileImePreviewNode = null; + this._mobileImePreviewHelpers = null; + try { + if (this._mobileImePreviewOfflineHandler) { + window.removeEventListener('offline', this._mobileImePreviewOfflineHandler); + } + if (this._mobileImePreviewPagehideHandler) { + window.removeEventListener('pagehide', this._mobileImePreviewPagehideHandler); + } + } catch { + // Best-effort listener cleanup only. + } + this._mobileImePreviewOfflineHandler = null; + this._mobileImePreviewPagehideHandler = null; + }, + + /** + * iOS Safari IME preview (mobile-ime-preview.js). WebKit does not show the + * text an IME is composing inside the terminal, so the user types blind; this + * paints it in a span inside `.xterm-helpers`, positioned by the same + * --xterm-helper-left/top vars as the helper textarea. Visual only: nothing + * here touches the input path, and every failure leaves no DOM behind. + */ + _initMobileImePreview() { + this._destroyMobileImePreview(); + let preview = null; + let helpers = null; + try { + if (typeof MobileImePreview === 'undefined' || !MobileImePreview?.isIosWebKitTouch?.()) return; + const textarea = this.terminal?.textarea; + helpers = this.terminal?.element?.querySelector?.('.xterm-helpers'); + if (!textarea || !helpers) return; + + preview = document.createElement('span'); + this._mobileImePreviewNode = preview; + this._mobileImePreviewHelpers = helpers; + preview.className = 'codeman-ime-preview'; + preview.setAttribute('aria-hidden', 'true'); + preview.hidden = true; + helpers.appendChild(preview); + const syncPreviewTypography = () => { + try { + const compositionView = + helpers.querySelector?.('.composition-view') || this.terminal?.element?.querySelector?.('.composition-view'); + if (!compositionView || !preview.style) return; + const style = typeof getComputedStyle === 'function' ? getComputedStyle(compositionView) : compositionView.style; + for (const property of ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'lineHeight', 'height']) { + const value = style?.[property] || compositionView.style?.[property]; + if (value) preview.style[property] = value; + } + let foreground = this.terminal?.options?.theme?.foreground; + if (!foreground) { + try { + foreground = window.codemanCurrentXtermTheme?.()?.foreground; + } catch { + // Theme lookup is best-effort; retain the safe terminal fallback. + } + } + preview.style.color = foreground || '#e0e0e0'; + } catch { + // Typography matching is visual-only and must not block input. + } + }; + const clearPreview = () => { + try { + preview.hidden = true; + } catch {} + try { + preview.textContent = ''; + } catch {} + try { + delete preview.dataset.phase; + } catch {} + try { + helpers.classList.remove('codeman-ime-preview-owned'); + } catch {} + }; + const controller = MobileImePreview.create({ + textarea, + render: ({ text, phase }) => { + try { + syncPreviewTypography(); + preview.textContent = text; + preview.dataset.phase = phase; + preview.hidden = !text; + helpers.classList.toggle('codeman-ime-preview-owned', !!text); + } catch { + clearPreview(); + } + }, + clear: clearPreview, + }); + this._mobileImePreview = controller; + this._mobileImePreviewSessionId = this.activeSessionId; + + this._mobileImePreviewOfflineHandler = () => { + try { + this._mobileImePreview?.reset?.(); + } catch { + // Disconnect cleanup is visual-only. + } + }; + this._mobileImePreviewPagehideHandler = () => this._destroyMobileImePreview(); + window.addEventListener('offline', this._mobileImePreviewOfflineHandler); + window.addEventListener('pagehide', this._mobileImePreviewPagehideHandler); + } catch { + this._destroyMobileImePreview(); + } + }, + + /** + * Tell the IME preview about a chunk xterm emitted through onData. Returns + * true when the chunk is the IME's committed text, in which case the preview + * holds it (phase 'committed') until something else shows it. Never throws. + */ + _consumeMobileImeTerminalData(data) { + let isImeCommit = false; + try { + isImeCommit = this._mobileImePreview?.consumeTerminalData?.(data) === true; + } catch { + // The preview is visual-only; normal terminal input must continue. + } + // Output accepted from here on can carry the echo of this commit. + if (isImeCommit) this._mobileImeCommitOutputSeq = this._terminalOutputSeq || 0; + return isImeCommit; + }, + + /** + * Clear a committed IME preview once terminal output accepted AFTER the + * commit has been parsed. `flushedOutputSeq` is the output sequence a fully + * written flush covered (null when part of it was deferred), so output that + * was already queued before the commit can never clear it early. + */ + _noteMobileImeAuthoritativeOutput(flushedOutputSeq, sessionId) { + try { + const commitSeq = this._mobileImeCommitOutputSeq; + if (commitSeq === null || commitSeq === undefined || flushedOutputSeq === null) return; + if (sessionId !== this.activeSessionId || !(flushedOutputSeq > commitSeq)) return; + this._mobileImeCommitOutputSeq = null; + this._mobileImePreview?.noteAuthoritativeOutput?.(); + } catch { + // Authoritative output is never delayed or consumed by the preview. + } + }, + + /** Hand a committed IME chunk to the local echo overlay. False = not taken. */ + _transferMobileImeCommitToLocalEcho(data) { + const overlay = this._localEchoOverlay; + try { + const update = data.length === 1 ? overlay?.addChar : overlay?.appendText; + if (typeof update !== 'function') return false; + update.call(overlay, data); + } catch { + return false; + } + this._mobileImeCommitOutputSeq = null; + try { + this._mobileImePreview?.completeCommit?.({ predicted: true }); + } catch { + // Ownership transfer is visual-only. + } + return true; + }, + initTerminal() { // Load scrollback setting from localStorage, treating DEFAULT_SCROLLBACK as a floor // so users who picked up the previous (smaller) default get the new minimum on upgrade. @@ -307,6 +490,7 @@ Object.assign(CodemanApp.prototype, { const container = document.getElementById('terminalContainer'); this.terminal.open(container); + this._initMobileImePreview(); this._installMobileTapMouseGuard(); this._installShiftDragSelection(); this._installTouchSelectionFocusGuard(); @@ -1211,6 +1395,8 @@ Object.assign(CodemanApp.prototype, { // survives tab switches and reconnects. const handleTerminalData = (data) => { + // Before anything can rewrite `data`: is this chunk the IME's commit? + const isImeCommit = this._consumeMobileImeTerminalData(data); // Mouse SGR reports (tap-to-position) are NOT IME input — they must reach // the PTY even while the CJK input field owns focus. Without this exception // tapping to move the cursor silently does nothing whenever Chinese input @@ -1288,7 +1474,16 @@ Object.assign(CodemanApp.prototype, { // When enabled, keystrokes are buffered locally in the overlay for // instant visual feedback. Nothing is sent to the PTY until Enter // (or a control char) is pressed — avoids out-of-order char delivery. - if (this._localEchoEnabled && !echoPassthrough) { + // An IME commit moves into the overlay, which then shows it in place of + // the preview. The charCode check skips a commit the one-shot Ctrl above + // turned into a control byte. If the overlay cannot take it, the text is + // sent directly rather than dropped. + let imeCommitBypassesEcho = false; + if (isImeCommit && this._localEchoEnabled && !echoPassthrough && data.charCodeAt(0) >= 32) { + if (this._transferMobileImeCommitToLocalEcho(data)) return; + imeCommitBypassesEcho = true; + } + if (this._localEchoEnabled && !echoPassthrough && !imeCommitBypassesEcho) { if (data === '\x7f') { const source = this._localEchoOverlay?.removeChar(); if (source === 'flushed') { @@ -3517,6 +3712,9 @@ Object.assign(CodemanApp.prototype, { }, batchTerminalWrite(data) { + // Arrival order of output, so the IME preview can tell output that + // followed a commit from output that was already queued before it. + this._terminalOutputSeq = (this._terminalOutputSeq || 0) + 1; // Feed the renderer watchdog. Recorded before the buffer-load early return // below: a write that is queued rather than written still means the pipeline // owes us a frame once it drains. @@ -3580,6 +3778,7 @@ Object.assign(CodemanApp.prototype, { // Accumulate raw data (may contain DEC 2026 markers) this.pendingWrites.push(data); + this._pendingWritesOutputSeq = this._terminalOutputSeq; this._scheduleTerminalWriteFlush(); }, @@ -3611,6 +3810,7 @@ Object.assign(CodemanApp.prototype, { // Transfer buffered data to normal pending writes this.pendingWrites.push(this.flickerFilterBuffer); + this._pendingWritesOutputSeq = this._terminalOutputSeq; this.flickerFilterBuffer = ''; this.flickerFilterActive = false; @@ -3641,6 +3841,15 @@ Object.assign(CodemanApp.prototype, { * Position is tracked dynamically by _findPrompt() on every render. */ _updateLocalEchoState() { + if (this._mobileImePreviewSessionId !== this.activeSessionId) { + this._mobileImePreviewSessionId = this.activeSessionId; + this._mobileImeCommitOutputSeq = null; + try { + this._mobileImePreview?.reset?.(); + } catch { + // The preview is visual-only; session switching must continue. + } + } const settings = this.loadAppSettingsFromStorage(); const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null; const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); @@ -3846,6 +4055,9 @@ Object.assign(CodemanApp.prototype, { this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES)); deferred = true; } + // Newest output this chunk fully contains, for the IME preview. A split + // chunk may not hold that output yet, so it reports nothing. + const flushedOutputSeq = deferred ? null : (this._pendingWritesOutputSeq ?? null); this._terminalWriteInFlight = true; this._terminalWriteInFlightBytes = writeChunk.length; try { @@ -3863,6 +4075,7 @@ Object.assign(CodemanApp.prototype, { // because the test's write mock moved the viewport synchronously.) this._restoreTerminalViewport(preserveViewportY, flushSessionId); this._scheduleTerminalWriteFlush(); + this._noteMobileImeAuthoritativeOutput(flushedOutputSeq, flushSessionId); }); } catch (err) { this._terminalWriteInFlight = false; diff --git a/test/mobile-ime-preview-structure.test.ts b/test/mobile-ime-preview-structure.test.ts new file mode 100644 index 00000000..5daa5c5c --- /dev/null +++ b/test/mobile-ime-preview-structure.test.ts @@ -0,0 +1,595 @@ +/** + * @fileoverview Wiring tests for the iOS IME preview (mobile-ime-preview.js). + * + * The controller itself is covered by test/mobile-ime-preview.test.ts. These + * pin how terminal-ui.js and the delivery graph consume it: script order, + * build registration and CSS, the _init/_destroyMobileImePreview lifecycle, + * the onData routing of an IME commit, and the rule that only output accepted + * AFTER a commit may clear the committed preview. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const read = (path: string) => readFileSync(resolve(import.meta.dirname, '..', path), 'utf8'); +const indexSource = read('src/web/public/index.html'); +const buildSource = read('scripts/build.mjs'); +const terminalSource = read('src/web/public/terminal-ui.js'); +const cssSource = read('src/web/public/styles.css'); + +type Fn = ReturnType; +type App = Record; + +function loadMixin(globals: Record = {}) { + const FakeCodemanApp = function () {} as unknown as { prototype: Record }; + const windowStub = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + ...(globals.window as object), + } as Record; + const context = vm.createContext({ + console, + performance, + setTimeout, + clearTimeout, + setInterval: vi.fn(), + clearInterval: vi.fn(), + requestAnimationFrame: vi.fn(), + cancelAnimationFrame: vi.fn(), + URLSearchParams, + location: { search: '' }, + localStorage: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }, + document: { addEventListener: vi.fn(), createElement: vi.fn() }, + MobileDetection: { isTouchDevice: () => false }, + CodemanApp: FakeCodemanApp, + _crashDiag: { log: vi.fn() }, + ...globals, + window: windowStub, + }); + vm.runInContext(terminalSource, context, { filename: 'terminal-ui.js' }); + return { mixin: FakeCodemanApp.prototype, context, windowStub }; +} + +function fakeClassList() { + const values = new Set(); + return { + add: vi.fn((value: string) => values.add(value)), + remove: vi.fn((value: string) => values.delete(value)), + toggle: vi.fn((value: string, force?: boolean) => { + const enabled = force === undefined ? !values.has(value) : force; + if (enabled) values.add(value); + else values.delete(value); + return enabled; + }), + contains: (value: string) => values.has(value), + }; +} + +function createPreviewHarness( + options: { + eligible?: boolean; + createThrows?: boolean; + omitGlobal?: boolean; + themeForeground?: string; + themeGetterThrows?: boolean; + } = {} +) { + const compositionView = { + style: { + fontFamily: '"Fira Code"', + fontSize: '10px', + fontWeight: '500', + fontStyle: 'italic', + lineHeight: '12px', + height: '12px', + color: 'rgb(255, 255, 255)', + }, + }; + const helpers = { + classList: fakeClassList(), + children: [] as Array>, + querySelector: (selector: string) => (selector === '.composition-view' ? compositionView : null), + appendChild(node: Record) { + this.children.push(node); + }, + }; + const createdControllers: Array> = []; + const previewNodes: Array> = []; + const documentStub = { + addEventListener: vi.fn(), + createElement: vi.fn(() => { + const node = { + className: '', + hidden: false, + textContent: '', + dataset: {} as Record, + style: {} as Record, + attributes: {} as Record, + setAttribute(name: string, value: string) { + this.attributes[name] = value; + }, + remove: vi.fn(), + }; + previewNodes.push(node); + return node; + }), + }; + const mobileImePreview = options.omitGlobal + ? undefined + : { + isIosWebKitTouch: vi.fn(() => options.eligible ?? true), + create: vi.fn((callbacks: Record) => { + if (options.createThrows) throw new Error('controller unavailable'); + const controller = { + destroy: vi.fn(), + reset: vi.fn(), + consumeTerminalData: vi.fn(() => false), + completeCommit: vi.fn(), + noteAuthoritativeOutput: vi.fn(), + callbacks, + }; + createdControllers.push(controller); + return controller; + }), + }; + const { mixin, windowStub } = loadMixin({ + document: documentStub, + MobileImePreview: mobileImePreview, + getComputedStyle: (node: { style: Record }) => node.style, + }); + windowStub.codemanCurrentXtermTheme = () => { + if (options.themeGetterThrows) throw new Error('theme unavailable'); + return { foreground: '#334455' }; + }; + const app: App = Object.assign(Object.create(mixin), { + terminal: { + textarea: {}, + options: { theme: options.themeForeground ? { foreground: options.themeForeground } : undefined }, + element: { querySelector: (selector: string) => (selector === '.xterm-helpers' ? helpers : null) }, + }, + activeSessionId: 'session-a', + }); + return { app, helpers, compositionView, previewNodes, createdControllers, mobileImePreview, windowStub }; +} + +describe('mobile IME preview delivery graph', () => { + it('loads the controller after xterm and before terminal wiring', () => { + const at = indexSource.indexOf(''); + expect(at).toBeGreaterThan(indexSource.indexOf('vendor/xterm.min.js')); + expect(at).toBeLessThan(indexSource.indexOf('')); + expect(at).toBeLessThan(indexSource.indexOf('')); + }); + + it('is minified and content-hashed by the build', () => { + expect(buildSource).toContain("run('minify mobile-ime-preview.js'"); + expect(buildSource).toMatch(/const HASHABLE = \[[^\]]*'mobile-ime-preview\.js'/); + }); + + it('scopes preview presentation and native composition suppression to touch ownership', () => { + expect(cssSource).toContain('.xterm-helpers .codeman-ime-preview {'); + expect(cssSource).toContain(".xterm-helpers .codeman-ime-preview[data-phase='provisional'] {"); + expect(cssSource).toContain('.touch-device .xterm-helpers.codeman-ime-preview-owned .composition-view.active {'); + const rule = cssSource.slice(cssSource.indexOf('.xterm-helpers .codeman-ime-preview {')); + expect(rule.slice(0, rule.indexOf('}'))).toContain('left: var(--xterm-helper-left, 0px)'); + expect(rule.slice(0, rule.indexOf('}'))).toContain('top: var(--xterm-helper-top, 0px)'); + }); + + it('initializes the preview right after the terminal opens', () => { + expect(terminalSource).toMatch(/this\.terminal\.open\(container\);\s*this\._initMobileImePreview\(\);/); + }); +}); + +describe('mobile IME preview lifecycle', () => { + it('fails open when the global is absent or create throws', () => { + for (const options of [{ omitGlobal: true }, { createThrows: true }]) { + const { app } = createPreviewHarness(options); + expect(() => app._initMobileImePreview()).not.toThrow(); + expect(app._mobileImePreview).toBeNull(); + } + }); + + it('does not create a controller on unsupported input platforms', () => { + const { app, mobileImePreview, previewNodes } = createPreviewHarness({ eligible: false }); + app._initMobileImePreview(); + expect(mobileImePreview?.create).not.toHaveBeenCalled(); + expect(previewNodes).toHaveLength(0); + expect(app._mobileImePreview).toBeNull(); + }); + + it('creates one controller bound to the terminal textarea and one hidden preview node', () => { + const { app, helpers, previewNodes, mobileImePreview } = createPreviewHarness(); + app._initMobileImePreview(); + expect(mobileImePreview?.create).toHaveBeenCalledOnce(); + expect(mobileImePreview?.create.mock.calls[0][0].textarea).toBe(app.terminal.textarea); + expect(helpers.children).toEqual([previewNodes[0]]); + expect(previewNodes[0]).toMatchObject({ className: 'codeman-ime-preview', hidden: true }); + expect(previewNodes[0].attributes['aria-hidden']).toBe('true'); + }); + + it('destroys prior ownership on repeated initialization and keeps one active controller', () => { + const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness(); + app._initMobileImePreview(); + const first = createdControllers[0]; + app._initMobileImePreview(); + expect(first.destroy).toHaveBeenCalledOnce(); + expect(previewNodes[0].remove).toHaveBeenCalledOnce(); + expect(createdControllers).toHaveLength(2); + expect(app._mobileImePreview).toBe(createdControllers[1]); + // Window listeners are released with the controller that owned them. + expect((windowStub.removeEventListener as Fn).mock.calls.map((call) => call[0]).sort()).toEqual([ + 'offline', + 'pagehide', + ]); + }); + + it('destroy releases the controller, the node and the listeners', () => { + const { app, createdControllers, previewNodes, windowStub } = createPreviewHarness(); + app._initMobileImePreview(); + app._destroyMobileImePreview(); + expect(createdControllers[0].destroy).toHaveBeenCalledOnce(); + expect(previewNodes[0].remove).toHaveBeenCalledOnce(); + expect(app._mobileImePreview).toBeNull(); + expect(windowStub.removeEventListener).toHaveBeenCalledTimes(2); + }); + + it('resets the controller exactly once when the active session changes', () => { + const { app, createdControllers } = createPreviewHarness(); + app._initMobileImePreview(); + app.activeSessionId = 'session-b'; + app.loadAppSettingsFromStorage = () => ({ localEchoEnabled: false }); + app.sessions = new Map(); + app._updateLocalEchoState(); + app._updateLocalEchoState(); + expect(createdControllers[0].reset).toHaveBeenCalledOnce(); + }); + + it('renders and clears owned preview state', () => { + const { app, helpers, previewNodes, createdControllers } = createPreviewHarness(); + app._initMobileImePreview(); + const callbacks = createdControllers[0].callbacks; + callbacks.render({ text: '你好', phase: 'provisional' }); + expect(previewNodes[0]).toMatchObject({ textContent: '你好', hidden: false, dataset: { phase: 'provisional' } }); + expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(true); + callbacks.clear(); + expect(previewNodes[0]).toMatchObject({ textContent: '', hidden: true, dataset: {} }); + expect(helpers.classList.contains('codeman-ime-preview-owned')).toBe(false); + }); + + it('uses the terminal foreground while mirroring native composition font metrics', () => { + const { app, compositionView, previewNodes, createdControllers } = createPreviewHarness({ + themeForeground: '#1f2328', + }); + app._initMobileImePreview(); + createdControllers[0].callbacks.render({ text: '入力', phase: 'provisional' }); + expect(previewNodes[0].style).toMatchObject({ + fontFamily: compositionView.style.fontFamily, + fontSize: compositionView.style.fontSize, + fontWeight: compositionView.style.fontWeight, + fontStyle: compositionView.style.fontStyle, + lineHeight: compositionView.style.lineHeight, + height: compositionView.style.height, + color: '#1f2328', + }); + }); + + it('keeps rendering with a safe foreground when the theme getter throws', () => { + const { app, previewNodes, createdControllers } = createPreviewHarness({ themeGetterThrows: true }); + app._initMobileImePreview(); + expect(() => createdControllers[0].callbacks.render({ text: '安全', phase: 'provisional' })).not.toThrow(); + expect(previewNodes[0].textContent).toBe('安全'); + expect(previewNodes[0].style.color).toBe('#e0e0e0'); + }); + + it.each(['query', 'create', 'append', 'className', 'hidden'] as const)( + 'removes partial DOM ownership when %s fails', + (failure) => { + const removed = vi.fn(); + const owner = fakeClassList(); + const preview = new Proxy( + { dataset: {}, remove: removed, setAttribute: vi.fn() }, + { + set(target, property, value) { + if (property === failure) throw new Error(`${failure} failed`); + return Reflect.set(target, property, value); + }, + } + ); + const helpers = { + classList: owner, + appendChild: + failure === 'append' + ? () => { + throw new Error('append failed'); + } + : vi.fn(), + }; + const documentStub = { + addEventListener: vi.fn(), + createElement: + failure === 'create' + ? () => { + throw new Error('create failed'); + } + : () => preview, + }; + const MobileImePreview = { isIosWebKitTouch: () => true, create: vi.fn() }; + const { mixin } = loadMixin({ document: documentStub, MobileImePreview }); + const app: App = Object.assign(Object.create(mixin), { + terminal: { + textarea: {}, + element: { + querySelector: + failure === 'query' + ? () => { + throw new Error('query failed'); + } + : () => helpers, + }, + }, + }); + expect(() => app._initMobileImePreview()).not.toThrow(); + expect(app._mobileImePreview).toBeNull(); + expect(MobileImePreview.create).not.toHaveBeenCalled(); + expect(owner.contains('codeman-ime-preview-owned')).toBe(false); + if (!['query', 'create'].includes(failure)) expect(removed).toHaveBeenCalled(); + } + ); +}); + +/** + * Rebuilds the real `handleTerminalData` closure from initTerminal's source, + * so these cases exercise the shipped routing rather than a copy of it. + */ +function loadHandleTerminalData(app: App, sent: string[]) { + const marker = 'const handleTerminalData = (data) => {'; + const start = terminalSource.indexOf(marker); + if (start < 0) throw new Error('handleTerminalData definition not found'); + const bodyStart = start + marker.length; + const end = terminalSource.indexOf('\n };', bodyStart); + if (end < 0) throw new Error('handleTerminalData boundary not found'); + const timers: Array<() => void> = []; + app._sendInputAsync = (_sessionId: string, data: string) => sent.push(data); + const context = vm.createContext({ + console, + performance, + setTimeout: (callback: () => void) => { + timers.push(callback); + return timers.length; + }, + clearTimeout: vi.fn(), + document: { activeElement: null, getElementById: vi.fn(() => null) }, + window: { + cjkActive: false, + CodemanTerminalInput: { + BRACKETED_PASTE_START: '\x1b[200~', + shouldSuppressTerminalQueryResponse: () => false, + isTerminalFocusOrMouseReport: () => false, + isComposerNavKey: () => false, + }, + }, + _crashDiag: { log: vi.fn() }, + flushInput: () => { + app._inputFlushTimeout = null; + if (app._pendingInput && app.activeSessionId) { + const input = app._pendingInput; + app._pendingInput = ''; + app._sendInputAsync(app.activeSessionId, input); + } + }, + }); + const handler = vm.runInContext(`(function (data) {${terminalSource.slice(bodyStart, end)}\n})`, context) as ( + this: App, + data: string + ) => void; + return { handle: (data: string) => handler.call(app, data), timers }; +} + +describe('mobile IME commit onData routing', () => { + function onDataApp(options: { + localEcho: boolean; + tagged?: boolean; + overlayMissing?: boolean; + addThrows?: boolean; + appendThrows?: boolean; + consumeThrows?: boolean; + }) { + const { mixin } = loadMixin(); + const sent: string[] = []; + const controller = { + consumeTerminalData: options.consumeThrows + ? vi.fn(() => { + throw new Error('consume failed'); + }) + : vi + .fn() + .mockReturnValueOnce(options.tagged ?? true) + .mockReturnValue(false), + completeCommit: vi.fn(), + noteAuthoritativeOutput: vi.fn(), + }; + const overlay = { + pendingText: '', + appendText: vi.fn((data: string) => { + if (options.appendThrows) throw new Error('overlay failed'); + overlay.pendingText += data; + }), + addChar: vi.fn((data: string) => { + if (options.addThrows) throw new Error('overlay failed'); + overlay.pendingText += data; + }), + clear: vi.fn(() => { + overlay.pendingText = ''; + }), + suppressBufferDetection: vi.fn(), + }; + const app: App = Object.assign(Object.create(mixin), { + activeSessionId: 'session-a', + _localEchoEnabled: options.localEcho, + _localEchoOverlay: options.overlayMissing ? null : overlay, + _echoPassthroughSessions: new Set(), + _flushedOffsets: new Map(), + _flushedTexts: new Map(), + _pendingInput: '', + _inputFlushTimeout: null, + _lastKeystrokeTime: 0, + _terminalOutputSeq: 5, + _mobileImePreview: controller, + }); + return { app, controller, overlay, sent, ...loadHandleTerminalData(app, sent) }; + } + + it('moves a multi-character commit into local echo and submits it only on Enter', () => { + const { app, controller, overlay, sent, handle, timers } = onDataApp({ localEcho: true }); + handle('你好'); + expect(controller.consumeTerminalData).toHaveBeenCalledOnce(); + expect(overlay.appendText).toHaveBeenCalledWith('你好'); + expect(controller.completeCommit).toHaveBeenCalledWith({ predicted: true }); + expect(app._mobileImeCommitOutputSeq).toBeNull(); + expect(sent).toEqual([]); + + handle('\r'); + expect(sent).toEqual(['你好']); + timers.shift()?.(); + expect(sent).toEqual(['你好', '\r']); + }); + + it('moves a single-character commit into local echo through addChar', () => { + const { controller, overlay, sent, handle } = onDataApp({ localEcho: true }); + handle('界'); + expect(overlay.addChar).toHaveBeenCalledWith('界'); + expect(overlay.appendText).not.toHaveBeenCalled(); + expect(controller.completeCommit).toHaveBeenCalledWith({ predicted: true }); + expect(sent).toEqual([]); + }); + + it.each([ + ['the overlay is missing', { overlayMissing: true }, '日本'], + ['appendText throws', { appendThrows: true }, '失敗'], + ['addChar throws', { addThrows: true }, '字'], + ])('sends the committed text exactly once when %s', (_label, extra, text) => { + const { controller, sent, handle } = onDataApp({ localEcho: true, ...extra }); + expect(() => handle(text)).not.toThrow(); + expect(sent).toEqual([text]); + // Nothing else shows the text yet, so the preview keeps it. + expect(controller.completeCommit).not.toHaveBeenCalled(); + }); + + it('keeps an untagged paste on the existing local echo path', () => { + const { controller, overlay, sent, handle } = onDataApp({ localEcho: true, tagged: false }); + handle('plain paste'); + expect(overlay.pendingText).toBe('plain paste'); + expect(controller.completeCommit).not.toHaveBeenCalled(); + expect(sent).toEqual([]); + }); + + it('sends a commit once without local echo and holds the preview until output arrives', () => { + const { app, controller, sent, handle } = onDataApp({ localEcho: false }); + handle('日本語'); + expect(controller.consumeTerminalData).toHaveBeenCalledOnce(); + expect(sent).toEqual(['日本語']); + expect(controller.completeCommit).not.toHaveBeenCalled(); + expect(app._mobileImeCommitOutputSeq).toBe(5); + }); + + it('sends the original bytes exactly once when the controller throws', () => { + const { sent, handle } = onDataApp({ localEcho: false, consumeThrows: true }); + expect(() => handle('你好')).not.toThrow(); + expect(sent).toEqual(['你好']); + }); +}); + +describe('mobile IME commit and authoritative terminal output', () => { + function outputHarness(mode = 'claude') { + const { mixin } = loadMixin(); + const parses: Array<() => void> = []; + const written: string[] = []; + const controller = { + consumeTerminalData: vi.fn(() => true), + noteAuthoritativeOutput: vi.fn(), + }; + const app: App = Object.assign(Object.create(mixin), { + pendingWrites: [], + terminal: { + rows: 24, + write: vi.fn((data: string, callback?: () => void) => { + written.push(data); + if (callback) parses.push(callback); + }), + buffer: { active: { viewportY: 0 } }, + scrollToBottom: vi.fn(), + }, + activeSessionId: 'session-a', + sessions: new Map([['session-a', { mode }]]), + isTerminalAtBottom: () => true, + _hasRecentUserScrollUp: () => false, + _safeYield: vi.fn(), + _localEchoOverlay: null, + _mobileImePreview: controller, + }); + const flush = () => { + app.writeFrameScheduled = false; + app.flushPendingWrites(); + }; + const parseNext = () => parses.shift()?.(); + return { app, controller, written, flush, parseNext }; + } + + it('clears the committed preview once output accepted after the commit is parsed', () => { + const { app, controller, flush, parseNext } = outputHarness(); + app._consumeMobileImeTerminalData('你好'); + app.batchTerminalWrite('echo'); + flush(); + expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled(); + parseNext(); + expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce(); + }); + + it('does not let output queued before the commit clear it, even when it parses after', () => { + const { app, controller, flush, parseNext } = outputHarness(); + app.batchTerminalWrite('before'); + flush(); + app._consumeMobileImeTerminalData('你好'); + parseNext(); + expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled(); + + app.batchTerminalWrite('after'); + flush(); + parseNext(); + expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce(); + }); + + it('does not let a split chunk clear the commit before its remainder is written', () => { + const { app, controller, written, flush, parseNext } = outputHarness('codex'); + app._consumeMobileImeTerminalData('你好'); + app.batchTerminalWrite('x'.repeat(40000)); + flush(); + parseNext(); + expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled(); + flush(); + parseNext(); + expect(written.join('')).toBe('x'.repeat(40000)); + expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce(); + }); + + it('does not let output parsed after a session switch clear the new session preview', () => { + const { app, controller, flush, parseNext } = outputHarness(); + app._consumeMobileImeTerminalData('你好'); + app.batchTerminalWrite('echo'); + flush(); + app.activeSessionId = 'session-b'; + parseNext(); + expect(controller.noteAuthoritativeOutput).not.toHaveBeenCalled(); + }); + + it('notifies once per commit, never for later output', () => { + const { app, controller, flush, parseNext } = outputHarness(); + app._consumeMobileImeTerminalData('你好'); + for (const chunk of ['a', 'b']) { + app.batchTerminalWrite(chunk); + flush(); + parseNext(); + } + expect(controller.noteAuthoritativeOutput).toHaveBeenCalledOnce(); + }); +}); diff --git a/test/mobile-ime-preview.test.ts b/test/mobile-ime-preview.test.ts new file mode 100644 index 00000000..8b52b35a --- /dev/null +++ b/test/mobile-ime-preview.test.ts @@ -0,0 +1,502 @@ +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +type Listener = (event: Record) => void; +type ListenerOptions = boolean | { capture?: boolean }; +type RegisteredListener = { listener: Listener; capture: boolean }; + +class FakeTextarea { + value = 'unchanged'; + private listeners = new Map(); + + addEventListener(type: string, listener: Listener, options?: ListenerOptions) { + const listeners = this.listeners.get(type) ?? []; + listeners.push({ listener, capture: options === true || options?.capture === true }); + this.listeners.set(type, listeners); + } + + removeEventListener(type: string, listener: Listener, options?: ListenerOptions) { + const capture = options === true || options?.capture === true; + const listeners = this.listeners.get(type) ?? []; + const index = listeners.findIndex( + (registered) => registered.listener === listener && registered.capture === capture + ); + if (index >= 0) listeners.splice(index, 1); + } + + dispatch(type: string, event: Record = {}) { + const listeners = [...(this.listeners.get(type) ?? [])]; + for (const phase of [true, false]) { + for (const registered of listeners) { + if (registered.capture === phase) registered.listener({ type, ...event }); + } + } + } + + listenerCount() { + return [...this.listeners.values()].reduce((total, listeners) => total + listeners.length, 0); + } +} + +type Scheduled = { id: number; callback: () => void; delay?: number }; + +function harness( + overrides: Record = {}, + beforeCreate?: (textarea: FakeTextarea, getController: () => Record | undefined) => void +) { + const source = readFileSync(new URL('../src/web/public/mobile-ime-preview.js', import.meta.url), 'utf8'); + const context = vm.createContext({ navigator: {} }); + vm.runInContext(source, context, { filename: 'mobile-ime-preview.js' }); + const api = vm.runInContext('MobileImePreview', context); + const textarea = new FakeTextarea(); + const frames: Scheduled[] = []; + const timers: Scheduled[] = []; + let nextId = 1; + const render = vi.fn(); + const clear = vi.fn(); + const onCommit = vi.fn(); + const scheduleFrame = vi.fn((callback: () => void) => { + const id = nextId++; + frames.push({ id, callback }); + return id; + }); + const cancelFrame = vi.fn((id: number) => { + const index = frames.findIndex((frame) => frame.id === id); + if (index >= 0) frames.splice(index, 1); + }); + const setTimer = vi.fn((callback: () => void, delay: number) => { + const id = nextId++; + timers.push({ id, callback, delay }); + return id; + }); + const clearTimer = vi.fn((id: number) => { + const index = timers.findIndex((timer) => timer.id === id); + if (index >= 0) timers.splice(index, 1); + }); + let controller: Record | undefined; + beforeCreate?.(textarea, () => controller); + controller = api.create({ + textarea, + render, + clear, + onCommit, + scheduleFrame, + cancelFrame, + setTimer, + clearTimer, + ...overrides, + }); + const flushFrame = () => frames.shift()?.callback(); + const flushTimer = () => timers.shift()?.callback(); + + return { + api, + textarea, + frames, + timers, + render, + clear, + onCommit, + scheduleFrame, + cancelFrame, + setTimer, + clearTimer, + controller, + flushFrame, + flushTimer, + }; +} + +describe('MobileImePreview', () => { + beforeEach(() => vi.restoreAllMocks()); + + test('collapses 500 composition updates into one latest-state frame', () => { + const h = harness(); + h.textarea.dispatch('compositionstart'); + for (let i = 0; i < 500; i += 1) h.textarea.dispatch('compositionupdate', { data: `value-${i}` }); + + expect(h.scheduleFrame).toHaveBeenCalledTimes(1); + expect(h.render).not.toHaveBeenCalled(); + h.flushFrame(); + expect(h.render).toHaveBeenCalledOnce(); + expect(h.render).toHaveBeenLastCalledWith({ text: 'value-499', phase: 'provisional' }); + expect(h.onCommit).not.toHaveBeenCalled(); + expect(h.textarea.value).toBe('unchanged'); + }); + + test('replaces provisional text for replacement, backspace, and composing input', () => { + const h = harness(); + h.textarea.dispatch('compositionstart'); + h.textarea.dispatch('compositionupdate', { data: 'abcdef' }); + h.textarea.dispatch('compositionupdate', { data: 'xy' }); + h.flushFrame(); + expect(h.render).toHaveBeenLastCalledWith({ text: 'xy', phase: 'provisional' }); + + h.textarea.dispatch('input', { data: '', isComposing: true }); + h.flushFrame(); + expect(h.render).toHaveBeenLastCalledWith({ text: '', phase: 'provisional' }); + expect(h.onCommit).not.toHaveBeenCalled(); + }); + + test('caps the preview without changing the terminal handoff value', () => { + const h = harness(); + const value = '界'.repeat(2050); + h.textarea.dispatch('compositionstart'); + h.textarea.dispatch('compositionupdate', { data: value }); + h.flushFrame(); + expect(h.render.mock.calls[0][0].text).toHaveLength(2048); + h.textarea.dispatch('compositionend', { data: value }); + expect(h.controller.consumeTerminalData(value)).toBe(true); + expect(h.onCommit).toHaveBeenCalledWith(value); + }); + + test('hands off only the first safe xterm onData value after finalization', () => { + const h = harness(); + h.textarea.dispatch('compositionstart'); + h.textarea.dispatch('compositionupdate', { data: '日本語' }); + h.textarea.dispatch('compositionend', { data: '日本語' }); + + expect(h.controller.consumeTerminalData('日本語')).toBe(true); + expect(h.controller.consumeTerminalData('日本語')).toBe(false); + expect(h.onCommit).toHaveBeenCalledOnce(); + expect(h.render).not.toHaveBeenCalled(); + expect(h.frames).toHaveLength(1); + h.flushFrame(); + expect(h.render).toHaveBeenLastCalledWith({ text: '日本語', phase: 'committed' }); + expect(h.setTimer).toHaveBeenCalledWith(expect.any(Function), 2000); + }); + + test('defers finalization rendering and updates the queued frame phase to committed', () => { + const h = harness(); + h.textarea.dispatch('compositionstart'); + h.textarea.dispatch('compositionupdate', { data: 'draft' }); + h.textarea.dispatch('compositionend'); + expect(h.render).not.toHaveBeenCalled(); + expect(h.frames).toHaveLength(1); + + expect(h.controller.consumeTerminalData('final')).toBe(true); + expect(h.frames).toHaveLength(1); + expect(h.render).not.toHaveBeenCalled(); + h.flushFrame(); + expect(h.render).toHaveBeenCalledOnce(); + expect(h.render).toHaveBeenCalledWith({ text: 'final', phase: 'committed' }); + }); + + test('treats the first safe xterm onData value as authoritative over stale provisional data', () => { + const h = harness(); + h.textarea.dispatch('compositionstart'); + h.textarea.dispatch('compositionupdate', { data: 'teh' }); + h.textarea.dispatch('compositionend'); + + expect(h.controller.consumeTerminalData('the')).toBe(true); + expect(h.onCommit).toHaveBeenCalledOnce(); + expect(h.onCommit).toHaveBeenCalledWith('the'); + expect(h.controller.consumeTerminalData('teh')).toBe(false); + }); + + test.each(['', '\n', 'line\rbreak', 'two\nlines', '\u0003', '\u007f'])( + 'rejects non-printable or multiline terminal data %j without consuming the pending value', + (rejected) => { + const h = harness(); + h.textarea.dispatch('compositionstart'); + h.textarea.dispatch('compositionend', { data: rejected }); + expect(h.controller.consumeTerminalData(rejected)).toBe(false); + expect(h.onCommit).not.toHaveBeenCalled(); + } + ); + + test.each(['line\u2028break', 'line\u2029break'])( + 'rejects Unicode line separator terminal data %j without consuming the finalization fence', + (rejected) => { + const h = harness(); + h.textarea.dispatch('compositionstart'); + h.textarea.dispatch('compositionend'); + expect(h.controller.consumeTerminalData(rejected)).toBe(false); + expect(h.controller.consumeTerminalData('safe')).toBe(true); + } + ); + + test('keydown can finalize composition before a late compositionend', () => { + const h = harness(); + h.textarea.dispatch('compositionstart'); + h.textarea.dispatch('compositionupdate', { data: '確定' }); + h.textarea.dispatch('keydown', { key: 'Enter', isComposing: false }); + expect(h.controller.consumeTerminalData('確定')).toBe(true); + h.textarea.dispatch('compositionend', { data: 'stale' }); + expect(h.controller.consumeTerminalData('stale')).toBe(false); + }); + + test('capture keydown finalization precedes an earlier xterm bubble onData listener', () => { + const consumed: boolean[] = []; + const h = harness({}, (textarea, getController) => { + textarea.addEventListener('keydown', () => { + const controller = getController() as { consumeTerminalData(data: string): boolean }; + consumed.push(controller.consumeTerminalData('確定')); + }); + }); + h.textarea.dispatch('compositionstart'); + h.textarea.dispatch('compositionupdate', { data: '確定' }); + h.textarea.dispatch('keydown', { key: 'Enter', isComposing: false }); + + expect(consumed).toEqual([true]); + expect(h.controller.consumeTerminalData('確定')).toBe(false); + expect(h.onCommit).toHaveBeenCalledOnce(); + + h.controller.destroy(); + h.textarea.dispatch('compositionstart'); + h.textarea.dispatch('compositionupdate', { data: 'later' }); + h.textarea.dispatch('keydown', { key: 'Enter', isComposing: false }); + expect(consumed).toEqual([true, false]); + expect(h.onCommit).toHaveBeenCalledOnce(); + }); + + test('generation fences stale frames and timers', () => { + const h = harness(); + h.textarea.dispatch('compositionstart'); + h.textarea.dispatch('compositionupdate', { data: 'old' }); + const staleFrame = h.frames[0].callback; + h.textarea.dispatch('compositionstart'); + staleFrame(); + expect(h.render).not.toHaveBeenCalled(); + + h.textarea.dispatch('compositionend', { data: 'first' }); + h.controller.consumeTerminalData('first'); + const staleTimer = h.timers[0].callback; + h.textarea.dispatch('compositionstart'); + h.textarea.dispatch('compositionupdate', { data: 'new' }); + h.flushFrame(); + staleTimer(); + expect(h.render).toHaveBeenLastCalledWith({ text: 'new', phase: 'provisional' }); + }); + + test('predicted completion clears immediately while fallback waits for output or TTL', () => { + const predicted = harness(); + predicted.textarea.dispatch('compositionstart'); + predicted.clear.mockClear(); + predicted.textarea.dispatch('compositionend', { data: 'one' }); + predicted.controller.consumeTerminalData('one'); + expect(predicted.frames).toHaveLength(1); + predicted.controller.completeCommit({ predicted: true }); + expect(predicted.clear).toHaveBeenCalledOnce(); + expect(predicted.frames).toHaveLength(0); + expect(predicted.timers).toHaveLength(0); + expect(predicted.controller.state.latest).toBe(''); + expect(predicted.controller.state.committed).toBe(false); + + const fallback = harness(); + fallback.textarea.dispatch('compositionstart'); + fallback.clear.mockClear(); + fallback.textarea.dispatch('compositionend', { data: 'two' }); + fallback.controller.consumeTerminalData('two'); + fallback.controller.completeCommit({ predicted: false }); + expect(fallback.clear).not.toHaveBeenCalled(); + fallback.controller.noteAuthoritativeOutput(); + expect(fallback.clear).toHaveBeenCalledOnce(); + expect(fallback.frames).toHaveLength(0); + expect(fallback.timers).toHaveLength(0); + expect(fallback.controller.state.latest).toBe(''); + expect(fallback.controller.state.committed).toBe(false); + + const ttl = harness(); + ttl.textarea.dispatch('compositionstart'); + ttl.clear.mockClear(); + ttl.textarea.dispatch('compositionend', { data: 'three' }); + ttl.controller.consumeTerminalData('three'); + ttl.controller.completeCommit({ predicted: false }); + ttl.flushTimer(); + expect(ttl.clear).toHaveBeenCalledOnce(); + expect(ttl.frames).toHaveLength(0); + expect(ttl.timers).toHaveLength(0); + expect(ttl.controller.state.latest).toBe(''); + expect(ttl.controller.state.committed).toBe(false); + }); + + test('contains re-entrant reset and destroy from commit callbacks without resurrecting work', () => { + let resetController: { reset(): void }; + const reset = harness({ onCommit: () => resetController.reset() }); + resetController = reset.controller; + reset.textarea.dispatch('compositionstart'); + reset.textarea.dispatch('compositionupdate', { data: 'draft' }); + reset.textarea.dispatch('compositionend'); + expect(reset.controller.consumeTerminalData('final')).toBe(true); + expect(reset.frames).toHaveLength(0); + expect(reset.timers).toHaveLength(0); + expect(reset.controller.state.latest).toBe(''); + + let destroyController: { destroy(): void }; + const destroy = harness({ onCommit: () => destroyController.destroy() }); + destroyController = destroy.controller; + destroy.textarea.dispatch('compositionstart'); + destroy.textarea.dispatch('compositionend'); + expect(destroy.controller.consumeTerminalData('final')).toBe(true); + expect(destroy.textarea.listenerCount()).toBe(0); + expect(destroy.frames).toHaveLength(0); + expect(destroy.timers).toHaveLength(0); + expect(destroy.controller.state.latest).toBe(''); + }); + + test('contains re-entrant render and clear callbacks', () => { + let renderController: { reset(): void }; + const render = harness({ render: () => renderController.reset() }); + renderController = render.controller; + render.textarea.dispatch('compositionstart'); + render.textarea.dispatch('compositionupdate', { data: 'draft' }); + expect(() => render.flushFrame()).not.toThrow(); + expect(render.frames).toHaveLength(0); + expect(render.timers).toHaveLength(0); + expect(render.controller.state.latest).toBe(''); + + let clearController: { destroy(): void }; + const clear = harness({ clear: () => clearController?.destroy() }); + clearController = clear.controller; + expect(() => clear.textarea.dispatch('compositionstart')).not.toThrow(); + expect(clear.textarea.listenerCount()).toBe(0); + expect(clear.frames).toHaveLength(0); + expect(clear.timers).toHaveLength(0); + }); + + test('fails open when frame or timer schedulers throw', () => { + const frame = harness({ + scheduleFrame: () => { + throw new Error('frame scheduler'); + }, + }); + frame.textarea.dispatch('compositionstart'); + expect(() => frame.textarea.dispatch('compositionupdate', { data: 'safe' })).not.toThrow(); + expect(frame.controller.state.framePending).toBe(false); + + let lateFrame: (() => void) | undefined; + const timer = harness({ + scheduleFrame: (callback: () => void) => { + lateFrame = callback; + return 1; + }, + cancelFrame: () => {}, + setTimer: () => { + throw new Error('timer scheduler'); + }, + }); + timer.textarea.dispatch('compositionstart'); + timer.clear.mockClear(); + timer.textarea.dispatch('compositionend'); + expect(() => timer.controller.consumeTerminalData('safe')).not.toThrow(); + expect(timer.controller.state.committed).toBe(false); + expect(timer.controller.state.latest).toBe(''); + expect(timer.controller.state.framePending).toBe(false); + expect(timer.controller.state.timerPending).toBe(false); + expect(timer.clear).toHaveBeenCalledOnce(); + lateFrame?.(); + expect(timer.render).not.toHaveBeenCalled(); + + const tokenlessTimer = harness({ setTimer: () => undefined }); + tokenlessTimer.textarea.dispatch('compositionstart'); + tokenlessTimer.clear.mockClear(); + tokenlessTimer.textarea.dispatch('compositionend'); + expect(tokenlessTimer.controller.consumeTerminalData('safe')).toBe(true); + expect(tokenlessTimer.controller.state.committed).toBe(false); + expect(tokenlessTimer.controller.state.latest).toBe(''); + expect(tokenlessTimer.controller.state.framePending).toBe(false); + expect(tokenlessTimer.controller.state.timerPending).toBe(false); + expect(tokenlessTimer.frames).toHaveLength(0); + expect(tokenlessTimer.timers).toHaveLength(0); + expect(tokenlessTimer.clear).toHaveBeenCalledOnce(); + + const cancellation = harness({ + cancelFrame: () => { + throw new Error('frame cancellation'); + }, + clearTimer: () => { + throw new Error('timer cancellation'); + }, + }); + cancellation.textarea.dispatch('compositionstart'); + cancellation.textarea.dispatch('compositionupdate', { data: 'draft' }); + cancellation.textarea.dispatch('compositionend'); + cancellation.controller.consumeTerminalData('final'); + expect(() => cancellation.controller.reset()).not.toThrow(); + expect(cancellation.controller.state.framePending).toBe(false); + expect(cancellation.controller.state.timerPending).toBe(false); + }); + + test('authoritative output never clears active provisional composition', () => { + const h = harness(); + h.textarea.dispatch('compositionstart'); + h.clear.mockClear(); + h.textarea.dispatch('compositionupdate', { data: 'active' }); + h.flushFrame(); + h.controller.noteAuthoritativeOutput(); + expect(h.clear).not.toHaveBeenCalled(); + }); + + test('blur and reset cancel scheduled work and clear visual state', () => { + const h = harness(); + h.textarea.dispatch('compositionstart'); + h.clear.mockClear(); + h.textarea.dispatch('compositionupdate', { data: 'pending' }); + h.textarea.dispatch('blur'); + expect(h.frames).toHaveLength(0); + expect(h.clear).toHaveBeenCalledOnce(); + + h.textarea.dispatch('compositionstart'); + h.clear.mockClear(); + h.textarea.dispatch('compositionupdate', { data: 'again' }); + h.controller.reset(); + expect(h.frames).toHaveLength(0); + expect(h.clear).toHaveBeenCalledOnce(); + expect(h.controller.state.latest).toBe(''); + expect(h.controller.state.committed).toBe(false); + }); + + test('destroy is idempotent and removes listeners and scheduled work', () => { + const h = harness(); + h.textarea.dispatch('compositionstart'); + h.textarea.dispatch('compositionupdate', { data: 'pending' }); + expect(h.textarea.listenerCount()).toBe(6); + h.controller.destroy(); + h.controller.destroy(); + expect(h.textarea.listenerCount()).toBe(0); + expect(h.frames).toHaveLength(0); + h.textarea.dispatch('compositionupdate', { data: 'ignored' }); + expect(h.scheduleFrame).toHaveBeenCalledOnce(); + expect(() => h.controller.reset()).not.toThrow(); + expect(h.controller.state.latest).toBe(''); + expect(h.controller.state.framePending).toBe(false); + expect(h.controller.state.timerPending).toBe(false); + }); + + test('contains render, clear, and commit callback exceptions', () => { + const h = harness({ + render: vi.fn(() => { + throw new Error('render'); + }), + clear: vi.fn(() => { + throw new Error('clear'); + }), + onCommit: vi.fn(() => { + throw new Error('commit'); + }), + }); + h.textarea.dispatch('compositionstart'); + h.textarea.dispatch('compositionupdate', { data: 'safe' }); + expect(() => h.flushFrame()).not.toThrow(); + h.textarea.dispatch('compositionend', { data: 'safe' }); + expect(() => h.controller.consumeTerminalData('safe')).not.toThrow(); + expect(() => h.controller.reset()).not.toThrow(); + }); + + test.each([ + [{ userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true], + [{ userAgent: 'Mozilla/5.0 (iPad) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true], + [{ userAgent: 'Mozilla/5.0 (iPod) AppleWebKit/605.1.15', maxTouchPoints: 1 }, true], + [{ userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15', platform: 'MacIntel', maxTouchPoints: 2 }, true], + [{ userAgent: 'CriOS/120.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 5 }, true], + [{ userAgent: 'Mozilla/5.0 (iPhone) Gecko/120', maxTouchPoints: 5 }, false], + [{ userAgent: 'Mozilla/5.0 (iPhone) AppleWebKit/605.1.15', maxTouchPoints: 0 }, false], + [{ userAgent: 'Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15', platform: 'MacIntel', maxTouchPoints: 1 }, false], + [{ userAgent: 'Mozilla/5.0 (Android) AppleWebKit/537.36', maxTouchPoints: 5 }, false], + ])('detects iOS WebKit touch eligibility for %j', (nav, expected) => { + expect(harness().api.isIosWebKitTouch(nav)).toBe(expected); + }); +});