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