From e0542bb1725a166e3e043d96e6299d115490fa71 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 26 Sep 2026 17:06:11 -0400 Subject: [PATCH 01/32] 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 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); + }); +}); From 2d96472dbe5f19e90bfd7db3075969fcdcb268cd Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 26 Sep 2026 22:47:48 -0400 Subject: [PATCH 02/32] 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 6c8a8dc9..02cde906 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 9f3ed295..0d1eac00 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 c18ece58..42826c8a 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 9413522d..7942255a 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 5daa5c5c..d673fa67 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 00000000..504282d1 --- /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 8b52b35a..cfdec9a3 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(consumed).toEqual([true]); - expect(h.controller.consumeTerminalData('確定')).toBe(false); - expect(h.onCommit).toHaveBeenCalledOnce(); + expect(h.emitted).toEqual([ + { data: '確定', consumed: true }, + { data: '\r', consumed: false }, + ]); + expect(h.onCommit).toHaveBeenCalledWith('確定'); + expect(h.controller.state).toMatchObject({ composing: false, awaitingCommit: false, committed: true }); + h.flushFrame(); + expect(h.render).toHaveBeenLastCalledWith({ text: '確定', phase: 'committed' }); + // The next unrelated keystroke is ordinary input, not an IME commit. + expect(h.controller.consumeTerminalData('x')).toBe(false); + }); + + test.each([ + // keyCode 229 with isComposing:false and a real key identity: xterm keeps + // composing, so the controller must too. + ['the IME composition character', 'k', 229], + ['CapsLock', 'CapsLock', 20], + ['Shift', 'Shift', 16], + ['Control', 'Control', 17], + ['Alt', 'Alt', 18], + ])('a keydown for %s keeps tracking the composition xterm is still composing', (_label, key, keyCode) => { + const h = withXtermStandIn(); + h.textarea.dispatch('compositionstart'); + h.textarea.dispatch('compositionupdate', { data: 'か' }); + h.textarea.dispatch('keydown', { key, keyCode, isComposing: false }); + expect(h.isXtermComposing()).toBe(true); + expect(h.controller.state).toMatchObject({ composing: true, awaitingCommit: false }); + + // The preview follows the composition instead of freezing on the old value. + h.textarea.dispatch('compositionupdate', { data: 'かな' }); + h.flushFrame(); + expect(h.render).toHaveBeenLastCalledWith({ text: 'かな', phase: 'provisional' }); + expect(h.emitted).toEqual([]); + }); + + test('ignores keydowns that did not target the helper textarea', () => { + const h = harness(); + const sibling = new FakeNode(); + sibling.parent = h.element; + h.textarea.dispatch('compositionstart'); + h.textarea.dispatch('compositionupdate', { data: '漢字' }); + sibling.dispatch('keydown', { key: 'Enter', keyCode: 13, isComposing: false }); + expect(h.controller.state).toMatchObject({ composing: true, awaitingCommit: false }); + }); + + test('destroy stops the controller observing keydown on the terminal element', () => { + const h = withXtermStandIn(); h.controller.destroy(); h.textarea.dispatch('compositionstart'); h.textarea.dispatch('compositionupdate', { data: 'later' }); - h.textarea.dispatch('keydown', { key: 'Enter', 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 03/32] 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 02cde906..0fa36dfa 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 6a25c5b3..402a5df5 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 d88c8743..7e8d7a07 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 bb9f7fe1..e81d610d 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 b1e4105b..ce7831cb 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 00000000..4f7490d1 --- /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 7942255a..5c48d556 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 d673fa67..62f4db34 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 504282d1..864683ad 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: '今日は天気' }); + }); +}); From 4123d229f430091a106eb1de0d980e5d65b77b3d Mon Sep 17 00:00:00 2001 From: Michael Grundberg Date: Thu, 1 Oct 2026 16:59:45 +0200 Subject: [PATCH 04/32] feat(sessions): accept a per-session Claude model on POST /api/sessions POST /api/sessions takes an optional `model`, and a Claude session launches with `claude --model `. It wins over the app-wide default model and writes nothing to disk, unlike `modelOverride`, which stays as it is and still writes the case's .claude/settings.local.json. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../skills/codeman/reference/endpoints.md | 3 +- skills/codeman/reference/endpoints.md | 3 +- src/web/routes/session-routes.ts | 9 +- src/web/schemas.ts | 10 ++ .../session-routes-claude-model.test.ts | 94 +++++++++++++++++++ 5 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 test/routes/session-routes-claude-model.test.ts diff --git a/plugins/codeman/skills/codeman/reference/endpoints.md b/plugins/codeman/skills/codeman/reference/endpoints.md index d8b26507..f7a5f622 100644 --- a/plugins/codeman/skills/codeman/reference/endpoints.md +++ b/plugins/codeman/skills/codeman/reference/endpoints.md @@ -384,7 +384,8 @@ every claude create path installs them, so a linked case and a raw path both get **The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`, -`envOverrides`). Three differences that break copied code: +`envOverrides`, and for claude a per-session `model` passed as `--model`). Three +differences that break copied code: - The id is at **`.data.session.id`**, not quick-start's `.data.sessionId` (`session-routes.ts:878` returns `{ session: lightState }`). diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index d8b26507..f7a5f622 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -384,7 +384,8 @@ every claude create path installs them, so a linked case and a raw path both get **The two-step alternative, `POST /api/v1/sessions`.** Use it when you need a session in a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort`, -`envOverrides`). Three differences that break copied code: +`envOverrides`, and for claude a per-session `model` passed as `--model`). Three +differences that break copied code: - The id is at **`.data.session.id`**, not quick-start's `.data.sessionId` (`session-routes.ts:878` returns `{ session: lightState }`). diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 6a7b78ad..33c82ac4 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -1073,9 +1073,10 @@ export function registerSessionRoutes( // genuinely different mechanisms: // 'flag' — the CLI takes --model, so read the value the caller sent // in that CLI's own config object. - // 'claude-settings-file' — claude alone, whose model is written to - // /.claude/settings.local.json rather than passed as - // a flag, so the app-wide default applies here. + // 'claude-settings-file' — claude alone, whose persistent model is written to + // /.claude/settings.local.json (`modelOverride`). A + // per-session `model` from the caller goes out as --model and + // wins; without one, the app-wide default applies. // 'none' — shell has no model; deepseek's is a composition entry in // the profile's config tree, not a session field // (docs/deepseek-integration.md). Both get nothing. @@ -1086,7 +1087,7 @@ export function registerSessionRoutes( | string | undefined) : modelSource?.source === 'claude-settings-file' - ? modelConfig?.defaultModel || undefined + ? body.model || modelConfig?.defaultModel || undefined : undefined; const claudeModeConfig = await ctx.getClaudeModeConfig(); // Section 6.3: force non-granted users to a classifier-guarded mode. diff --git a/src/web/schemas.ts b/src/web/schemas.ts index c12a83a9..e9859a73 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -526,6 +526,16 @@ export const CreateSessionSchema = z.object({ effort: effortLevelSchema, /** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */ modelOverride: z.string().max(50).optional(), + /** + * Claude model for THIS session only, passed as `claude --model `; nothing is written to + * disk. Wins over the app-wide default model. Same character set as the registry's + * `model-claude` pattern, so a value accepted here is never rejected at launch. + */ + model: z + .string() + .max(100) + .regex(/^[a-zA-Z0-9._\-[\]]+$/) + .optional(), openCodeConfig: OpenCodeConfigSchema, codexConfig: CodexConfigSchema, geminiConfig: GeminiConfigSchema, diff --git a/test/routes/session-routes-claude-model.test.ts b/test/routes/session-routes-claude-model.test.ts new file mode 100644 index 00000000..8b9ae957 --- /dev/null +++ b/test/routes/session-routes-claude-model.test.ts @@ -0,0 +1,94 @@ +/** + * @fileoverview `model` on POST /api/sessions — a Claude model for one session only. + * + * Claude's model reaches disk only through `modelOverride`, which writes it into the + * case's `.claude/settings.local.json` for every later run there. `model` is the + * per-session counterpart: it goes out as `claude --model `, wins over the app-wide + * default, and writes nothing. What the tests read is the model the session hands the + * mux when it starts, which is what becomes the `--model` flag. + * + * Uses app.inject(), so no real HTTP port is needed. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; + +interface Harness { + app: FastifyInstance; + ctx: MockRouteContext; +} + +async function createHarness(): Promise { + const app = Fastify({ logger: false }); + await app.register(fastifyCookie); + const ctx = createMockRouteContext(); + registerSessionRoutes(app, ctx); + installRouteErrorHandler(app); + await app.ready(); + return { app, ctx }; +} + +describe('POST /api/sessions model', () => { + let workingDir: string; + let harness: Harness; + + beforeEach(async () => { + workingDir = await mkdtemp(join(tmpdir(), 'codeman-session-model-')); + harness = await createHarness(); + }); + + afterEach(async () => { + await harness.app.close(); + await rm(workingDir, { recursive: true, force: true }); + }); + + /** Creates a session and starts it, then returns the model it handed the mux. */ + async function launchedModel(payload: Record): Promise { + const res = await harness.app.inject({ method: 'POST', url: '/api/sessions', payload: { workingDir, ...payload } }); + expect(res.statusCode).toBe(200); + const parsed = JSON.parse(res.body); + const id = (parsed.data?.session ?? parsed.session).id as string; + await harness.app.inject({ method: 'POST', url: `/api/sessions/${id}/interactive`, payload: {} }); + const calls = harness.ctx.mux.createSession.mock.calls; + expect(calls.length).toBeGreaterThan(0); + return (calls[calls.length - 1][0] as { model?: string }).model; + } + + it('launches a Claude session on the model the caller names', async () => { + expect(await launchedModel({ mode: 'claude', model: 'claude-fable-5-1' })).toBe('claude-fable-5-1'); + }); + + it('wins over the app-wide default model', async () => { + harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' }); + expect(await launchedModel({ mode: 'claude', model: 'opus' })).toBe('opus'); + }); + + it('leaves the app-wide default in charge when the caller names none', async () => { + harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' }); + expect(await launchedModel({ mode: 'claude' })).toBe('sonnet'); + }); + + it('writes no model into the case directory', async () => { + // The create still installs Codeman's workspace hooks into settings.local.json, so the + // file exists; what must not be in it is a model that would outlive this session. + await launchedModel({ mode: 'claude', model: 'opus' }); + const settings = await readFile(join(workingDir, '.claude', 'settings.local.json'), 'utf8').catch(() => '{}'); + expect(JSON.parse(settings)).not.toHaveProperty('model'); + }); + + it('rejects a model with characters the launch pattern refuses', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions', + payload: { workingDir, mode: 'claude', model: 'opus; rm -rf ~' }, + }); + expect(res.statusCode).toBe(400); + }); +}); From 45db24bacfb6564731c34a54e5ebed3ba33b2fd7 Mon Sep 17 00:00:00 2001 From: Michael Grundberg Date: Thu, 1 Oct 2026 16:59:46 +0200 Subject: [PATCH 05/32] feat(codex): start a codex session at a chosen reasoning effort codexConfig takes a `reasoningEffort`, one of the levels codex accepts, and the session starts with `--config model_reasoning_effort=`. The registry declares one literal per level, gated on the enum, because an argv token cannot splice a value into a literal. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/config/cli-registry/stock.ts | 10 ++++++ src/types/session.ts | 12 +++++++ src/web/schemas.ts | 2 ++ test/cli-registry-spawn-golden.test.ts | 13 ++++++++ test/codex-reasoning-effort-schema.test.ts | 38 ++++++++++++++++++++++ 5 files changed, 75 insertions(+) create mode 100644 test/codex-reasoning-effort-schema.test.ts diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 465da26a..87651161 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -11,6 +11,7 @@ */ import type { CliEntry } from './types.js'; +import { CODEX_REASONING_EFFORTS } from '../../types/session.js'; const HOME_DIRS = { local: '~/.local/bin', @@ -532,6 +533,7 @@ const CODEX: CliEntry = { bypassApprovals: { type: 'bool' }, animations: { type: 'bool' }, model: { type: 'token', pattern: 'model' }, + reasoningEffort: { type: 'enum', values: [...CODEX_REASONING_EFFORTS] }, resumeId: { type: 'token', pattern: 'id' }, }, variants: [ @@ -543,6 +545,14 @@ const CODEX: CliEntry = { { flag: '--config', value: 'tui.animations=true', when: { param: 'animations', is: true } }, { flag: '--config', value: 'tui.animations=false', when: { param: 'animations', is: false } }, { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + // One literal per level: an argv token cannot splice a value into a literal, and + // `model_reasoning_effort=` is a single `--config` value. The enum above is + // what admits a level, so an unknown one emits nothing. + ...CODEX_REASONING_EFFORTS.map((level) => ({ + flag: '--config', + value: `model_reasoning_effort=${level}`, + when: { param: 'reasoningEffort', is: level }, + })), { lit: 'resume', when: { param: 'resumeId', state: 'set' } }, { valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, ], diff --git a/src/types/session.ts b/src/types/session.ts index 49249aff..1b1ae9b7 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -377,6 +377,16 @@ export function isEffortLevel(value: string | undefined): value is EffortLevel { return value !== undefined && (EFFORT_LEVELS as readonly string[]).includes(value); } +/** + * Reasoning effort levels codex accepts as `model_reasoning_effort` (codex-cli 0.154.0). + * Which of them a given model honours is codex's business; Codeman only keeps the value + * to a known word, since it lands in the launch argv. + */ +export const CODEX_REASONING_EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] as const; + +/** Codex reasoning effort for a session, passed as `--config model_reasoning_effort=` */ +export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORTS)[number]; + /** OpenCode session configuration */ export interface OpenCodeConfig { /** Model identifier (e.g., "anthropic/claude-sonnet-4-5", "openai/gpt-5.2", "ollama/codellama") */ @@ -398,6 +408,8 @@ export type CodexRenderMode = 'hybrid'; export interface CodexConfig { /** Model identifier (e.g., "gpt-5", "o4-mini"). Passed via --model. */ model?: string; + /** Reasoning effort for this session. Passed via --config model_reasoning_effort=. */ + reasoningEffort?: CodexReasoningEffort; /** Resume a previous codex conversation by session id (passed via --resume) */ resumeSessionId?: string; /** Bypass approval prompts (passes --dangerously-bypass-approvals-and-sandbox) */ diff --git a/src/web/schemas.ts b/src/web/schemas.ts index c12a83a9..e73d7e85 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -18,6 +18,7 @@ import { MIN_TERMINAL_SCROLLBACK_LINES, } from '../config/terminal-history.js'; import { MAX_EDITABLE_BYTES } from '../config/file-editing.js'; +import { CODEX_REASONING_EFFORTS } from '../types/session.js'; import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js'; import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js'; import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js'; @@ -298,6 +299,7 @@ const CodexConfigSchema = z .max(100) .regex(/^[a-zA-Z0-9._\-/]+$/) .optional(), + reasoningEffort: z.enum(CODEX_REASONING_EFFORTS).optional(), resumeSessionId: z .string() .max(100) diff --git a/test/cli-registry-spawn-golden.test.ts b/test/cli-registry-spawn-golden.test.ts index 72efee78..3883018c 100644 --- a/test/cli-registry-spawn-golden.test.ts +++ b/test/cli-registry-spawn-golden.test.ts @@ -24,6 +24,7 @@ import { describe, it, expect } from 'vitest'; import { getCli } from '../src/config/cli-registry/registry.js'; import { buildSpawnCommandFromRegistry, type SpawnBridgeOptions } from '../src/session-cli-registry-bridge.js'; +import { CODEX_REASONING_EFFORTS } from '../src/types/session.js'; /** A fixed session id, so `--session-id` is stable across runs. */ const SID = '0f9c2b14-1111-2222-3333-444455556666'; @@ -131,6 +132,18 @@ describe('codex', () => { it('resumes with a POSITIONAL subcommand, not a flag', () => { expect(cx({ model: 'gpt-5', resumeSessionId: 'roll_42' })).toBe('codex --model gpt-5 resume roll_42'); }); + + it('sends reasoning effort as one model_reasoning_effort config value, for every level', () => { + for (const level of CODEX_REASONING_EFFORTS) { + expect(cx({ reasoningEffort: level })).toBe(`codex --config model_reasoning_effort=${level}`); + } + }); + + it('keeps reasoning effort ahead of the resume subcommand', () => { + expect(cx({ model: 'gpt-5', reasoningEffort: 'high', resumeSessionId: 'roll_42' })).toBe( + 'codex --model gpt-5 --config model_reasoning_effort=high resume roll_42' + ); + }); }); describe('gemini', () => { diff --git a/test/codex-reasoning-effort-schema.test.ts b/test/codex-reasoning-effort-schema.test.ts new file mode 100644 index 00000000..a216472f --- /dev/null +++ b/test/codex-reasoning-effort-schema.test.ts @@ -0,0 +1,38 @@ +/** + * @fileoverview `codexConfig.reasoningEffort` on the create routes. + * + * The level becomes part of a `--config model_reasoning_effort=` launch token, so the + * schema admits only the words codex knows; anything else fails the request rather than + * reaching the argv. + */ + +import { describe, it, expect } from 'vitest'; +import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js'; +import { CODEX_REASONING_EFFORTS } from '../src/types/session.js'; + +describe('codexConfig.reasoningEffort', () => { + it('accepts every level codex knows on both create routes', () => { + for (const level of CODEX_REASONING_EFFORTS) { + const created = CreateSessionSchema.parse({ + workingDir: '/tmp', + mode: 'codex', + codexConfig: { reasoningEffort: level }, + }); + expect(created.codexConfig?.reasoningEffort).toBe(level); + const quick = QuickStartSchema.parse({ + caseName: 'work', + mode: 'codex', + codexConfig: { reasoningEffort: level }, + }); + expect(quick.codexConfig?.reasoningEffort).toBe(level); + } + }); + + it('rejects a level codex does not know, and anything shaped like shell', () => { + for (const reasoningEffort of ['bogus', 'HIGH', 'high; rm -rf /', '']) { + expect(() => + CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'codex', codexConfig: { reasoningEffort } }) + ).toThrow(); + } + }); +}); From 0ae39cdd94dcfc08317f56837d67927f00f3f32d Mon Sep 17 00:00:00 2001 From: Michael Grundberg Date: Thu, 1 Oct 2026 17:16:18 +0200 Subject: [PATCH 06/32] test(codex): pin reasoning effort through quick-start and the multi-user clamp Both create schemas now refuse an unknown level, and a non-granted owner's codexConfig keeps its reasoningEffort when the clamp forces bypass off. docs/architecture-invariants.md lists the two --config values codex now takes from codexConfig. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture-invariants.md | 2 +- test/codex-reasoning-effort-schema.test.ts | 5 ++++- test/routes/external-cli-bypass-clamp.test.ts | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 848ae8f1..936be89a 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -37,7 +37,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test. ### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP) -**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation ` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume ` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`. +**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `--config model_reasoning_effort=` from `reasoningEffort`, `--config tui.animations=` from `animations`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation ` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume ` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`. **DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it. diff --git a/test/codex-reasoning-effort-schema.test.ts b/test/codex-reasoning-effort-schema.test.ts index a216472f..d018a485 100644 --- a/test/codex-reasoning-effort-schema.test.ts +++ b/test/codex-reasoning-effort-schema.test.ts @@ -28,11 +28,14 @@ describe('codexConfig.reasoningEffort', () => { } }); - it('rejects a level codex does not know, and anything shaped like shell', () => { + it('rejects a level codex does not know, and anything shaped like shell, on both create routes', () => { for (const reasoningEffort of ['bogus', 'HIGH', 'high; rm -rf /', '']) { expect(() => CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'codex', codexConfig: { reasoningEffort } }) ).toThrow(); + expect(() => + QuickStartSchema.parse({ caseName: 'work', mode: 'codex', codexConfig: { reasoningEffort } }) + ).toThrow(); } }); }); diff --git a/test/routes/external-cli-bypass-clamp.test.ts b/test/routes/external-cli-bypass-clamp.test.ts index 226f7fda..22cf06ec 100644 --- a/test/routes/external-cli-bypass-clamp.test.ts +++ b/test/routes/external-cli-bypass-clamp.test.ts @@ -104,6 +104,20 @@ describe('clampExternalCliBypassForOwner — multi-user mode', () => { expect(out.grokConfig).toEqual({ alwaysApprove: false, model: 'grok-4.5' }); }); + it("keeps a non-granted owner's codex reasoning effort while forcing bypass off", async () => { + // The clamp rewrites one field and must carry the rest; a clamp rebuilt from named + // fields would drop the effort here without a word. + const out = await _clampExternalCliBypassForOwner( + 'peon', + { dangerouslyBypassApprovals: true, reasoningEffort: 'xhigh' }, + undefined, + undefined, + undefined, + undefined + ); + expect(out.codexConfig).toEqual({ dangerouslyBypassApprovals: false, reasoningEffort: 'xhigh' }); + }); + it('leaves codex/antigravity/grok absent when nothing was sent (they already spawn safe)', async () => { const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined, undefined); expect(out.codexConfig).toBeUndefined(); From 3df113fc54e38ce5e6abcd51f56401595044adf2 Mon Sep 17 00:00:00 2001 From: Michael Grundberg Date: Thu, 1 Oct 2026 17:24:12 +0200 Subject: [PATCH 07/32] fix(sessions): keep a session's model through recovery, refuse it off claude SessionState now carries the model a session launched with, and both recovery constructors (mux recovery and reboot restore) pass it back, so a recovered session relaunches on the same --model rather than the account default. A top-level `model` sent with any other CLI is refused, since those take their model in their own config object, and an empty string means no per-session model, as it does for modelOverride. CLAUDE.md now describes both routes for a Claude model. The tests pin which of `model` and `modelOverride` reaches the launch and which the case file, and that a model opening with a dash renders as --model's value. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- .../skills/codeman/reference/endpoints.md | 4 +- skills/codeman/reference/endpoints.md | 4 +- src/session.ts | 1 + src/types/session.ts | 7 ++ src/web/routes/reboot-restore-routes.ts | 1 + src/web/routes/session-routes.ts | 9 +++ src/web/schemas.ts | 5 +- src/web/server.ts | 1 + test/cli-registry-spawn-golden.test.ts | 13 ++++ .../session-routes-claude-model.test.ts | 30 ++++++-- test/session-model-recovery.test.ts | 71 +++++++++++++++++++ 12 files changed, 136 insertions(+), 12 deletions(-) create mode 100644 test/session-model-recovery.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 539f9b32..cdf54fd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,7 +137,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph - **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically) - **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects /projects`). ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir) - **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort ` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts` -- **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not +- **Model choice: a persistent default in `settings.local.json`, a per-session `--model`, never env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not. A caller that wants one session on a model without touching the case sends `model` on `POST /api/sessions` instead: it goes out as `claude --model `, writes nothing, wins over the app-wide default, and is persisted as `SessionState.model` so both recovery paths relaunch on it (`test/routes/session-routes-claude-model.test.ts`, `test/session-model-recovery.test.ts`). - **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*` vs `PI_*` vs `GROK_*` vs `DSH_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this; non-prefix exceptions are exact keys in `ALLOWED_ENV_KEYS` (currently only `CLAUDE_CONFIG_DIR`), never a widened prefix. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI), and Grok allowlists **`XAI_*`** for the same vendor-namespace reason (`XAI_API_KEY` is grok's documented auth var). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `HF_TOKEN`, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via `/login` or the server process's own env. ⚠️ DeepSeek repeats pi's lesson exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), so only the vendor namespaces `DSH_*` (launcher inputs incl. `DSH_PERMISSION_MODE`) and `DEEPSEEK_*` (`DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`) are admitted; foreign provider keys authenticate from dsh's own files or the server env. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`, `docs/grok-integration.md`, `docs/deepseek-integration.md` - **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. This has caused real shipped bugs twice - **Local-echo overlay stays on screen**: the overlay lays its wrapped lines out DOWNWARD from the prompt row, and the text has not reached the PTY yet, so the CLI never learns the prompt is long and nothing scrolls to make room. With the keyboard up only a handful of rows are visible, so a long prompt used to run off the bottom and the user typed blind. The block now grows UPWARD once it would pass the last visible row (optional `totalRows` in `RenderParams`; the line divs are opaque, so they cover transcript above), and a prompt taller than the viewport keeps its TAIL. ⚠️ Separately, `_shrinkPaddingToFit()` (mobile-handlers.js) must never shrink `main`'s padding-bottom below the MEASURED height of the fixed bars: on phones the toolbar and accessory bar are `position: fixed`, so that padding is the only thing reserving room for them, and taking it pulled the terminal's bottom row behind them. Tests: `packages/xterm-zerolag-input/test/overlay-renderer.test.ts`, `test/mobile-keyboard-bottom-padding.test.ts`. diff --git a/plugins/codeman/skills/codeman/reference/endpoints.md b/plugins/codeman/skills/codeman/reference/endpoints.md index f7a5f622..1913df20 100644 --- a/plugins/codeman/skills/codeman/reference/endpoints.md +++ b/plugins/codeman/skills/codeman/reference/endpoints.md @@ -388,14 +388,14 @@ a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort differences that break copied code: - The id is at **`.data.session.id`**, not quick-start's `.data.sessionId` - (`session-routes.ts:878` returns `{ session: lightState }`). + (the `POST /api/sessions` handler in `session-routes.ts` returns `{ session: lightState }`). - **It spawns no PTY.** The session exists with `pid:null` and nothing running, so `wait?until=exit` answers `exit` immediately. Follow it with `POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or `POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker. - Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's `SESSION_BUSY` (409), from the same global-50 / per-user-25 caps - (`session-routes.ts:648`). + (`sessionCapacityMessage()` in `route-helpers.ts`). ⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index f7a5f622..1913df20 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -388,14 +388,14 @@ a directory that is not a case (body takes `workingDir`, `mode`, `name`, `effort differences that break copied code: - The id is at **`.data.session.id`**, not quick-start's `.data.sessionId` - (`session-routes.ts:878` returns `{ session: lightState }`). + (the `POST /api/sessions` handler in `session-routes.ts` returns `{ session: lightState }`). - **It spawns no PTY.** The session exists with `pid:null` and nothing running, so `wait?until=exit` answers `exit` immediately. Follow it with `POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or `POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker. - Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's `SESSION_BUSY` (409), from the same global-50 / per-user-25 caps - (`session-routes.ts:648`). + (`sessionCapacityMessage()` in `route-helpers.ts`). ⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit circuit breaker**. That breaker exists to stop a session that keeps crashing on spawn diff --git a/src/session.ts b/src/session.ts index 3a26e90e..08b64194 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1827,6 +1827,7 @@ export class Session extends EventEmitter { ompConfig: this._ompConfig, resumeSessionId: this._resumeSessionId, effort: this._effort, + model: this._model, customModel: this.customModel, // COD-118: runtime-only — surfaced so the frontend can require explicit user // intent before restarting a crash-looped session. Deliberately NOT restored diff --git a/src/types/session.ts b/src/types/session.ts index 49249aff..bf70f44a 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -795,6 +795,13 @@ export interface SessionState { resumeSessionId?: string; /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ effort?: EffortLevel; + /** + * The model the session was LAUNCHED with (`--model`): the caller's per-session `model`, or + * the app-wide default when there was none. Persisted so a recovered session relaunches on + * the same model rather than whatever the default is by then. Not `cliModel`, which is what + * the CLI's banner reports. + */ + model?: string; /** * Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): the custom * OpenAI-compatible endpoint (local or cloud) this session's CLI is currently pointed diff --git a/src/web/routes/reboot-restore-routes.ts b/src/web/routes/reboot-restore-routes.ts index fb6cec97..fb0221fd 100644 --- a/src/web/routes/reboot-restore-routes.ts +++ b/src/web/routes/reboot-restore-routes.ts @@ -195,6 +195,7 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes (saved as { __envOverrides?: Record }).__envOverrides ), effort: saved.effort, + model: saved.model, attachmentHistory: (saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ?? saved.attachmentHistory, diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 33c82ac4..2e8e12fd 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -888,6 +888,15 @@ export function registerSessionRoutes( if (capMsg) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, capMsg); const body = parseBody(CreateSessionSchema, req.body); + // The top-level `model` is Claude's per-session `--model`. Every other CLI takes its model + // in its own config object (`codexConfig.model` and so on), so a `model` here would be + // dropped without a word; refuse it before anything is written for the session. + if (body.model && getCli(body.mode ?? 'claude')?.capabilities.model.source !== 'claude-settings-file') { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'model applies to claude sessions only; other CLIs take their model in their own config object, such as codexConfig.model' + ); + } let workingDir = body.workingDir || process.cwd(); let remote = undefined; diff --git a/src/web/schemas.ts b/src/web/schemas.ts index e9859a73..c432a470 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -529,12 +529,15 @@ export const CreateSessionSchema = z.object({ /** * Claude model for THIS session only, passed as `claude --model `; nothing is written to * disk. Wins over the app-wide default model. Same character set as the registry's - * `model-claude` pattern, so a value accepted here is never rejected at launch. + * `model-claude` pattern, so a value accepted here is never rejected at launch. An empty + * string means no per-session model, as it does for `modelOverride`. Claude only: the route + * refuses it for any other CLI. */ model: z .string() .max(100) .regex(/^[a-zA-Z0-9._\-[\]]+$/) + .or(z.literal('')) .optional(), openCodeConfig: OpenCodeConfigSchema, codexConfig: CodexConfigSchema, diff --git a/src/web/server.ts b/src/web/server.ts index d70577e6..c3ad7c5c 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -3489,6 +3489,7 @@ export class WebServer extends EventEmitter { ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined, envOverrides: savedEnvOverrides, effort: savedState?.effort, + model: savedState?.model, attachmentHistory: savedAttachmentHistory, // The pane's last Enter. Without it the response viewer would show // the launch conversation until the user types again, even though diff --git a/test/cli-registry-spawn-golden.test.ts b/test/cli-registry-spawn-golden.test.ts index 72efee78..625dc7df 100644 --- a/test/cli-registry-spawn-golden.test.ts +++ b/test/cli-registry-spawn-golden.test.ts @@ -54,6 +54,19 @@ describe('claude', () => { ); }); + it('renders a model as the quoted value of --model, even one that opens with a dash', () => { + // POST /api/sessions admits a leading '-' in `model`. It still lands as the option's + // value: quoted here, and Claude's option parser takes the word after `--model` as its + // value whatever it starts with, so it can never become a flag of its own. + expect(claude({ model: 'claude-fable-5-1' })).toBe( + 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" --model "claude-fable-5-1"' + ); + expect(claude({ model: '--dangerously-skip-permissions' })).toBe( + 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' + + '--model "--dangerously-skip-permissions"' + ); + }); + it('resumes through a shell fallback to a fresh session', () => { // The ` || ` is emitted by the ENGINE, not by config — no registry field can hold shell // text. This pin is what proves the fallback chain still renders as one command line. diff --git a/test/routes/session-routes-claude-model.test.ts b/test/routes/session-routes-claude-model.test.ts index 8b9ae957..658b8692 100644 --- a/test/routes/session-routes-claude-model.test.ts +++ b/test/routes/session-routes-claude-model.test.ts @@ -75,12 +75,30 @@ describe('POST /api/sessions model', () => { expect(await launchedModel({ mode: 'claude' })).toBe('sonnet'); }); - it('writes no model into the case directory', async () => { - // The create still installs Codeman's workspace hooks into settings.local.json, so the - // file exists; what must not be in it is a model that would outlive this session. - await launchedModel({ mode: 'claude', model: 'opus' }); - const settings = await readFile(join(workingDir, '.claude', 'settings.local.json'), 'utf8').catch(() => '{}'); - expect(JSON.parse(settings)).not.toHaveProperty('model'); + it('launches on `model` while `modelOverride` alone reaches the case file', async () => { + // Sent together, each lands where it belongs: the persistent default in the case's + // settings.local.json, and this session's model on its launch line. A route that wrote + // `model` to disk would put 'opus' in the file; one that ignored it would launch 'sonnet'. + expect(await launchedModel({ mode: 'claude', model: 'opus', modelOverride: 'sonnet' })).toBe('opus'); + const settings = JSON.parse(await readFile(join(workingDir, '.claude', 'settings.local.json'), 'utf8')); + expect(settings.model).toBe('sonnet'); + }); + + it('reads an empty model as no model, as modelOverride does', async () => { + harness.ctx.getModelConfig.mockResolvedValue({ defaultModel: 'sonnet' }); + expect(await launchedModel({ mode: 'claude', model: '' })).toBe('sonnet'); + }); + + it('refuses a model for a CLI that takes its model in its own config object', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions', + payload: { workingDir, mode: 'codex', model: 'gpt-5' }, + }); + const parsed = JSON.parse(res.body); + expect(parsed.success).toBe(false); + expect(parsed.errorCode).toBe('INVALID_INPUT'); + expect(harness.ctx.sessions.size).toBe(1); // only the session the mock context starts with }); it('rejects a model with characters the launch pattern refuses', async () => { diff --git a/test/session-model-recovery.test.ts b/test/session-model-recovery.test.ts new file mode 100644 index 00000000..54a09d70 --- /dev/null +++ b/test/session-model-recovery.test.ts @@ -0,0 +1,71 @@ +/** + * @fileoverview A session's launch model survives recovery. + * + * `Session._model` is what becomes `claude --model `: the caller's per-session `model` + * from POST /api/sessions, or the app-wide default. It lives in memory, so it reaches a + * relaunch after a Codeman restart or a reboot restore only if `toState()` persists it and + * both recovery constructors hand it back. Without that, a recovered session silently + * relaunches on the account default. + * + * `restoreMuxSessions()` (server.ts) cannot be reached under vitest, where + * `reconcileSessions()` reports every pane alive, and the reboot-restore route rejects every + * workspace before building a Session in its route tests. The two constructors are therefore + * pinned by a source check, the same way `test/remote-wake.test.ts` pins its wiring, and the + * round trip itself is driven through a real `Session` against the in-memory tmux layer. + */ +import { mkdirSync, readFileSync, rmSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Session } from '../src/session.js'; +import { TmuxManager } from '../src/tmux-manager.js'; + +const SRC = fileURLToPath(new URL('../src', import.meta.url)); + +describe('the launch model survives recovery', () => { + const workingDir = join(homedir(), 'codeman-cases', 'session-model-recovery'); + const sessions: Session[] = []; + + afterEach(() => { + for (const s of sessions.splice(0)) s.stop(); + rmSync(workingDir, { recursive: true, force: true }); + }); + + it('persists the model in the session state', () => { + const session = new Session({ workingDir: '/tmp', mode: 'claude', model: 'claude-fable-5-1' }); + sessions.push(session); + expect(session.toState().model).toBe('claude-fable-5-1'); + }); + + it('relaunches a session rebuilt from that state on the same model', async () => { + mkdirSync(workingDir, { recursive: true }); + const original = new Session({ workingDir, mode: 'claude', model: 'claude-fable-5-1' }); + sessions.push(original); + const state = original.toState(); + + // Rebuilt the way both recovery paths build one, from the persisted record. + const mux = new TmuxManager(); + const createSession = vi.spyOn(mux, 'createSession'); + const rebuilt = new Session({ + id: state.id, + workingDir, + mode: state.mode, + mux, + useMux: true, + model: state.model, + }); + sessions.push(rebuilt); + await rebuilt.startInteractive(); + + expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ model: 'claude-fable-5-1' })); + }); + + it('is handed back by both recovery constructors', () => { + const server = readFileSync(join(SRC, 'web', 'server.ts'), 'utf-8'); + const reboot = readFileSync(join(SRC, 'web', 'routes', 'reboot-restore-routes.ts'), 'utf-8'); + expect(server).toMatch(/model:\s*savedState\?\.model,/); + expect(reboot).toMatch(/model:\s*saved\.model,/); + }); +}); From 7cbce5bf6c81843ceed4c827e7add39f75f98fc1 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Thu, 1 Oct 2026 14:14:30 -0400 Subject: [PATCH 08/32] feat(tabs): grouped vertical rail from owner tab layouts The vertical tab rail now reads the owner's tab layout (GET /api/tab-layout) and draws its groups as collapsible sections. This is the first frontend consumer of the tab-layout backend and it is read-only: nothing in the browser writes the layout yet. - tab-layout-browser.js (new, pure, loaded before app.js): projects the layout onto the live sessions and open web tabs, renders the grouped markup, stores collapse per device, and sequences loads newest-wins with a bounded retry on failure. - app.js: loads the layout on init and on tab:layoutChanged, renders the grouped rail from the same per-row markup the flat rail uses, falls through to a full render whenever the grouping structure changes, and withholds drag-reorder in the grouped rail. - Grouping is opt-in by construction. With no layout, a failed read, a layout without groups, or a horizontal strip, the rail renders exactly as before (byte-identical markup). - Grouping is a render layer only: sessionOrder, Alt+N, Ctrl+Tab and the palette keep reading the server-projected order, and row badges keep their Alt+N slot. - A collapsed group still shows the active row; lineage arcs to a hidden session anchor to its group header. - webview-tabs.js: renderWebviewTab() extracted so a single web tab can be placed into its group with unchanged markup. --- CLAUDE.md | 4 +- docs/architecture-invariants.md | 9 +- scripts/build.mjs | 2 + src/web/public/app.js | 209 ++++++++++++++- src/web/public/i18n.js | 1 + src/web/public/index.html | 2 + src/web/public/session-lineage.js | 38 ++- src/web/public/styles.css | 87 +++++++ src/web/public/tab-layout-browser.js | 260 ++++++++++++++++++ src/web/public/webview-tabs.js | 30 ++- test/tab-layout-browser.test.ts | 296 +++++++++++++++++++++ test/tab-layout-rail.test.ts | 377 +++++++++++++++++++++++++++ 12 files changed, 1281 insertions(+), 34 deletions(-) create mode 100644 src/web/public/tab-layout-browser.js create mode 100644 test/tab-layout-browser.test.ts create mode 100644 test/tab-layout-rail.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 539f9b32..a77a3871 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -254,7 +254,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager) -**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. BACKEND ONLY: no frontend calls these routes yet. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts) +**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend only READS it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. No frontend WRITES the layout yet. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts) **Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation) @@ -317,7 +317,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) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. **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/docs/architecture-invariants.md b/docs/architecture-invariants.md index 848ae8f1..bfb4257d 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -296,7 +296,14 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t **Owner tab layouts** (COD-359, `tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat tab strip, scoped per owner (`SINGLE_USER_LAYOUT_OWNER` = `@single` when multi-user is off), persisted under the `tabLayouts` key in state.json. The pure model is `tab-layout.ts`, `tab-layout-service.ts` is the sole mutation boundary, plus `tab-layout-persistence.ts` and `tab-layout-legacy-order.ts`. A layout is `{version, groups[], ungrouped[], updatedAt}` whose refs point at either a session or a saved webview (`TabRefKind`), capped at 32 groups / 512 refs. -⚠️ **BACKEND ONLY as of 1.24.1**: nothing in `src/web/public/` calls these routes yet, so a UI built on top is new frontend work, not a rewiring job. +⚠️ **The frontend READS the layout; nothing writes it yet.** `tab-layout-browser.js` (pure, loaded before app.js) projects it onto what is live in the page, and app.js's grouped-rail block draws the VERTICAL rail as collapsible group sections. The rules that keep it safe: + +- **Grouped iff vertical AND the owner has at least one group.** No layout, a failed `GET` (retried, newest-wins via `createLoadCoordinator`) or zero groups renders the flat rail unchanged; the horizontal strip, phones and the sidebar never group. +- **A render layer, never an order source.** `sessionOrder` (the server-projected global order), Alt+N, Ctrl+Tab and the palette are untouched; a grouped session row is the flat row's markup, so its badge still names its Alt+N slot. Web tabs keep their slot after every session wherever their group puts them (`renderWebviewTab`). +- **Collapse is per-device** (`codeman:tab-groups-collapsed` in localStorage, ids of deleted groups garbage-collected on adoption, any storage failure means all-expanded). A collapsed group still SHOWS the active row, and `_updateActiveTabImmediate` falls through to a full render whenever the structure key changes, since a class toggle cannot reveal a hidden row. +- **Lineage arcs to a collapse-hidden session anchor to its group header** (`lineage-line--proxied`); two endpoints proxied to one header draw nothing. +- **Drag-reorder is off in the grouped rail** until grouped editing lands: a flat-order drop cannot express a group move, and the server re-ranks within the old group. + ⚠️ **`TabLayoutService` is the single mutation boundary** and every lifecycle caller (session created/removed, webview created/deleted, a legacy order PUT) describes ONE completed server action and gets AT MOST ONE versioned write; writing layout state from a route or a manager directly is what the service exists to prevent. diff --git a/scripts/build.mjs b/scripts/build.mjs index 2ab8ecd5..edebdedd 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -86,6 +86,7 @@ run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify -- 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'); +run('minify tab-layout-browser.js', 'npx esbuild dist/web/public/tab-layout-browser.js --minify --outfile=dist/web/public/tab-layout-browser.js --allow-overwrite'); run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite'); run('minify tab-rail-resize.js', 'npx esbuild dist/web/public/tab-rail-resize.js --minify --outfile=dist/web/public/tab-rail-resize.js --allow-overwrite'); run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite'); @@ -113,6 +114,7 @@ console.log('\n[build] content-hash cache busting'); 'input-cjk.js', 'terminal-keycode229-recovery.js', 'sanitize-html.js', + 'tab-layout-browser.js', 'app.js', 'tab-rail-resize.js', 'terminal-ui.js', diff --git a/src/web/public/app.js b/src/web/public/app.js index 56997f21..62aefb20 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -319,6 +319,8 @@ const _SSE_HANDLER_MAP = [ // Session order (global tab order sync, COD-131) [SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'], + // Owner tab layout (grouped vertical rail) + [SSE_EVENTS.TAB_LAYOUT_CHANGED, '_onTabLayoutChanged'], // Web tabs (dashboard URLs) [SSE_EVENTS.WEBVIEW_CHANGED, '_onWebviewChanged'], @@ -573,6 +575,12 @@ class CodemanApp { this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results this.sessionOrder = []; // Track tab order for drag-and-drop reordering this.draggedTabId = null; // Currently dragged tab session ID + // Owner tab layout (GET /api/tab-layout), read-only here: it only changes how + // the vertical rail GROUPS rows. sessionOrder above stays the tab order. + this.tabLayout = null; + this.collapsedTabGroupIds = new Set(); // per-device, localStorage-backed + this._hiddenTabGroupByRef = new Map(); // 'session:' -> collapsed group id + this._lastTabGroupStructureKey = null; this.cases = []; this.currentRun = null; this.totalTokens = 0; @@ -4266,6 +4274,10 @@ class CodemanApp { // Sync sessionOrder with current sessions (preserve order, add new, remove stale) this.syncSessionOrder(); + // (Re)read the owner tab layout on every init, including SSE reconnects: a + // tab:layoutChanged sent while this client was disconnected is never replayed. + this._loadTabLayout(); + if (data.respawnStatus) { this.respawnStatus = data.respawnStatus; } else { @@ -5078,6 +5090,12 @@ class CodemanApp { _updateActiveTabImmediate(sessionId) { const container = this.$('sessionTabs'); if (!container) return; + // Grouped rail: selecting a session hidden in a collapsed group must show it + // (and re-hide the previous exception), which a class toggle cannot do. + if (this._isTabGroupStructureStale()) { + this._fullRenderSessionTabs(); + return; + } const tabs = container.querySelectorAll('.session-tab[data-id]'); for (const tab of tabs) { if (tab.dataset.id === sessionId) { @@ -5237,7 +5255,12 @@ class CodemanApp { const container = this.$('sessionTabs'); const existingTabs = container.querySelectorAll('.session-tab[data-id]'); const existingIds = new Set([...existingTabs].map(t => t.dataset.id)); - const currentIds = new Set(this.sessions.keys()); + // Grouped rail: a collapsed group keeps its rows out of the DOM, so compare + // against the rows the projection SHOWS, not every live session. + const groupProjection = this._projectTabGroups(); + const currentIds = groupProjection + ? new Set(groupProjection.visibleRefs.filter((ref) => ref.kind === 'session').map((ref) => ref.id)) + : new Set(this.sessions.keys()); // Web tabs live in the same strip but are not in this.sessions, so they need // their own change check. Without it, the session-only comparison below is @@ -5246,14 +5269,20 @@ class CodemanApp { const existingWebIds = [...container.querySelectorAll('.session-tab[data-webview-id]')].map( t => t.dataset.webviewId ); - const wantedWebIds = (this.webviewOrder || []).filter(id => this.webviews?.has(id)); + const wantedWebIds = groupProjection + ? groupProjection.visibleRefs.filter((ref) => ref.kind === 'webview').map((ref) => ref.id) + : (this.webviewOrder || []).filter(id => this.webviews?.has(id)); const webTabsUnchanged = existingWebIds.length === wantedWebIds.length && existingWebIds.every((id, i) => id === wantedWebIds[i]); // Check if we can do incremental update (same session IDs and same web tabs) + // The grouped rail's structure (sections, collapse, the shown exception) can + // change while the id sets stay equal; the in-place patch below cannot move + // or hide a row, so any structural change takes the full rebuild. const canIncremental = existingIds.size === currentIds.size && [...existingIds].every(id => currentIds.has(id)) && - webTabsUnchanged; + webTabsUnchanged && + !this._isTabGroupStructureStale(groupProjection); if (canIncremental) { // Read once for the whole pass, like the full-rebuild path: this touches @@ -5591,6 +5620,10 @@ class CodemanApp { // layout, and the tabs then carry no inline order at all — the header // strip's markup is byte-identical to before. const railSortOrder = this._tabRailSortOrder(tabOrder.filter((id) => this.sessions.has(id))); + // One row per session, in tab order. The flat strip emits them as-is; the + // grouped rail places the SAME markup into its sections, so a row never + // differs between the two (badge = Alt+N slot in sessionOrder either way). + const rowHtml = new Map(); let _tabIdx = 0; for (const id of tabOrder) { const session = this.sessions.get(id); @@ -5654,7 +5687,7 @@ class CodemanApp { const inlineSessionActions = this.shouldInlineSessionActions(); const tabActionsHtml = `⚙⧉×`; - parts.push(`