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: '今日は天気' }); + }); +});