From 0ef57d41e323d5af4729e1846822236b78fb348f Mon Sep 17 00:00:00 2001
From: lior
Date: Wed, 29 Jul 2026 09:12:16 +0300
Subject: [PATCH 1/8] feat(zerolag): add serializable multiline input state
---
packages/xterm-zerolag-input/README.md | 100 +++--
packages/xterm-zerolag-input/src/index.ts | 15 +-
packages/xterm-zerolag-input/src/types.ts | 16 +
.../src/zerolag-input-addon.ts | 424 ++++++++++++++----
.../test/zerolag-input-addon.test.ts | 350 ++++++++++++++-
5 files changed, 753 insertions(+), 152 deletions(-)
diff --git a/packages/xterm-zerolag-input/README.md b/packages/xterm-zerolag-input/README.md
index ee511c17..0719e598 100644
--- a/packages/xterm-zerolag-input/README.md
+++ b/packages/xterm-zerolag-input/README.md
@@ -8,7 +8,7 @@
-
+
@@ -39,7 +39,7 @@ Server echoes 'h' ←───────────────────
## Origin
-This library was extracted from [Codeman](https://github.com/Ark0N/Codeman), mission control for AI coding agents — multi-session management, real-time agent visualization, autonomous respawn loops, and a mobile-first web UI for Claude Code, OpenCode, and Codex. The local echo system was built to make mobile and remote access feel instant, then battle-tested across thousands of hours of real usage. After 3 deep code audits, it was extracted into this standalone library with 78 tests covering every state transition.
+This library was extracted from [Codeman](https://github.com/Ark0N/Codeman), mission control for AI coding agents — multi-session management, real-time agent visualization, autonomous respawn loops, and a mobile-first web UI for Claude Code, OpenCode, and Codex. The local echo system was built to make mobile and remote access feel instant, then battle-tested across thousands of hours of real usage. After 3 deep code audits, it was extracted into this standalone library with 194 tests covering every state transition.
## Install
@@ -75,7 +75,7 @@ terminal.onData((data) => {
ws.send(text + '\r');
} else if (data === '\x7f') {
const source = zerolag.removeChar();
- if (source === 'flushed') ws.send(data); // only backspace text already in PTY
+ if (source !== 'pending') ws.send(data); // let the PTY handle text not owned locally
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
zerolag.addChar(data);
}
@@ -98,6 +98,7 @@ Most terminal UIs can't do local echo because:
3. **Font matching**: Canvas/WebGL renderers use their own text shaping. A DOM overlay must pixel-match the canvas grid — normal DOM text flow drifts due to sub-pixel glyph width differences.
This library solves all three by:
+
- Using a **DOM overlay** that Ink can't touch (separate z-index layer)
- **Scanning the buffer** bottom-up for the prompt character instead of trusting cursor position
- Rendering each character as an **absolutely-positioned ``** at exact cell-grid coordinates
@@ -160,67 +161,86 @@ Implements xterm.js `ITerminalAddon`. The addon does **not** hook `terminal.onDa
### Input
-| Method | Returns | Description |
-|--------|---------|-------------|
-| `addChar(char)` | `void` | Add a single printable character. Auto-detects existing buffer text on first keystroke. |
-| `appendText(text)` | `void` | Append multiple characters (paste). |
-| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove last char. See [backspace handling](#backspace-handling). |
-| `clear()` | `void` | Clear all state, hide overlay. Call on Enter/Ctrl+C/Escape. |
+| Method | Returns | Description |
+| ------------------------------ | ------------------------------------- | --------------------------------------------------------------------------------------------- |
+| `addChar(char)` | `void` | Add a single printable character. |
+| `appendText(text)` | `void` | Append multiple characters (paste). |
+| `setCompositionText(text)` | `void` | Replace the visible, uncommitted IME candidate. |
+| `commitComposition(text)` | `void` | Atomically replace the candidate with finalized pending text. |
+| `clearComposition()` | `void` | Remove an unfinished candidate without changing pending text. |
+| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove last char. See [backspace handling](#backspace-handling). |
+| `clear()` | `void` | Clear all state, hide overlay. Call on Enter/Ctrl+C/Escape. |
+| `restoreDraft(draft, render?)` | `void` | Atomically restore pending/flushed text. Pass `false` until the current terminal frame loads. |
+
+Composition text is rendered after `pendingText` but remains transient. Call `setCompositionText()` for each `compositionupdate`, then `commitComposition()` with xterm's finalized `onData` payload. This prevents candidate rewrites from duplicating partially composed words.
### Backspace Handling
`removeChar()` cascades through three layers and tells you what it removed:
-| Return | Source | Your action |
-|--------|--------|-------------|
-| `'pending'` | Unsent text (never transmitted to PTY) | Do nothing |
-| `'flushed'` | Text already sent to PTY | Send `\x7f` backspace to PTY |
-| `false` | Nothing to remove | Do nothing |
-
-The cascade: pending text first, then flushed text, then auto-detect buffer text (handles tab completion). This means backspace "just works" through any combination of typed, flushed, and tab-completed text.
+| Return | Source | Your action |
+| ----------- | ------------------------ | ----------------------------------------------- |
+| `'pending'` | Composing or unsent text | Do nothing |
+| `'flushed'` | Text already sent to PTY | Send `\x7f` backspace to PTY |
+| `false` | Nothing tracked locally | Decide whether the PTY still owns editable text |
+
+The cascade is transient composition first, then pending text, then explicitly
+tracked flushed text. A `false` result only means the addon did not remove local
+text; applications with another input source can still forward Backspace so the
+PTY can delete text entered elsewhere. The addon never infers editable input
+during ordinary typing or Backspace because full-screen terminal UIs can render
+status text after a prompt glyph. For Tab completion, call `detectBufferText()`
+after the completion response is rendered; for session restoration, call
+`restoreDraft()`.
### Flushed Text
"Flushed" = sent to PTY but echo hasn't arrived yet. Happens during tab switches and tab completion.
-| Method | Description |
-|--------|-------------|
+| Method | Description |
+| ---------------------------------- | -------------------------------------------------------------------------------------------- |
| `setFlushed(count, text, render?)` | Mark text as flushed. Pass `render=false` during tab-switch restore (buffer not loaded yet). |
-| `getFlushed()` | Returns `{ count, text }`. |
-| `clearFlushed()` | Clear flushed state when server echo arrives. |
+| `getFlushed()` | Returns `{ count, text }`. |
+| `clearFlushed()` | Clear flushed state when server echo arrives. |
+
+`restoreDraft({ pendingText, flushedText }, false)` is the preferred reload
+and session-switch rehydration API. It clears transient composition and cached
+prompt coordinates without rendering against the outgoing terminal frame.
### Buffer Detection
Scan the terminal for text that exists after the prompt but wasn't typed through the overlay.
-| Method | Description |
-|--------|-------------|
-| `detectBufferText()` | Scan and return detected text (or `null`). Sets it as flushed. Guarded: runs once per `clear()` cycle. |
-| `resetBufferDetection()` | Re-enable detection. |
-| `suppressBufferDetection()` | Block detection until next `clear()`. Use for sessions with UI framework text after the prompt. |
-| `undoDetection()` | Undo last detection — clears flushed state, re-enables detection. For tab completion retry. |
+| Method | Description |
+| --------------------------- | ----------------------------------------------------------------------------------------------------------------- |
+| `detectBufferText()` | Explicitly scan and return detected text (or `null`). Sets it as flushed. Guarded: runs once per `clear()` cycle. |
+| `resetBufferDetection()` | Re-enable detection. |
+| `suppressBufferDetection()` | Block detection until next `clear()`. Use for sessions with UI framework text after the prompt. |
+| `undoDetection()` | Undo last detection — clears flushed state, re-enables detection. For tab completion retry. |
### Rendering
-| Method | Description |
-|--------|-------------|
-| `rerender()` | Force re-render. Call after buffer reloads, screen redraws, resizes, reconnects. |
-| `refreshFont()` | Re-cache font properties from terminal. Call after font size or theme changes. |
+| Method | Description |
+| --------------------------- | ----------------------------------------------------------------------------------------------- |
+| `rerender()` | Force re-render. Call after buffer reloads, screen redraws, resizes, reconnects. |
+| `refreshFont()` | Re-cache font properties from terminal. Call after font size or theme changes. |
+| `setViewportPinned(pinned)` | Keep a pending draft visible at the bottom while xterm scrollback is away from the live prompt. |
### Prompt Utilities
-| Method | Description |
-|--------|-------------|
-| `findPrompt()` | Find prompt position. Returns `{ row, col }` or `null`. |
+| Method | Description |
+| ------------------ | -------------------------------------------------------- |
+| `findPrompt()` | Find prompt position. Returns `{ row, col }` or `null`. |
| `readPromptText()` | Read text after prompt marker. Returns string or `null`. |
### State
-| Property | Type | Description |
-|----------|------|-------------|
-| `pendingText` | `string` | Unacknowledged text (read-only) |
-| `hasPending` | `boolean` | `true` if overlay has any content |
-| `state` | `ZerolagInputState` | Full snapshot: pendingText, flushedLength, flushedText, visible, promptPosition |
+| Property | Type | Description |
+| ----------------- | ------------------- | ------------------------------------------------------------------------------------------------ |
+| `pendingText` | `string` | Unacknowledged text (read-only) |
+| `compositionText` | `string` | Current uncommitted IME candidate (read-only) |
+| `hasPending` | `boolean` | `true` if overlay has any content |
+| `state` | `ZerolagInputState` | Full snapshot: pendingText, compositionText, flushedLength, flushedText, visible, promptPosition |
### Options
@@ -311,7 +331,9 @@ zerolag.rerender();
terminal.options.fontSize = 18;
zerolag.refreshFont();
-function onReconnect() { zerolag.rerender(); }
+function onReconnect() {
+ zerolag.rerender();
+}
```
---
diff --git a/packages/xterm-zerolag-input/src/index.ts b/packages/xterm-zerolag-input/src/index.ts
index c16416d9..98428e25 100644
--- a/packages/xterm-zerolag-input/src/index.ts
+++ b/packages/xterm-zerolag-input/src/index.ts
@@ -1,10 +1,11 @@
export { ZerolagInputAddon } from './zerolag-input-addon.js';
export type {
- XtermTerminal,
- XtermAddon,
- ZerolagInputOptions,
- ZerolagInputState,
- PromptFinder,
- PromptPosition,
- CellDimensions,
+ XtermTerminal,
+ XtermAddon,
+ ZerolagInputOptions,
+ ZerolagInputState,
+ ZerolagInputDraftState,
+ PromptFinder,
+ PromptPosition,
+ CellDimensions,
} from './types.js';
diff --git a/packages/xterm-zerolag-input/src/types.ts b/packages/xterm-zerolag-input/src/types.ts
index 08dd0050..2e25e02a 100644
--- a/packages/xterm-zerolag-input/src/types.ts
+++ b/packages/xterm-zerolag-input/src/types.ts
@@ -130,6 +130,8 @@ export interface ZerolagInputOptions {
export interface ZerolagInputState {
/** Characters typed but not yet acknowledged by the server */
pendingText: string;
+ /** Current uncommitted IME candidate text */
+ compositionText?: string;
/** Number of characters flushed to PTY but echo not yet received */
flushedLength: number;
/** Text content of the flushed portion */
@@ -140,6 +142,20 @@ export interface ZerolagInputState {
promptPosition: PromptPosition | null;
}
+/**
+ * Serializable editable input state.
+ *
+ * Transient IME composition is deliberately excluded: a browser cannot resume
+ * an operating-system composition session after reload. Consumers should fold
+ * any visible composition candidate into `pendingText` before persisting it.
+ */
+export interface ZerolagInputDraftState {
+ /** Text that has not been sent to the terminal process. */
+ pendingText: string;
+ /** Text sent to the process that is still tracked as editable input. */
+ flushedText: string;
+}
+
/** Cell dimensions in CSS pixels. */
export interface CellDimensions {
width: number;
diff --git a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts
index 5e201ba4..1ff5ed1c 100644
--- a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts
+++ b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts
@@ -1,6 +1,7 @@
import type {
XtermTerminal,
XtermAddon,
+ ZerolagInputDraftState,
ZerolagInputOptions,
ZerolagInputState,
PromptPosition,
@@ -17,6 +18,33 @@ const DEFAULT_SCROLL_DEBOUNCE_MS = 50;
const DEFAULT_BG = '#0d0d0d';
const DEFAULT_FG = '#eeeeee';
const DEFAULT_CURSOR = '#e0e0e0';
+const GRAPHEME_SEGMENTER =
+ typeof Intl.Segmenter === 'function' ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null;
+
+interface InputLayoutLine {
+ text: string;
+ startOffset: number;
+ endOffset: number;
+}
+
+function removeLastInputUnit(text: string): string {
+ if (!text) return '';
+ if (text.endsWith('\r\n')) return text.slice(0, -2);
+
+ if (GRAPHEME_SEGMENTER) {
+ const lastSegment = GRAPHEME_SEGMENTER.segment(text).containing(text.length - 1);
+ return text.slice(0, lastSegment?.index ?? 0);
+ }
+
+ const last = text.charCodeAt(text.length - 1);
+ const removesSurrogatePair =
+ last >= 0xdc00 &&
+ last <= 0xdfff &&
+ text.length > 1 &&
+ text.charCodeAt(text.length - 2) >= 0xd800 &&
+ text.charCodeAt(text.length - 2) <= 0xdbff;
+ return text.slice(0, removesSurrogatePair ? -2 : -1);
+}
/**
* xterm.js addon that provides instant keystroke feedback via a DOM overlay.
@@ -49,7 +77,7 @@ const DEFAULT_CURSOR = '#e0e0e0';
* ws.send(text + '\r');
* } else if (data === '\x7f') {
* const source = zerolag.removeChar();
- * if (source === 'flushed') ws.send(data); // only send if text was already in PTY
+ * if (source !== 'pending') ws.send(data); // let the PTY handle text not owned locally
* } else if (data.length === 1 && data.charCodeAt(0) >= 32) {
* zerolag.addChar(data);
* }
@@ -64,6 +92,7 @@ export class ZerolagInputAddon implements XtermAddon {
// Text state
private _pendingText = '';
+ private _compositionText = '';
private _flushedOffset = 0;
private _flushedText = '';
private _bufferDetectDone = false;
@@ -71,6 +100,13 @@ export class ZerolagInputAddon implements XtermAddon {
// Render cache
private _lastRenderKey = '';
private _lastPromptPos: PromptPosition | null = null;
+ private _layoutLines: InputLayoutLine[] = [];
+ private _layoutDirtyOffset: number | null = 0;
+ private _layoutTextLength = 0;
+ private _layoutStartCol = -1;
+ private _layoutCols = -1;
+ private _layoutRevision = 0;
+ private _fallbackFlushedText: string | null = null;
// Font cache
private _font: FontStyle = {
@@ -86,6 +122,7 @@ export class ZerolagInputAddon implements XtermAddon {
private _scrollTimer: ReturnType | null = null;
private _scrollHandler: (() => void) | null = null;
private _scrollViewport: Element | null = null;
+ private _viewportPinned = false;
constructor(options?: ZerolagInputOptions) {
this._options = {
@@ -124,13 +161,13 @@ export class ZerolagInputAddon implements XtermAddon {
this._scrollHandler = () => {
try {
const buf = this._terminal!.buffer.active;
- if (buf.viewportY !== buf.baseY) {
+ if (buf.viewportY !== buf.baseY && !this._viewportPinned) {
this._overlay!.style.display = 'none';
if (this._scrollTimer) {
clearTimeout(this._scrollTimer);
this._scrollTimer = null;
}
- } else if (this._pendingText || this._flushedOffset > 0) {
+ } else if (this._pendingText || this._compositionText || this._flushedOffset > 0) {
if (this._scrollTimer) clearTimeout(this._scrollTimer);
this._scrollTimer = setTimeout(() => {
this._scrollTimer = null;
@@ -176,7 +213,7 @@ export class ZerolagInputAddon implements XtermAddon {
* Call this when the user types a character (charCode >= 32, length === 1).
*/
addChar(char: string): void {
- if (!this._pendingText && !this._flushedOffset) this._detectBufferText();
+ this._markLayoutDirty(this._pendingDisplayOffset() + this._pendingText.length);
this._pendingText += char;
this._render();
}
@@ -186,31 +223,67 @@ export class ZerolagInputAddon implements XtermAddon {
*/
appendText(text: string): void {
if (!text) return;
- if (!this._pendingText && !this._flushedOffset) this._detectBufferText();
+ this._markLayoutDirty(this._pendingDisplayOffset() + this._pendingText.length);
this._pendingText += text;
this._render();
}
+ /**
+ * Replace the transient IME candidate shown after committed pending text.
+ * Candidate updates are replacements, not appends: mobile keyboards rewrite
+ * the whole composing word as suggestions change.
+ */
+ setCompositionText(text: string): void {
+ this._markLayoutDirty(this._pendingDisplayOffset() + this._pendingText.length);
+ this._compositionText = text;
+ if (text || this._pendingText || this._flushedOffset > 0) {
+ this._render();
+ } else {
+ this._hide();
+ }
+ }
+
+ /**
+ * Atomically replace the transient IME candidate with finalized input.
+ */
+ commitComposition(text: string): void {
+ this._markLayoutDirty(this._pendingDisplayOffset() + this._pendingText.length);
+ this._compositionText = '';
+ if (text) this._pendingText += text;
+ if (this._pendingText || this._flushedOffset > 0) {
+ this._render();
+ } else {
+ this._hide();
+ }
+ }
+
+ /** Remove an unfinished IME candidate without changing committed input. */
+ clearComposition(): void {
+ this.commitComposition('');
+ }
+
/**
* Remove the last character from the overlay.
*
* Cascade order:
- * 1. Remove from `pendingText` if non-empty → returns `'pending'`
- * 2. Decrement `flushedOffset` if pending is empty but flushed exists → returns `'flushed'`
- * 3. Try `detectBufferText()` if both are empty, then decrement → returns `'flushed'`
+ * 1. Remove from transient composition if non-empty → returns `'pending'`
+ * 2. Remove from `pendingText` if non-empty → returns `'pending'`
+ * 3. Decrement `flushedOffset` if pending is empty but flushed exists → returns `'flushed'`
*
* @returns The source of the removed character, or `false` if nothing to remove.
*
- * - `'pending'`: A character was removed from unsent text. The consumer
- * should NOT send backspace to the PTY (the text was never transmitted).
+ * - `'pending'`: A user-visible input unit was removed from unsent or
+ * composing text. The consumer should NOT send backspace to the PTY.
* - `'flushed'`: A character was removed from text already sent to the PTY.
* The consumer SHOULD send backspace to the PTY.
- * - `false`: Nothing to remove. The consumer should NOT send backspace.
+ * - `false`: No locally tracked text was removed. The consumer decides
+ * whether the PTY may still own editable text and should receive backspace.
*/
removeChar(): 'pending' | 'flushed' | false {
- if (this._pendingText.length > 0) {
- this._pendingText = this._pendingText.slice(0, -1);
- if (this._pendingText.length > 0 || this._flushedOffset > 0) {
+ if (this._compositionText.length > 0) {
+ this._compositionText = removeLastInputUnit(this._compositionText);
+ this._markLayoutDirty(this._pendingDisplayOffset() + this._pendingText.length + this._compositionText.length);
+ if (this._compositionText || this._pendingText || this._flushedOffset > 0) {
this._render();
} else {
this._hide();
@@ -218,23 +291,23 @@ export class ZerolagInputAddon implements XtermAddon {
return 'pending';
}
- if (this._flushedOffset > 0) {
- this._flushedOffset--;
- this._flushedText = this._flushedText.slice(0, -1);
- if (this._flushedOffset > 0) {
+ if (this._pendingText.length > 0) {
+ this._pendingText = removeLastInputUnit(this._pendingText);
+ this._markLayoutDirty(this._pendingDisplayOffset() + this._pendingText.length);
+ if (this._pendingText.length > 0 || this._flushedOffset > 0) {
this._render();
} else {
this._hide();
}
- return 'flushed';
+ return 'pending';
}
- // Both empty — try detecting text already on the prompt line
- // (handles tab completion, arrow-key edits, etc.)
- this._detectBufferText();
if (this._flushedOffset > 0) {
- this._flushedOffset--;
- this._flushedText = this._flushedText.slice(0, -1);
+ const nextText = removeLastInputUnit(this._flushedText);
+ const removedLength = this._flushedText.length - nextText.length;
+ this._flushedOffset = Math.max(0, this._flushedOffset - Math.max(1, removedLength));
+ this._flushedText = nextText;
+ this._markLayoutDirty(0);
if (this._flushedOffset > 0) {
this._render();
} else {
@@ -252,14 +325,44 @@ export class ZerolagInputAddon implements XtermAddon {
*/
clear(): void {
this._pendingText = '';
+ this._compositionText = '';
this._flushedOffset = 0;
this._flushedText = '';
this._bufferDetectDone = false;
this._lastRenderKey = '';
this._lastPromptPos = null;
+ this._resetLayout();
this._hide();
}
+ /**
+ * Restore serialized editable input atomically.
+ *
+ * Pass `render=false` while a different session's terminal frame is still
+ * visible. A later `rerender()` will locate the prompt in the current frame,
+ * avoiding a stale prompt-position cache during asynchronous tab switches.
+ */
+ restoreDraft(draft: ZerolagInputDraftState, render = true): void {
+ this._pendingText = typeof draft?.pendingText === 'string' ? draft.pendingText : '';
+ this._compositionText = '';
+ this._flushedText = typeof draft?.flushedText === 'string' ? draft.flushedText : '';
+ this._flushedOffset = this._flushedText.length;
+ this._bufferDetectDone = false;
+ this._lastRenderKey = '';
+ this._lastPromptPos = null;
+ this._resetLayout();
+
+ if (!render) {
+ this._hide();
+ return;
+ }
+ if (this.hasPending) {
+ this._render();
+ } else {
+ this._hide();
+ }
+ }
+
// ─── Flushed text tracking ────────────────────────────────────────
/**
@@ -280,6 +383,7 @@ export class ZerolagInputAddon implements XtermAddon {
setFlushed(count: number, text: string, render = true): void {
this._flushedOffset = count;
this._flushedText = text;
+ this._markLayoutDirty(0);
if (render) this._render();
}
@@ -297,7 +401,8 @@ export class ZerolagInputAddon implements XtermAddon {
clearFlushed(): void {
this._flushedOffset = 0;
this._flushedText = '';
- if (this._pendingText) {
+ this._markLayoutDirty(0);
+ if (this._pendingText || this._compositionText) {
this._render();
} else {
this._hide();
@@ -312,12 +417,35 @@ export class ZerolagInputAddon implements XtermAddon {
* that move the prompt.
*/
rerender(): void {
- if (this._pendingText || this._flushedOffset > 0) {
+ if (this._pendingText || this._compositionText || this._flushedOffset > 0) {
this._lastRenderKey = '';
this._render();
}
}
+ /**
+ * Keep the pending draft pinned to the visible terminal rows while xterm
+ * scrollback is away from the bottom. Consumers should enable this only
+ * while a separate input surface (for example a phone keyboard) stays active.
+ */
+ setViewportPinned(pinned: boolean): void {
+ const next = pinned === true;
+ if (this._viewportPinned === next) return;
+ this._viewportPinned = next;
+ this._lastRenderKey = '';
+
+ if (next && !this._lastPromptPos) {
+ this._lastPromptPos = this.findPrompt();
+ }
+ if (!this._terminal || !this._overlay || !this.hasPending) return;
+ const buf = this._terminal.buffer.active;
+ if (!next && buf.viewportY !== buf.baseY) {
+ this._overlay.style.display = 'none';
+ return;
+ }
+ this._render();
+ }
+
/**
* Re-read font properties from the terminal and re-render.
* Call after font size changes, theme changes, etc.
@@ -325,7 +453,7 @@ export class ZerolagInputAddon implements XtermAddon {
refreshFont(): void {
this._cacheFont();
this._lastRenderKey = '';
- if (this._pendingText || this._flushedOffset > 0) this._render();
+ if (this._pendingText || this._compositionText || this._flushedOffset > 0) this._render();
}
// ─── Buffer detection ─────────────────────────────────────────────
@@ -364,6 +492,7 @@ export class ZerolagInputAddon implements XtermAddon {
this._flushedOffset = 0;
this._flushedText = '';
this._bufferDetectDone = false;
+ this._markLayoutDirty(0);
}
/**
@@ -391,7 +520,7 @@ export class ZerolagInputAddon implements XtermAddon {
this._options.prompt = finder;
this._lastPromptPos = null;
this._lastRenderKey = '';
- if (this._pendingText || this._flushedOffset > 0) this._render();
+ if (this._pendingText || this._compositionText || this._flushedOffset > 0) this._render();
}
// ─── Prompt utilities ─────────────────────────────────────────────
@@ -425,15 +554,21 @@ export class ZerolagInputAddon implements XtermAddon {
return this._pendingText;
}
- /** Whether there is any overlay content (pending or flushed). */
+ /** Current uncommitted IME candidate text. */
+ get compositionText(): string {
+ return this._compositionText;
+ }
+
+ /** Whether there is any overlay content (pending, composing, or flushed). */
get hasPending(): boolean {
- return this._pendingText.length > 0 || this._flushedOffset > 0;
+ return this._pendingText.length > 0 || this._compositionText.length > 0 || this._flushedOffset > 0;
}
/** Read-only state snapshot. */
get state(): ZerolagInputState {
return {
pendingText: this._pendingText,
+ compositionText: this._compositionText,
flushedLength: this._flushedOffset,
flushedText: this._flushedText,
visible: this._overlay !== null && this._overlay.style.display !== 'none',
@@ -462,6 +597,7 @@ export class ZerolagInputAddon implements XtermAddon {
if (afterPrompt.length > 0) {
this._flushedOffset = afterPrompt.length;
this._flushedText = afterPrompt;
+ this._markLayoutDirty(0);
this._lastPromptPos = prompt;
this._bufferDetectDone = true;
return afterPrompt;
@@ -498,38 +634,170 @@ export class ZerolagInputAddon implements XtermAddon {
private _hide(): void {
if (!this._overlay) return;
this._lastRenderKey = '';
- this._lastPromptPos = null;
+ if (!this._viewportPinned) this._lastPromptPos = null;
this._overlay.innerHTML = '';
this._overlay.style.display = 'none';
}
+ private _pendingDisplayOffset(): number {
+ return this._flushedText.length === this._flushedOffset ? this._flushedText.length : 0;
+ }
+
+ private _markLayoutDirty(offset: number): void {
+ const safeOffset = Math.max(0, offset);
+ this._layoutDirtyOffset =
+ this._layoutDirtyOffset === null ? safeOffset : Math.min(this._layoutDirtyOffset, safeOffset);
+ this._lastRenderKey = '';
+ }
+
+ private _resetLayout(): void {
+ this._layoutLines = [];
+ this._layoutDirtyOffset = 0;
+ this._layoutTextLength = 0;
+ this._layoutStartCol = -1;
+ this._layoutCols = -1;
+ this._fallbackFlushedText = null;
+ this._layoutRevision += 1;
+ }
+
+ private _layoutVisibleLines(
+ displayText: string,
+ startCol: number,
+ totalCols: number,
+ terminalRows: number
+ ): { visibleLines: string[]; hiddenLineCount: number } {
+ const firstLineCols = Math.max(1, totalCols - startCol);
+ const geometryChanged = this._layoutStartCol !== startCol || this._layoutCols !== totalCols;
+ let dirtyOffset = geometryChanged ? 0 : this._layoutDirtyOffset;
+ if (dirtyOffset === null && this._layoutTextLength !== displayText.length) dirtyOffset = 0;
+ if (dirtyOffset === null && this._layoutLines.length > 0) {
+ const hiddenLineCount = Math.max(0, this._layoutLines.length - terminalRows);
+ return {
+ visibleLines: this._layoutLines.slice(hiddenLineCount).map((line) => line.text),
+ hiddenLineCount,
+ };
+ }
+
+ let reflowLineIndex = 0;
+ if (!geometryChanged && this._layoutLines.length > 0 && (dirtyOffset ?? 0) > 0) {
+ let low = 0;
+ let high = this._layoutLines.length - 1;
+ while (low <= high) {
+ const middle = Math.floor((low + high) / 2);
+ if (this._layoutLines[middle].startOffset <= (dirtyOffset ?? 0)) {
+ reflowLineIndex = middle;
+ low = middle + 1;
+ } else {
+ high = middle - 1;
+ }
+ }
+ if (reflowLineIndex > 0 && this._layoutLines[reflowLineIndex].startOffset === (dirtyOffset ?? 0)) {
+ reflowLineIndex -= 1;
+ }
+ }
+
+ let startOffset = this._layoutLines[reflowLineIndex]?.startOffset ?? 0;
+ if (startOffset > displayText.length) {
+ reflowLineIndex = 0;
+ startOffset = 0;
+ }
+ this._layoutLines.length = reflowLineIndex;
+ const lines = this._layoutLines;
+ let lineStartOffset = startOffset;
+ let lineText = '';
+ let lineCols = 0;
+ let lineCapacity = reflowLineIndex === 0 ? firstLineCols : totalCols;
+ let index = startOffset;
+
+ while (index < displayText.length) {
+ const codePoint = displayText.codePointAt(index);
+ if (codePoint === undefined) break;
+ const char = String.fromCodePoint(codePoint);
+ const charLength = char.length;
+
+ if (char === '\r' || char === '\n') {
+ index += charLength;
+ if (char === '\r' && displayText[index] === '\n') index += 1;
+ lines.push({ text: lineText, startOffset: lineStartOffset, endOffset: index });
+ lineStartOffset = index;
+ lineText = '';
+ lineCols = 0;
+ lineCapacity = totalCols;
+ continue;
+ }
+
+ const charCols = charCellWidth(this._terminal, char);
+ if (lineCols + charCols > lineCapacity && (lineText.length > 0 || lineCapacity !== totalCols)) {
+ lines.push({ text: lineText, startOffset: lineStartOffset, endOffset: index });
+ lineStartOffset = index;
+ lineText = '';
+ lineCols = 0;
+ lineCapacity = totalCols;
+ continue;
+ }
+
+ lineText += char;
+ lineCols += charCols;
+ index += charLength;
+ }
+ lines.push({ text: lineText, startOffset: lineStartOffset, endOffset: displayText.length });
+
+ this._layoutLines = lines;
+ this._layoutDirtyOffset = null;
+ this._layoutTextLength = displayText.length;
+ this._layoutStartCol = startCol;
+ this._layoutCols = totalCols;
+ this._layoutRevision += 1;
+
+ const hiddenLineCount = Math.max(0, lines.length - terminalRows);
+ return {
+ visibleLines: lines.slice(hiddenLineCount).map((line) => line.text),
+ hiddenLineCount,
+ };
+ }
+
private _render(): void {
if (!this._terminal || !this._overlay) return;
- if (!this._pendingText && !(this._flushedOffset > 0)) {
+ if (!this._pendingText && !this._compositionText && !(this._flushedOffset > 0)) {
this._overlay.style.display = 'none';
return;
}
try {
const buf = this._terminal.buffer.active;
+ const terminalRows = Math.max(1, this._terminal.rows);
+ const pinnedToScrolledViewport = this._viewportPinned && buf.viewportY !== buf.baseY;
// Hide overlay when scrolled up — prompt is at bottom, not in viewport
- if (buf.viewportY !== buf.baseY) {
+ if (buf.viewportY !== buf.baseY && !pinnedToScrolledViewport) {
this._overlay.style.display = 'none';
return;
}
- // Re-scan for prompt on every render (full-screen redraws can move it)
- const prompt = this.findPrompt();
- if (prompt) {
- // When flushed text exists, lock column to prevent jitter from
- // redraws that temporarily shift the prompt marker. Allow row changes.
- if (this._lastPromptPos && this._flushedOffset > 0) {
- this._lastPromptPos = { row: prompt.row, col: this._lastPromptPos.col };
- } else {
- this._lastPromptPos = prompt;
+ if (pinnedToScrolledViewport) {
+ if (this._lastPromptPos) {
+ this._lastPromptPos = {
+ row: terminalRows - 1,
+ col: this._lastPromptPos.col,
+ };
}
- } else if (!this._lastPromptPos) {
+ } else {
+ // Re-scan for prompt on every render (full-screen redraws can move it)
+ const prompt = this.findPrompt();
+ if (prompt) {
+ // When flushed text exists, lock column to prevent jitter from
+ // redraws that temporarily shift the prompt marker. Allow row changes.
+ if (this._lastPromptPos && this._flushedOffset > 0) {
+ this._lastPromptPos = { row: prompt.row, col: this._lastPromptPos.col };
+ } else {
+ this._lastPromptPos = prompt;
+ }
+ } else if (!this._lastPromptPos) {
+ this._overlay.style.display = 'none';
+ return;
+ }
+ }
+ if (!this._lastPromptPos) {
this._overlay.style.display = 'none';
return;
}
@@ -547,10 +815,14 @@ export class ZerolagInputAddon implements XtermAddon {
const startCol = activePrompt.col + offset;
// Build display text: flushed chars + pending chars
- let displayText = this._pendingText;
+ let displayText = this._pendingText + this._compositionText;
if (this._flushedOffset > 0) {
if (this._flushedText && this._flushedText.length === this._flushedOffset) {
- displayText = this._flushedText + this._pendingText;
+ if (this._fallbackFlushedText !== null) {
+ this._fallbackFlushedText = null;
+ this._markLayoutDirty(0);
+ }
+ displayText = this._flushedText + this._pendingText + this._compositionText;
} else {
// Fallback: read flushed chars from terminal buffer
const absRow = buf.viewportY + activePrompt.row;
@@ -558,60 +830,44 @@ export class ZerolagInputAddon implements XtermAddon {
if (line) {
const lineText = line.translateToString(true);
const flushedChars = lineText.slice(startCol, startCol + this._flushedOffset);
- displayText = flushedChars + this._pendingText;
+ if (this._fallbackFlushedText !== flushedChars) {
+ this._fallbackFlushedText = flushedChars;
+ this._markLayoutDirty(0);
+ }
+ displayText = flushedChars + this._pendingText + this._compositionText;
}
}
}
- // Skip redundant re-renders — include text content to detect
- // same-length changes (e.g., setFlushed with different text)
- const renderKey = `${displayText}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._flushedOffset}`;
+ // Keep the newest draft row visible at the bottom of the terminal. As
+ // the draft grows it expands upward; deleting rows lets it shrink back
+ // toward the prompt. Drafts taller than the viewport retain their tail.
+ const { visibleLines, hiddenLineCount } = this._layoutVisibleLines(
+ displayText,
+ startCol,
+ totalCols,
+ terminalRows
+ );
+ const visibleStartCol = hiddenLineCount > 0 ? 0 : startCol;
+ const renderRow = Math.max(0, Math.min(activePrompt.row, terminalRows - visibleLines.length));
+
+ // Skip redundant re-renders — include the resolved geometry so a
+ // keyboard resize still repositions identical text.
+ const renderKey = `${this._layoutRevision}:${visibleStartCol}:${renderRow}:${activePrompt.col}:${totalCols}:${terminalRows}:${cellW}:${cellH}:${charTop}:${charHeight}:${this._flushedOffset}`;
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
this._lastRenderKey = renderKey;
- // Split into visual lines by column width (CJK wide chars = 2 cols)
- const firstLineCols = Math.max(1, totalCols - startCol);
- const chars = [...displayText]; // proper Unicode iteration
- const lines: string[] = [];
- let ci = 0;
- // First line: remaining columns after prompt
- {
- let lineStr = '';
- let lineCols = 0;
- while (ci < chars.length) {
- const cw = charCellWidth(this._terminal, chars[ci]);
- if (lineCols + cw > firstLineCols) break;
- lineStr += chars[ci];
- lineCols += cw;
- ci++;
- }
- lines.push(lineStr);
- }
- // Subsequent lines: full terminal width
- while (ci < chars.length) {
- let lineStr = '';
- let lineCols = 0;
- while (ci < chars.length) {
- const cw = charCellWidth(this._terminal, chars[ci]);
- if (lineCols + cw > totalCols) break;
- lineStr += chars[ci];
- lineCols += cw;
- ci++;
- }
- lines.push(lineStr);
- }
-
const cursorColor = this._options.cursorColor ?? this._terminal.options.theme?.cursor ?? DEFAULT_CURSOR;
renderOverlay(this._overlay, {
- lines,
- startCol,
+ lines: visibleLines,
+ startCol: visibleStartCol,
totalCols,
cellW,
cellH,
charTop,
charHeight,
- promptRow: activePrompt.row,
+ promptRow: renderRow,
font: this._font,
showCursor: this._options.showCursor,
cursorColor,
diff --git a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts
index 74063761..da09886c 100644
--- a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts
+++ b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts
@@ -27,6 +27,19 @@ function tracked(lines?: string[], promptChar?: string) {
return result;
}
+function trackedTerminal(options: Parameters[0], promptChar = '$') {
+ const mock = createMockTerminal(options);
+ const addon = new ZerolagInputAddon({
+ prompt: { type: 'character', char: promptChar, offset: 2 },
+ });
+ mock.terminal.loadAddon(addon);
+ cleanups.push(() => {
+ addon.dispose();
+ mock.cleanup();
+ });
+ return { addon, mock };
+}
+
describe('ZerolagInputAddon', () => {
describe('lifecycle', () => {
it('creates overlay element in .xterm-screen', () => {
@@ -79,6 +92,179 @@ describe('ZerolagInputAddon', () => {
expect(addon.pendingText).toBe('');
expect(addon.hasPending).toBe(false);
});
+
+ it('renders explicit line breaks as separate terminal rows', () => {
+ const { addon, mock } = tracked();
+
+ addon.appendText('first\nsecond');
+
+ const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
+ expect(addon.pendingText).toBe('first\nsecond');
+ expect(overlay.children).toHaveLength(3); // two rows and the cursor
+ expect(overlay.children[0].textContent).toBe('first');
+ expect(overlay.children[1].textContent).toBe('second');
+ });
+
+ it('pins a growing multiline draft to the bottom and shrinks it after delete', () => {
+ const lines = Array.from({ length: 24 }, () => '');
+ lines[22] = '$ ';
+ const mock = createMockTerminal({
+ buffer: { lines },
+ cols: 20,
+ rows: 24,
+ cellHeight: 10,
+ });
+ const addon = new ZerolagInputAddon({
+ prompt: { type: 'character', char: '$', offset: 2 },
+ });
+ mock.terminal.loadAddon(addon);
+ cleanups.push(() => {
+ addon.dispose();
+ mock.cleanup();
+ });
+
+ addon.appendText('one\ntwo\nthree');
+
+ const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
+ expect(overlay.style.top).toBe('210px');
+ expect(
+ Array.from(overlay.children)
+ .slice(0, 3)
+ .map((element) => element.textContent)
+ ).toEqual(['one', 'two', 'three']);
+
+ for (let i = 0; i < 6; i++) addon.removeChar();
+
+ expect(addon.pendingText).toBe('one\ntwo');
+ expect(overlay.style.top).toBe('220px');
+ expect(
+ Array.from(overlay.children)
+ .slice(0, 2)
+ .map((element) => element.textContent)
+ ).toEqual(['one', 'two']);
+ });
+
+ it('keeps only the visible tail when a draft exceeds the terminal height', () => {
+ const lines = ['', '', '', '$ '];
+ const mock = createMockTerminal({
+ buffer: { lines },
+ cols: 20,
+ rows: 4,
+ cellHeight: 10,
+ });
+ const addon = new ZerolagInputAddon({
+ prompt: { type: 'character', char: '$', offset: 2 },
+ });
+ mock.terminal.loadAddon(addon);
+ cleanups.push(() => {
+ addon.dispose();
+ mock.cleanup();
+ });
+
+ addon.appendText('a\nb\nc\nd\ne\nf');
+
+ const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
+ expect(overlay.style.top).toBe('0px');
+ expect(
+ Array.from(overlay.children)
+ .slice(0, 4)
+ .map((element) => element.textContent)
+ ).toEqual(['c', 'd', 'e', 'f']);
+ });
+
+ it('reflows only the changed tail of a long draft', () => {
+ const { addon } = trackedTerminal({
+ buffer: { lines: ['$ '] },
+ cols: 12,
+ rows: 4,
+ });
+ addon.appendText('x'.repeat(1_000));
+ const before = (
+ addon as unknown as {
+ _layoutLines: Array<{ text: string }>;
+ }
+ )._layoutLines;
+ const beforeLength = before.length;
+ const preserved = before.slice(0, -1);
+
+ addon.addChar('y');
+
+ const after = (
+ addon as unknown as {
+ _layoutLines: Array<{ text: string }>;
+ }
+ )._layoutLines;
+ expect(after.length).toBeGreaterThanOrEqual(beforeLength);
+ for (let index = 0; index < preserved.length; index += 1) {
+ expect(after[index]).toBe(preserved[index]);
+ }
+ });
+
+ it('removes a soft-wrapped tail without leaving a phantom row', () => {
+ const { addon, mock } = trackedTerminal({
+ buffer: { lines: ['$ '] },
+ cols: 6,
+ rows: 4,
+ });
+ addon.appendText('abcde');
+ addon.removeChar();
+
+ const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
+ const lines = (
+ addon as unknown as {
+ _layoutLines: Array<{ text: string }>;
+ }
+ )._layoutLines;
+ expect(lines.map((line) => line.text)).toEqual(['abcd']);
+ expect(overlay.textContent).toContain('abcd');
+ });
+ });
+
+ describe('composition text', () => {
+ it('renders changing IME candidates without committing them to pending text', () => {
+ const { addon, mock } = tracked();
+ addon.appendText('first ');
+
+ addon.setCompositionText('sec');
+ addon.setCompositionText('second');
+
+ const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
+ expect(addon.pendingText).toBe('first ');
+ expect(addon.compositionText).toBe('second');
+ expect(overlay.textContent).toContain('first second');
+ });
+
+ it('commits the finalized composition atomically', () => {
+ const { addon } = tracked();
+ addon.appendText('first ');
+ addon.setCompositionText('second');
+
+ addon.commitComposition('second');
+
+ expect(addon.pendingText).toBe('first second');
+ expect(addon.compositionText).toBe('');
+ expect(addon.hasPending).toBe(true);
+ });
+
+ it('clear removes an unfinished composition', () => {
+ const { addon } = tracked();
+ addon.setCompositionText('candidate');
+
+ addon.clear();
+
+ expect(addon.compositionText).toBe('');
+ expect(addon.state.visible).toBe(false);
+ });
+
+ it('removes composition before committed pending text', () => {
+ const { addon } = tracked();
+ addon.appendText('committed');
+ addon.setCompositionText('候補');
+
+ expect(addon.removeChar()).toBe('pending');
+ expect(addon.pendingText).toBe('committed');
+ expect(addon.compositionText).toBe('候');
+ });
});
describe('removeChar', () => {
@@ -96,6 +282,30 @@ describe('ZerolagInputAddon', () => {
expect(addon.removeChar()).toBe(false);
});
+ it('removes one emoji grapheme without leaving a surrogate', () => {
+ const { addon } = tracked();
+ addon.appendText('a😀');
+
+ expect(addon.removeChar()).toBe('pending');
+ expect(addon.pendingText).toBe('a');
+ });
+
+ it('removes one combining grapheme as a visible unit', () => {
+ const { addon } = tracked();
+ addon.appendText('e\u0301');
+
+ expect(addon.removeChar()).toBe('pending');
+ expect(addon.pendingText).toBe('');
+ });
+
+ it('removes CRLF as one rendered line break', () => {
+ const { addon } = tracked();
+ addon.appendText('line\r\n');
+
+ expect(addon.removeChar()).toBe('pending');
+ expect(addon.pendingText).toBe('line');
+ });
+
it('returns "flushed" when removing from flushed text', () => {
const { addon } = tracked();
addon.setFlushed(3, 'abc');
@@ -105,6 +315,14 @@ describe('ZerolagInputAddon', () => {
expect(addon.getFlushed().text).toBe('ab');
});
+ it('keeps flushed offsets aligned when removing an emoji grapheme', () => {
+ const { addon } = tracked();
+ addon.setFlushed('😀'.length, '😀');
+
+ expect(addon.removeChar()).toBe('flushed');
+ expect(addon.getFlushed()).toEqual({ count: 0, text: '' });
+ });
+
it('removes pending before flushed', () => {
const { addon } = tracked();
addon.setFlushed(2, 'ab');
@@ -122,14 +340,21 @@ describe('ZerolagInputAddon', () => {
expect(addon.hasPending).toBe(false);
});
- it('detects buffer text and removes from it when both empty', () => {
+ it('does not infer editable text from the terminal buffer', () => {
const { addon } = tracked(['$ hello']);
- // Both pending and flushed are empty, but buffer has text
+
const source = addon.removeChar();
- expect(source).toBe('flushed');
- // "hello" (5 chars) detected, then one removed = 4
- expect(addon.getFlushed().count).toBe(4);
- expect(addon.getFlushed().text).toBe('hell');
+
+ expect(source).toBe(false);
+ expect(addon.getFlushed()).toEqual({ count: 0, text: '' });
+ });
+
+ it('removes explicitly detected completion text as flushed input', () => {
+ const { addon } = tracked(['$ hello']);
+ expect(addon.detectBufferText()).toBe('hello');
+
+ expect(addon.removeChar()).toBe('flushed');
+ expect(addon.getFlushed()).toEqual({ count: 4, text: 'hell' });
});
it('returns false on empty prompt with no buffer text', () => {
@@ -184,6 +409,19 @@ describe('ZerolagInputAddon', () => {
expect(pos).not.toBeNull();
});
+ it('refreshes equal-length fallback text read from the terminal buffer', () => {
+ const { addon, mock } = tracked(['$ abc']);
+ addon.setFlushed(3, '');
+ const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
+ expect(overlay.textContent).toContain('abc');
+
+ mock.setLines(['$ xyz']);
+ addon.rerender();
+
+ expect(overlay.textContent).toContain('xyz');
+ expect(overlay.textContent).not.toContain('abc');
+ });
+
it('clearFlushed resets flushed state', () => {
const { addon } = tracked();
addon.setFlushed(3, 'abc');
@@ -222,6 +460,32 @@ describe('ZerolagInputAddon', () => {
expect(s1.pendingText).toBe('a');
expect(s2.pendingText).toBe('ab');
});
+
+ it('restores an editable draft without rendering against a stale terminal frame', () => {
+ const { addon, mock } = tracked(['$ stale session']);
+
+ addon.restoreDraft(
+ {
+ pendingText: ' pending',
+ flushedText: 'already sent',
+ },
+ false
+ );
+
+ expect(addon.state).toMatchObject({
+ pendingText: ' pending',
+ compositionText: '',
+ flushedLength: 'already sent'.length,
+ flushedText: 'already sent',
+ visible: false,
+ promptPosition: null,
+ });
+
+ mock.setLines(['$ current session']);
+ addon.rerender();
+ expect(addon.state.visible).toBe(true);
+ expect(addon.state.promptPosition).not.toBeNull();
+ });
});
describe('prompt detection', () => {
@@ -295,22 +559,19 @@ describe('ZerolagInputAddon', () => {
expect(addon.getFlushed().count).toBe(0);
});
- it('suppressBufferDetection also blocks implicit detection in addChar', () => {
+ it('suppressBufferDetection keeps explicit detection disabled while typing', () => {
const { addon } = tracked(['$ ink status bar']);
addon.suppressBufferDetection();
- // addChar calls _detectBufferText internally on first keystroke
addon.addChar('x');
- // Should have only 'x' pending, NOT the buffer text as flushed
expect(addon.pendingText).toBe('x');
expect(addon.getFlushed().count).toBe(0);
});
- it('suppressBufferDetection also blocks detection in removeChar cascade', () => {
+ it('suppressBufferDetection keeps explicit detection disabled before backspace', () => {
const { addon } = tracked(['$ buffer text']);
addon.suppressBufferDetection();
- // removeChar step 3 calls _detectBufferText — should be blocked
expect(addon.removeChar()).toBe(false);
expect(addon.getFlushed().count).toBe(0);
});
@@ -426,6 +687,53 @@ describe('ZerolagInputAddon', () => {
expect(() => addon.refreshFont()).not.toThrow();
expect(addon.hasPending).toBe(true);
});
+
+ it('can pin a pending draft to the viewport while terminal history is scrolled', () => {
+ const { addon, mock } = tracked(['$ draft']);
+ addon.appendText('draft');
+ const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
+
+ mock.terminal.buffer.active.baseY = 8;
+ mock.terminal.buffer.active.viewportY = 2;
+ mock.terminal.element.querySelector('.xterm-viewport')!.dispatchEvent(new Event('scroll'));
+ expect(addon.state.visible).toBe(false);
+
+ addon.setViewportPinned(true);
+ expect(addon.state.visible).toBe(true);
+ expect(overlay.textContent).toContain('draft');
+
+ addon.setViewportPinned(false);
+ expect(addon.state.visible).toBe(false);
+ expect(addon.pendingText).toBe('draft');
+ });
+
+ it('caches the live prompt before an empty pinned draft scrolls away', () => {
+ const { addon, mock } = tracked(['$ ']);
+ const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement;
+ addon.setViewportPinned(true);
+
+ mock.terminal.buffer.active.baseY = 8;
+ mock.terminal.buffer.active.viewportY = 2;
+ mock.terminal.element.querySelector('.xterm-viewport')!.dispatchEvent(new Event('scroll'));
+ addon.addChar('a');
+
+ expect(addon.state.visible).toBe(true);
+ expect(overlay.textContent).toContain('a');
+ });
+
+ it('retains the pinned prompt after deleting the draft to empty', () => {
+ const { addon, mock } = tracked(['$ ']);
+ addon.setViewportPinned(true);
+ mock.terminal.buffer.active.baseY = 8;
+ mock.terminal.buffer.active.viewportY = 2;
+ addon.addChar('a');
+ addon.removeChar();
+
+ addon.addChar('b');
+
+ expect(addon.state.visible).toBe(true);
+ expect(addon.pendingText).toBe('b');
+ });
});
describe('tab-switch save/restore pattern', () => {
@@ -705,23 +1013,21 @@ describe('ZerolagInputAddon', () => {
});
});
- describe('addChar implicit buffer detection', () => {
- it('first keystroke detects existing buffer text as flushed', () => {
+ describe('typing with pre-existing buffer content', () => {
+ it('does not adopt existing buffer text on the first keystroke', () => {
const { addon } = tracked(['$ existing']);
- // First addChar should trigger _detectBufferText
+
addon.addChar('!');
expect(addon.pendingText).toBe('!');
- expect(addon.getFlushed().count).toBe(8); // 'existing'
- expect(addon.getFlushed().text).toBe('existing');
+ expect(addon.getFlushed()).toEqual({ count: 0, text: '' });
});
- it('second keystroke does NOT re-detect', () => {
+ it('does not adopt existing buffer text on appendText', () => {
const { addon } = tracked(['$ existing']);
- addon.addChar('a');
- addon.addChar('b');
- // Should NOT detect again — flushed from first char remains
- expect(addon.pendingText).toBe('ab');
- expect(addon.getFlushed().count).toBe(8); // still 'existing'
+
+ addon.appendText('pasted');
+ expect(addon.pendingText).toBe('pasted');
+ expect(addon.getFlushed()).toEqual({ count: 0, text: '' });
});
});
});
From 362ccfc0381b8b24b25461cc74d0bbf8b8ef7107 Mon Sep 17 00:00:00 2001
From: lior
Date: Wed, 29 Jul 2026 09:12:35 +0300
Subject: [PATCH 2/8] feat(input): add per-session terminal draft store
---
src/web/public/terminal-input-state.js | 441 ++++++++++++++++++++
test/terminal-input-state.test.ts | 536 +++++++++++++++++++++++++
2 files changed, 977 insertions(+)
create mode 100644 src/web/public/terminal-input-state.js
create mode 100644 test/terminal-input-state.test.ts
diff --git a/src/web/public/terminal-input-state.js b/src/web/public/terminal-input-state.js
new file mode 100644
index 00000000..6ef4eb60
--- /dev/null
+++ b/src/web/public/terminal-input-state.js
@@ -0,0 +1,441 @@
+/**
+ * @fileoverview Durable editable terminal input state, scoped by session.
+ *
+ * This store owns the complete local draft record and its persistence lifecycle.
+ * It deliberately does not render text or deliver bytes to a PTY. Callers provide
+ * snapshots from their input adapters and explicitly consume `handoff().flushText`
+ * through Codeman's separate exactly-once delivery layer.
+ *
+ * @globals {TerminalInputStateStore}
+ * @dependency None
+ * @loadorder 5.55 of 16 - loaded after input-cjk.js, before terminal-input-controller.js
+ */
+
+const DEFAULT_MAX_TERMINAL_DRAFTS = 50;
+const DEFAULT_MAX_TERMINAL_DRAFT_AGE_MS = 30 * 24 * 60 * 60 * 1000;
+const DEFAULT_MAX_TERMINAL_DRAFT_STORAGE_CHARS = 512 * 1024;
+const DEFAULT_MAX_TOTAL_TERMINAL_DRAFT_STORAGE_CHARS = 2 * 1024 * 1024;
+
+class TerminalInputStateStore {
+ constructor(options = {}) {
+ this._storageKey = options.storageKey || 'codeman:sessionDrafts';
+ this._draftKeyPrefix = options.draftKeyPrefix || `${this._storageKey}:draft:`;
+ this._snapshotPrefix = options.snapshotPrefix || 'codeman-xs-';
+ this._debounceMs = Number.isFinite(options.debounceMs) ? options.debounceMs : 150;
+ this._maxDrafts =
+ Number.isInteger(options.maxDrafts) && options.maxDrafts > 0 ? options.maxDrafts : DEFAULT_MAX_TERMINAL_DRAFTS;
+ this._maxDraftAgeMs =
+ Number.isFinite(options.maxDraftAgeMs) && options.maxDraftAgeMs > 0
+ ? options.maxDraftAgeMs
+ : DEFAULT_MAX_TERMINAL_DRAFT_AGE_MS;
+ this._maxPersistedChars =
+ Number.isInteger(options.maxPersistedChars) && options.maxPersistedChars > 0
+ ? options.maxPersistedChars
+ : DEFAULT_MAX_TERMINAL_DRAFT_STORAGE_CHARS;
+ this._maxTotalPersistedChars =
+ Number.isInteger(options.maxTotalPersistedChars) && options.maxTotalPersistedChars > 0
+ ? options.maxTotalPersistedChars
+ : DEFAULT_MAX_TOTAL_TERMINAL_DRAFT_STORAGE_CHARS;
+ this._now = typeof options.now === 'function' ? options.now : Date.now;
+ const setTimer = typeof options.setTimer === 'function' ? options.setTimer : globalThis.setTimeout;
+ const clearTimer = typeof options.clearTimer === 'function' ? options.clearTimer : globalThis.clearTimeout;
+ const requestIdle =
+ typeof options.requestIdle === 'function' ? options.requestIdle : globalThis.requestIdleCallback;
+ const cancelIdle = typeof options.cancelIdle === 'function' ? options.cancelIdle : globalThis.cancelIdleCallback;
+ if (typeof requestIdle === 'function' && typeof cancelIdle === 'function') {
+ this._scheduleWrite = (callback) => requestIdle(callback, { timeout: this._debounceMs });
+ this._cancelWrite = (handle) => cancelIdle(handle);
+ } else {
+ this._scheduleWrite = (callback) => setTimer(callback, this._debounceMs);
+ this._cancelWrite = (handle) => clearTimer(handle);
+ }
+ this._storage = Object.prototype.hasOwnProperty.call(options, 'storage') ? options.storage : this._resolveStorage();
+ this._drafts = new Map();
+ this._persistedSessionIds = new Set();
+ this._persistedSizes = new Map();
+ this._dirtySessionIds = new Set();
+ this._nonDurableSessionIds = new Set();
+ this._persistHandle = null;
+ this._legacyStoragePresent = false;
+ this.load();
+ }
+
+ capture(sessionId, snapshot, options = {}) {
+ const source = snapshot && typeof snapshot === 'object' ? snapshot : {};
+ return this.set(
+ sessionId,
+ {
+ // OS composition sessions cannot survive a page suspension. Preserve
+ // the visible candidate as ordinary editable text on capture.
+ pendingText: this._text(source.pendingText) + this._text(source.compositionText),
+ flushedText: this._text(source.flushedText),
+ cjkText: this._text(source.cjkText),
+ ptyOwned: source.ptyOwned === true,
+ },
+ options
+ );
+ }
+
+ handoff(sessionId, snapshot, options = {}) {
+ const source = snapshot && typeof snapshot === 'object' ? snapshot : {};
+ const flushText = this._text(source.pendingText);
+ const draft = this.set(
+ sessionId,
+ {
+ // A live composition remains editable; only committed pending text is
+ // flushed into the outgoing session's PTY input buffer.
+ pendingText: this._text(source.compositionText),
+ flushedText: this._text(source.flushedText) + flushText,
+ cjkText: this._text(source.cjkText),
+ ptyOwned: source.ptyOwned === true,
+ },
+ options
+ );
+ return { flushText, draft };
+ }
+
+ set(sessionId, candidate, options = {}) {
+ if (!sessionId) return null;
+ const draft = this._normalize(candidate);
+ if (draft) {
+ this._drafts.set(sessionId, draft);
+ } else {
+ this._drafts.delete(sessionId);
+ }
+ this._nonDurableSessionIds.delete(sessionId);
+ this._dirtySessionIds.add(sessionId);
+ if (options.persist !== false) this.schedulePersist();
+ return this._clone(draft);
+ }
+
+ get(sessionId) {
+ return this._clone(this._drafts.get(sessionId) || null);
+ }
+
+ has(sessionId) {
+ return this._drafts.has(sessionId);
+ }
+
+ hasFlushed(sessionId) {
+ return !!this._drafts.get(sessionId)?.flushedText;
+ }
+
+ clear(sessionId, options = {}) {
+ if (!sessionId) return;
+ this._drafts.delete(sessionId);
+ this._nonDurableSessionIds.delete(sessionId);
+ this._dirtySessionIds.add(sessionId);
+ if (options.persist !== false) this.schedulePersist();
+ }
+
+ clearAll(options = {}) {
+ for (const sessionId of this._drafts.keys()) this._dirtySessionIds.add(sessionId);
+ for (const sessionId of this._persistedSessionIds) this._dirtySessionIds.add(sessionId);
+ this._drafts.clear();
+ this._nonDurableSessionIds.clear();
+ if (options.persist !== false) this.schedulePersist();
+ }
+
+ load() {
+ this._drafts.clear();
+ this._persistedSessionIds.clear();
+ this._persistedSizes.clear();
+ this._dirtySessionIds.clear();
+ this._nonDurableSessionIds.clear();
+ this._legacyStoragePresent = false;
+ const storage = this._storage;
+ if (!storage) return;
+
+ try {
+ const raw = storage.getItem(this._storageKey);
+ if (raw) {
+ const saved = JSON.parse(raw);
+ if (saved && saved.drafts && typeof saved.drafts === 'object') {
+ this._legacyStoragePresent = true;
+ for (const [sessionId, candidate] of Object.entries(saved.drafts)) {
+ const draft = this._normalize(candidate);
+ if (!draft) continue;
+ this._drafts.set(sessionId, draft);
+ this._dirtySessionIds.add(sessionId);
+ }
+ }
+ }
+ } catch {
+ // Corrupt or disabled legacy storage must not block per-session records.
+ }
+
+ for (const key of this._storageKeys(storage)) {
+ if (!key.startsWith(this._draftKeyPrefix)) continue;
+ const sessionId = this._sessionIdFromKey(key);
+ if (!sessionId) continue;
+ try {
+ const raw = storage.getItem(key);
+ if (!raw) continue;
+ const saved = JSON.parse(raw);
+ const draft = this._normalize(saved?.draft ?? saved);
+ if (!draft) continue;
+ const existing = this._drafts.get(sessionId);
+ if (!existing || draft.updatedAt >= existing.updatedAt) {
+ this._drafts.set(sessionId, draft);
+ }
+ this._persistedSessionIds.add(sessionId);
+ this._persistedSizes.set(sessionId, raw.length);
+ } catch {
+ // One corrupt draft must not hide the other sessions' editable state.
+ }
+ }
+ this._pruneDrafts();
+ }
+
+ schedulePersist() {
+ if (this._persistHandle !== null) return;
+ this._persistHandle = this._scheduleWrite(() => {
+ this._persistHandle = null;
+ this.persistNow();
+ });
+ }
+
+ persistNow() {
+ if (this._persistHandle !== null) {
+ this._cancelWrite(this._persistHandle);
+ this._persistHandle = null;
+ }
+ const storage = this._storage;
+ if (!storage) return false;
+
+ this._pruneDrafts();
+ let allDurable = this._nonDurableSessionIds.size === 0;
+ let migrationComplete = true;
+ for (const sessionId of Array.from(this._dirtySessionIds)) {
+ const key = this._draftStorageKey(sessionId);
+ const draft = this._drafts.get(sessionId);
+ if (!draft) {
+ if (!this._removeStorageKey(storage, key)) {
+ allDurable = false;
+ migrationComplete = false;
+ continue;
+ }
+ this._dirtySessionIds.delete(sessionId);
+ this._persistedSessionIds.delete(sessionId);
+ this._persistedSizes.delete(sessionId);
+ this._nonDurableSessionIds.delete(sessionId);
+ continue;
+ }
+
+ const payload = JSON.stringify({ version: 1, draft: this._clone(draft) });
+ if (payload.length > this._maxPersistedChars) {
+ // Never leave an older value that could resurrect after reload.
+ this._nonDurableSessionIds.add(sessionId);
+ if (this._removeStorageKey(storage, key)) {
+ this._dirtySessionIds.delete(sessionId);
+ this._persistedSessionIds.delete(sessionId);
+ this._persistedSizes.delete(sessionId);
+ } else {
+ migrationComplete = false;
+ }
+ allDurable = false;
+ continue;
+ }
+
+ if (!this._persistDraft(storage, sessionId, key, payload)) {
+ allDurable = false;
+ migrationComplete = false;
+ continue;
+ }
+ this._dirtySessionIds.delete(sessionId);
+ this._nonDurableSessionIds.delete(sessionId);
+ this._persistedSessionIds.add(sessionId);
+ this._persistedSizes.set(sessionId, payload.length);
+ }
+
+ if (
+ this._legacyStoragePresent &&
+ migrationComplete &&
+ this._nonDurableSessionIds.size === 0 &&
+ this._dirtySessionIds.size === 0
+ ) {
+ try {
+ storage.removeItem(this._storageKey);
+ this._legacyStoragePresent = false;
+ } catch {
+ allDurable = false;
+ }
+ }
+ return allDurable && this._nonDurableSessionIds.size === 0;
+ }
+
+ _normalize(raw) {
+ if (!raw || typeof raw !== 'object') return null;
+ const pendingText = this._text(raw.pendingText);
+ const flushedText = this._text(raw.flushedText);
+ const cjkText = this._text(raw.cjkText);
+ const ptyOwned = raw.ptyOwned === true;
+ if (!pendingText && !flushedText && !cjkText && !ptyOwned) return null;
+ const draft = {
+ pendingText,
+ flushedText,
+ cjkText,
+ updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : this._now(),
+ };
+ if (ptyOwned) draft.ptyOwned = true;
+ return draft;
+ }
+
+ _clone(draft) {
+ return draft
+ ? {
+ pendingText: draft.pendingText,
+ flushedText: draft.flushedText,
+ cjkText: draft.cjkText,
+ updatedAt: draft.updatedAt,
+ ...(draft.ptyOwned ? { ptyOwned: true } : {}),
+ }
+ : null;
+ }
+
+ _text(value) {
+ return typeof value === 'string' ? value : '';
+ }
+
+ _persistDraft(storage, sessionId, key, payload) {
+ if (!this._ensureTotalBudget(storage, sessionId, payload.length)) return false;
+ let result = this._tryPersist(storage, key, payload);
+ if (result.ok) return true;
+ if (!this._isQuotaError(result.error)) return false;
+
+ // Terminal snapshots are reproducible from the server; typed text is not.
+ // Reclaim snapshot quota one entry at a time before abandoning the draft.
+ for (const snapshotKey of this._storageKeys(storage)) {
+ if (!snapshotKey.startsWith(this._snapshotPrefix)) continue;
+ if (!this._removeStorageKey(storage, snapshotKey)) return false;
+ result = this._tryPersist(storage, key, payload);
+ if (result.ok) return true;
+ if (!this._isQuotaError(result.error)) return false;
+ }
+
+ while (this._reclaimOldestDraft(storage, sessionId)) {
+ result = this._tryPersist(storage, key, payload);
+ if (result.ok) return true;
+ if (!this._isQuotaError(result.error)) return false;
+ }
+ return false;
+ }
+
+ _ensureTotalBudget(storage, sessionId, payloadLength) {
+ const previousLength = this._persistedSizes.get(sessionId) || 0;
+ let nextTotal = this._totalPersistedChars() - previousLength + payloadLength;
+ while (nextTotal > this._maxTotalPersistedChars) {
+ if (!this._reclaimOldestDraft(storage, sessionId)) return false;
+ nextTotal = this._totalPersistedChars() - previousLength + payloadLength;
+ }
+ return true;
+ }
+
+ _reclaimOldestDraft(storage, currentSessionId) {
+ const candidates = Array.from(this._persistedSessionIds)
+ .filter(
+ (sessionId) =>
+ sessionId !== currentSessionId && !this._dirtySessionIds.has(sessionId) && this._persistedSizes.has(sessionId)
+ )
+ .sort((left, right) => (this._drafts.get(left)?.updatedAt || 0) - (this._drafts.get(right)?.updatedAt || 0));
+ const sessionId = candidates[0];
+ if (!sessionId) return false;
+ if (!this._removeStorageKey(storage, this._draftStorageKey(sessionId))) return false;
+ this._persistedSessionIds.delete(sessionId);
+ this._persistedSizes.delete(sessionId);
+ if (this._drafts.has(sessionId)) this._nonDurableSessionIds.add(sessionId);
+ return true;
+ }
+
+ _totalPersistedChars() {
+ let total = 0;
+ for (const size of this._persistedSizes.values()) total += size;
+ return total;
+ }
+
+ _tryPersist(storage, key, payload) {
+ try {
+ storage.setItem(key, payload);
+ return { ok: true, error: null };
+ } catch (error) {
+ return { ok: false, error };
+ }
+ }
+
+ _isQuotaError(error) {
+ if (!error || typeof error !== 'object') return false;
+ return (
+ error.name === 'QuotaExceededError' ||
+ error.name === 'NS_ERROR_DOM_QUOTA_REACHED' ||
+ error.code === 22 ||
+ error.code === 1014
+ );
+ }
+
+ _pruneDrafts() {
+ const now = this._now();
+ if (Number.isFinite(now)) {
+ for (const [sessionId, draft] of this._drafts) {
+ if (now - draft.updatedAt > this._maxDraftAgeMs) {
+ this._drafts.delete(sessionId);
+ this._nonDurableSessionIds.delete(sessionId);
+ this._dirtySessionIds.add(sessionId);
+ }
+ }
+ }
+
+ if (this._drafts.size <= this._maxDrafts) return;
+ const oldestFirst = Array.from(this._drafts.entries()).sort(
+ ([, left], [, right]) => left.updatedAt - right.updatedAt
+ );
+ for (const [sessionId] of oldestFirst.slice(0, this._drafts.size - this._maxDrafts)) {
+ this._drafts.delete(sessionId);
+ this._nonDurableSessionIds.delete(sessionId);
+ this._dirtySessionIds.add(sessionId);
+ }
+ }
+
+ _draftStorageKey(sessionId) {
+ return `${this._draftKeyPrefix}${encodeURIComponent(sessionId)}`;
+ }
+
+ _sessionIdFromKey(key) {
+ try {
+ return decodeURIComponent(key.slice(this._draftKeyPrefix.length));
+ } catch {
+ return null;
+ }
+ }
+
+ _removeStorageKey(storage, key) {
+ try {
+ storage.removeItem(key);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ _storageKeys(storage) {
+ try {
+ const keys = [];
+ for (let index = 0; index < storage.length; index += 1) {
+ const key = storage.key(index);
+ if (key !== null) keys.push(key);
+ }
+ return keys;
+ } catch {
+ return [];
+ }
+ }
+
+ _resolveStorage() {
+ try {
+ return globalThis.localStorage || null;
+ } catch {
+ return null;
+ }
+ }
+}
+
+globalThis.TerminalInputStateStore = TerminalInputStateStore;
diff --git a/test/terminal-input-state.test.ts b/test/terminal-input-state.test.ts
new file mode 100644
index 00000000..5d3b94eb
--- /dev/null
+++ b/test/terminal-input-state.test.ts
@@ -0,0 +1,536 @@
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+import { describe, expect, it } from 'vitest';
+
+type InputDraft = {
+ pendingText: string;
+ flushedText: string;
+ cjkText: string;
+ updatedAt: number;
+ ptyOwned?: boolean;
+};
+
+type InputSnapshot = {
+ pendingText?: string;
+ compositionText?: string;
+ flushedText?: string;
+ cjkText?: string;
+ ptyOwned?: boolean;
+};
+
+type TerminalInputStateStore = {
+ capture: (sessionId: string, snapshot: InputSnapshot, options?: { persist?: boolean }) => InputDraft | null;
+ handoff: (
+ sessionId: string,
+ snapshot: InputSnapshot,
+ options?: { persist?: boolean }
+ ) => { flushText: string; draft: InputDraft | null };
+ set: (sessionId: string, draft: Partial, options?: { persist?: boolean }) => InputDraft | null;
+ get: (sessionId: string) => InputDraft | null;
+ has: (sessionId: string) => boolean;
+ hasFlushed: (sessionId: string) => boolean;
+ clear: (sessionId: string, options?: { persist?: boolean }) => void;
+ clearAll: (options?: { persist?: boolean }) => void;
+ load: () => void;
+ persistNow: () => boolean;
+};
+
+type StoreOptions = {
+ storage?: MemoryStorage | null;
+ now?: () => number;
+ setTimer?: (callback: () => void, delay: number) => unknown;
+ clearTimer?: (timer: unknown) => void;
+ requestIdle?: (callback: () => void, options: { timeout: number }) => unknown;
+ cancelIdle?: (handle: unknown) => void;
+ maxDrafts?: number;
+ maxDraftAgeMs?: number;
+ maxPersistedChars?: number;
+ maxTotalPersistedChars?: number;
+};
+
+type TerminalInputStateStoreConstructor = new (options?: StoreOptions) => TerminalInputStateStore;
+
+class MemoryStorage {
+ protected readonly values = new Map();
+
+ get length(): number {
+ return this.values.size;
+ }
+
+ key(index: number): string | null {
+ return Array.from(this.values.keys())[index] ?? null;
+ }
+
+ getItem(key: string): string | null {
+ return this.values.get(key) ?? null;
+ }
+
+ setItem(key: string, value: string): void {
+ this.values.set(String(key), String(value));
+ }
+
+ removeItem(key: string): void {
+ this.values.delete(key);
+ }
+
+ clear(): void {
+ this.values.clear();
+ }
+}
+
+class QuotaStorage extends MemoryStorage {
+ override setItem(key: string, value: string): void {
+ if (
+ key.startsWith('codeman:sessionDrafts:draft:') &&
+ Array.from(this.values.keys()).some((candidate) => candidate.startsWith('codeman-xs-'))
+ ) {
+ const error = new Error('quota exceeded');
+ error.name = 'QuotaExceededError';
+ throw error;
+ }
+ super.setItem(key, value);
+ }
+}
+
+class RecordingStorage extends MemoryStorage {
+ readonly writes: string[] = [];
+
+ override setItem(key: string, value: string): void {
+ this.writes.push(key);
+ super.setItem(key, value);
+ }
+}
+
+class NonQuotaFailingStorage extends MemoryStorage {
+ failWrites = false;
+
+ override setItem(key: string, value: string): void {
+ if (this.failWrites) {
+ const error = new Error('storage disabled');
+ error.name = 'SecurityError';
+ throw error;
+ }
+ super.setItem(key, value);
+ }
+}
+
+class FailingRemoveStorage extends MemoryStorage {
+ failDraftRemovals = false;
+
+ override removeItem(key: string): void {
+ if (this.failDraftRemovals && key.startsWith('codeman:sessionDrafts:draft:')) {
+ throw new Error('remove failed');
+ }
+ super.removeItem(key);
+ }
+}
+
+function loadStore(): TerminalInputStateStoreConstructor {
+ const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-input-state.js'), 'utf8');
+ const context = vm.createContext({
+ console,
+ globalThis: {},
+ });
+ vm.runInContext(source, context, { filename: 'terminal-input-state.js' });
+ return (
+ context.globalThis as {
+ TerminalInputStateStore: TerminalInputStateStoreConstructor;
+ }
+ ).TerminalInputStateStore;
+}
+
+function createStore(
+ Store: TerminalInputStateStoreConstructor,
+ storage: MemoryStorage | null = new MemoryStorage(),
+ now = () => 100
+): TerminalInputStateStore {
+ return new Store({
+ storage,
+ now,
+ setTimer: () => 1,
+ clearTimer: () => {},
+ });
+}
+
+function draftKey(sessionId: string): string {
+ return `codeman:sessionDrafts:draft:${encodeURIComponent(sessionId)}`;
+}
+
+function persistedDraft(storage: MemoryStorage, sessionId: string): InputDraft | null {
+ const raw = storage.getItem(draftKey(sessionId));
+ return raw ? (JSON.parse(raw).draft as InputDraft) : null;
+}
+
+describe('TerminalInputStateStore', () => {
+ it('does not rebind injected platform timer functions to the store', () => {
+ const Store = loadStore();
+ let setTimerReceiver: unknown = 'not called';
+ let clearTimerReceiver: unknown = 'not called';
+ const store = new Store({
+ storage: new MemoryStorage(),
+ setTimer: function (this: unknown) {
+ setTimerReceiver = this;
+ return 7;
+ },
+ clearTimer: function (this: unknown) {
+ clearTimerReceiver = this;
+ },
+ });
+
+ store.set('session-a', { pendingText: 'draft' });
+ store.persistNow();
+
+ expect(setTimerReceiver).toBeUndefined();
+ expect(clearTimerReceiver).toBeUndefined();
+ });
+
+ it('uses idle scheduling when the browser provides it', () => {
+ const Store = loadStore();
+ const callbacks: Array<() => void> = [];
+ let timeout = 0;
+ const store = new Store({
+ storage: new MemoryStorage(),
+ debounceMs: 175,
+ requestIdle: (callback, options) => {
+ callbacks.push(callback);
+ timeout = options.timeout;
+ return 1;
+ },
+ cancelIdle: () => {},
+ setTimer: () => {
+ throw new Error('timer fallback should not be used');
+ },
+ });
+
+ store.set('session-a', { pendingText: 'draft' });
+
+ expect(callbacks).toHaveLength(1);
+ expect(timeout).toBe(175);
+ });
+
+ it('coalesces scheduled persistence and can schedule again after the timer fires', () => {
+ const Store = loadStore();
+ const storage = new MemoryStorage();
+ const callbacks: Array<() => void> = [];
+ const store = new Store({
+ storage,
+ now: () => 100,
+ setTimer: (callback) => {
+ callbacks.push(callback);
+ return callbacks.length;
+ },
+ clearTimer: () => {},
+ });
+
+ store.set('session-a', { pendingText: 'a' });
+ store.set('session-a', { pendingText: 'ab' });
+ expect(callbacks).toHaveLength(1);
+
+ callbacks.shift()!();
+ expect(persistedDraft(storage, 'session-a')?.pendingText).toBe('ab');
+
+ store.set('session-a', { pendingText: 'abc' });
+ expect(callbacks).toHaveLength(1);
+ });
+
+ it('captures one exact editable record per session without exposing mutable internals', () => {
+ const Store = loadStore();
+ const store = createStore(Store);
+
+ const captured = store.capture(
+ 'session-a',
+ {
+ pendingText: 'first paragraph\n\nsecond paragraph',
+ compositionText: '候補',
+ flushedText: 'already sent',
+ cjkText: '中文',
+ },
+ { persist: false }
+ );
+
+ expect(captured).toEqual({
+ pendingText: 'first paragraph\n\nsecond paragraph候補',
+ flushedText: 'already sent',
+ cjkText: '中文',
+ updatedAt: 100,
+ });
+ captured!.pendingText = 'mutated outside';
+ expect(store.get('session-a')?.pendingText).toBe('first paragraph\n\nsecond paragraph候補');
+ expect(store.hasFlushed('session-a')).toBe(true);
+ });
+
+ it('returns pending text as an explicit handoff output and keeps composition editable', () => {
+ const Store = loadStore();
+ const store = createStore(Store);
+
+ const result = store.handoff(
+ 'session-a',
+ {
+ pendingText: 'flush me',
+ compositionText: 'keep me',
+ flushedText: 'older text',
+ cjkText: '保留',
+ },
+ { persist: false }
+ );
+
+ expect(result).toEqual({
+ flushText: 'flush me',
+ draft: {
+ pendingText: 'keep me',
+ flushedText: 'older textflush me',
+ cjkText: '保留',
+ updatedAt: 100,
+ },
+ });
+ expect(store.get('session-a')).toEqual(result.draft);
+ });
+
+ it('persists and reloads multiline Unicode drafts without loss', () => {
+ const Store = loadStore();
+ const storage = new MemoryStorage();
+ const first = createStore(Store, storage, () => 321);
+ first.set(
+ 'session-a',
+ {
+ pendingText: 'References\n--------------------\n\nWisdom–Holman',
+ flushedText: '',
+ cjkText: '轨道',
+ },
+ { persist: false }
+ );
+
+ expect(first.persistNow()).toBe(true);
+ const second = createStore(Store, storage, () => 999);
+
+ expect(second.get('session-a')).toEqual({
+ pendingText: 'References\n--------------------\n\nWisdom–Holman',
+ flushedText: '',
+ cjkText: '轨道',
+ updatedAt: 321,
+ });
+ });
+
+ it('persists PTY-owned editing even when no overlay text remains', () => {
+ const Store = loadStore();
+ const storage = new MemoryStorage();
+ const first = createStore(Store, storage, () => 321);
+
+ expect(first.set('session-a', { ptyOwned: true }, { persist: false })).toEqual({
+ pendingText: '',
+ flushedText: '',
+ cjkText: '',
+ updatedAt: 321,
+ ptyOwned: true,
+ });
+ expect(first.persistNow()).toBe(true);
+
+ const second = createStore(Store, storage, () => 999);
+ expect(second.get('session-a')).toEqual({
+ pendingText: '',
+ flushedText: '',
+ cjkText: '',
+ updatedAt: 321,
+ ptyOwned: true,
+ });
+ });
+
+ it('migrates the aggregate storage format to per-session records', () => {
+ const Store = loadStore();
+ const storage = new MemoryStorage();
+ storage.setItem(
+ 'codeman:sessionDrafts',
+ JSON.stringify({
+ version: 1,
+ drafts: {
+ 'session-a': {
+ pendingText: 'legacy',
+ flushedText: '',
+ cjkText: '',
+ updatedAt: 100,
+ },
+ },
+ })
+ );
+ const store = createStore(Store, storage);
+
+ expect(store.get('session-a')?.pendingText).toBe('legacy');
+ expect(store.persistNow()).toBe(true);
+ expect(storage.getItem('codeman:sessionDrafts')).toBeNull();
+ expect(persistedDraft(storage, 'session-a')?.pendingText).toBe('legacy');
+ });
+
+ it('writes only sessions whose drafts changed', () => {
+ const Store = loadStore();
+ const storage = new RecordingStorage();
+ const store = createStore(Store, storage);
+ store.set('session-a', { pendingText: 'a' }, { persist: false });
+ store.set('session-b', { pendingText: 'b' }, { persist: false });
+ expect(store.persistNow()).toBe(true);
+ storage.writes.length = 0;
+
+ store.set('session-a', { pendingText: 'updated' }, { persist: false });
+ expect(store.persistNow()).toBe(true);
+
+ expect(storage.writes).toEqual([draftKey('session-a')]);
+ });
+
+ it('clears one session or the complete store through explicit APIs', () => {
+ const Store = loadStore();
+ const store = createStore(Store);
+ store.set('session-a', { pendingText: 'a' }, { persist: false });
+ store.set('session-b', { pendingText: 'b' }, { persist: false });
+
+ store.clear('session-a', { persist: false });
+ expect(store.has('session-a')).toBe(false);
+ expect(store.has('session-b')).toBe(true);
+
+ store.clearAll({ persist: false });
+ expect(store.has('session-b')).toBe(false);
+ });
+
+ it('evicts reproducible terminal snapshots before abandoning typed drafts', () => {
+ const Store = loadStore();
+ const storage = new QuotaStorage();
+ MemoryStorage.prototype.setItem.call(storage, 'codeman-xs-old-a', 'snapshot');
+ MemoryStorage.prototype.setItem.call(storage, 'codeman-xs-old-b', 'snapshot');
+ MemoryStorage.prototype.setItem.call(storage, 'keep-me', 'other state');
+ const store = createStore(Store, storage);
+ store.set('session-a', { pendingText: 'irreplaceable' }, { persist: false });
+
+ expect(store.persistNow()).toBe(true);
+ expect(storage.getItem('codeman-xs-old-a')).toBeNull();
+ expect(storage.getItem('codeman-xs-old-b')).toBeNull();
+ expect(storage.getItem('keep-me')).toBe('other state');
+ expect(persistedDraft(storage, 'session-a')?.pendingText).toBe('irreplaceable');
+ });
+
+ it('does not evict terminal snapshots for non-quota storage failures', () => {
+ const Store = loadStore();
+ const storage = new NonQuotaFailingStorage();
+ storage.setItem('codeman-xs-warm-session', 'snapshot');
+ const store = createStore(Store, storage);
+ store.set('session-a', { pendingText: 'irreplaceable' }, { persist: false });
+ storage.failWrites = true;
+
+ expect(store.persistNow()).toBe(false);
+ expect(storage.getItem('codeman-xs-warm-session')).toBe('snapshot');
+ });
+
+ it('bounds stale session retention and refuses oversized writes without truncating memory', () => {
+ const Store = loadStore();
+ const storage = new MemoryStorage();
+ const store = new Store({
+ storage,
+ now: () => 1_000,
+ maxDrafts: 2,
+ maxDraftAgeMs: 500,
+ maxPersistedChars: 200,
+ setTimer: () => 1,
+ clearTimer: () => {},
+ });
+ store.set('expired', { pendingText: 'old', updatedAt: 100 }, { persist: false });
+ store.set('session-a', { pendingText: 'a', updatedAt: 800 }, { persist: false });
+ store.set('session-b', { pendingText: 'b', updatedAt: 900 }, { persist: false });
+
+ expect(store.persistNow()).toBe(true);
+ expect(storage.getItem(draftKey('expired'))).toBeNull();
+ expect(persistedDraft(storage, 'session-a')?.pendingText).toBe('a');
+ expect(persistedDraft(storage, 'session-b')?.pendingText).toBe('b');
+
+ store.set('session-b', { pendingText: 'x'.repeat(500) }, { persist: false });
+ expect(store.persistNow()).toBe(false);
+ expect(store.persistNow()).toBe(false);
+ expect(store.get('session-b')?.pendingText).toBe('x'.repeat(500));
+ expect(storage.getItem(draftKey('session-b'))).toBeNull();
+ expect(createStore(Store, storage, () => 1_000).get('session-b')).toBeNull();
+ });
+
+ it('retries stale-record removal for an oversized live draft', () => {
+ const Store = loadStore();
+ const storage = new FailingRemoveStorage();
+ storage.setItem(
+ draftKey('session-a'),
+ JSON.stringify({
+ version: 1,
+ draft: {
+ pendingText: 'stale',
+ flushedText: '',
+ cjkText: '',
+ updatedAt: 100,
+ },
+ })
+ );
+ const store = new Store({
+ storage,
+ now: () => 200,
+ maxPersistedChars: 100,
+ setTimer: () => 1,
+ clearTimer: () => {},
+ });
+ store.set('session-a', { pendingText: 'x'.repeat(500) }, { persist: false });
+ storage.failDraftRemovals = true;
+
+ expect(store.persistNow()).toBe(false);
+ expect(persistedDraft(storage, 'session-a')?.pendingText).toBe('stale');
+
+ storage.failDraftRemovals = false;
+ expect(store.persistNow()).toBe(false);
+ expect(storage.getItem(draftKey('session-a'))).toBeNull();
+ });
+
+ it('bounds total durable draft storage by reclaiming the oldest clean session', () => {
+ const Store = loadStore();
+ const storage = new MemoryStorage();
+ const store = new Store({
+ storage,
+ now: () => 1_000,
+ maxPersistedChars: 500,
+ maxTotalPersistedChars: 260,
+ setTimer: () => 1,
+ clearTimer: () => {},
+ });
+ store.set('session-a', { pendingText: 'a'.repeat(40), updatedAt: 100 }, { persist: false });
+ expect(store.persistNow()).toBe(true);
+ store.set('session-b', { pendingText: 'b'.repeat(40), updatedAt: 200 }, { persist: false });
+ expect(store.persistNow()).toBe(true);
+ store.set('session-c', { pendingText: 'c'.repeat(40), updatedAt: 300 }, { persist: false });
+
+ expect(store.persistNow()).toBe(false);
+ expect(storage.getItem(draftKey('session-a'))).toBeNull();
+ expect(persistedDraft(storage, 'session-b')?.pendingText).toBe('b'.repeat(40));
+ expect(persistedDraft(storage, 'session-c')?.pendingText).toBe('c'.repeat(40));
+ expect(store.get('session-a')?.pendingText).toBe('a'.repeat(40));
+ });
+
+ it('retains legacy aggregate data when an oversized draft cannot migrate', () => {
+ const Store = loadStore();
+ const storage = new MemoryStorage();
+ storage.setItem(
+ 'codeman:sessionDrafts',
+ JSON.stringify({
+ version: 1,
+ drafts: {
+ 'session-a': {
+ pendingText: 'x'.repeat(500),
+ flushedText: '',
+ cjkText: '',
+ updatedAt: 100,
+ },
+ },
+ })
+ );
+ const store = new Store({
+ storage,
+ now: () => 200,
+ maxPersistedChars: 100,
+ setTimer: () => 1,
+ clearTimer: () => {},
+ });
+
+ expect(store.persistNow()).toBe(false);
+ expect(storage.getItem('codeman:sessionDrafts')).not.toBeNull();
+ expect(createStore(Store, storage, () => 300).get('session-a')?.pendingText).toBe('x'.repeat(500));
+ });
+});
From f2710db78e6ebe090199f21fa69593b4d5e3d178 Mon Sep 17 00:00:00 2001
From: lior
Date: Wed, 29 Jul 2026 09:13:19 +0300
Subject: [PATCH 3/8] refactor(input): centralize terminal input arbitration
---
src/web/public/terminal-input-controller.js | 1333 +++++++++++++++++++
test/terminal-input-controller.test.ts | 879 ++++++++++++
2 files changed, 2212 insertions(+)
create mode 100644 src/web/public/terminal-input-controller.js
create mode 100644 test/terminal-input-controller.test.ts
diff --git a/src/web/public/terminal-input-controller.js b/src/web/public/terminal-input-controller.js
new file mode 100644
index 00000000..9cf3fd80
--- /dev/null
+++ b/src/web/public/terminal-input-controller.js
@@ -0,0 +1,1333 @@
+/**
+ * @fileoverview Single pre-delivery controller for terminal text input.
+ *
+ * Browser/xterm/CJK/accessory adapters feed semantic input into this class.
+ * It owns transient composition state, alternate-path deduplication, local
+ * echo mutations, helper-textarea lifecycle, and final delivery ordering.
+ * Durable draft persistence and exactly-once transport remain injected ports.
+ *
+ * @globals {TerminalInputController}
+ * @dependency None
+ * @loadorder 5.58 of 16 - loaded after terminal-input-state.js, before app.js
+ */
+
+const TERMINAL_INPUT_SEGMENTER =
+ typeof Intl.Segmenter === 'function' ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null;
+
+function countTerminalInputUnits(text) {
+ const value = String(text || '');
+ if (!value) return 0;
+ if (TERMINAL_INPUT_SEGMENTER) {
+ let count = 0;
+ for (const _segment of TERMINAL_INPUT_SEGMENTER.segment(value)) count += 1;
+ return count;
+ }
+ return Array.from(value.replace(/\r\n/g, '\n')).length;
+}
+
+class TerminalInputController {
+ constructor(options = {}) {
+ this._textarea = options.textarea || null;
+ this._overlay = options.overlay || null;
+ this._getOverlay = typeof options.getOverlay === 'function' ? options.getOverlay : () => this._overlay;
+ this._getSessionId = typeof options.getSessionId === 'function' ? options.getSessionId : () => '';
+ this._getSessionMode = typeof options.getSessionMode === 'function' ? options.getSessionMode : () => '';
+ this._isLocalEchoEnabled =
+ typeof options.isLocalEchoEnabled === 'function' ? options.isLocalEchoEnabled : () => false;
+ this._isRestoringDraft = typeof options.isRestoringDraft === 'function' ? options.isRestoringDraft : () => false;
+ this._isInteractionPrompt =
+ typeof options.isInteractionPrompt === 'function' ? options.isInteractionPrompt : () => false;
+ this._onInteractionControl =
+ typeof options.onInteractionControl === 'function' ? options.onInteractionControl : () => {};
+ this._captureDraft = typeof options.captureDraft === 'function' ? options.captureDraft : () => {};
+ this._setDraft = typeof options.setDraft === 'function' ? options.setDraft : () => {};
+ this._clearDraft = typeof options.clearDraft === 'function' ? options.clearDraft : () => {};
+ this._deliver = typeof options.deliver === 'function' ? options.deliver : () => {};
+ this._preparePaste = typeof options.preparePaste === 'function' ? options.preparePaste : (text) => text;
+ this._trace = typeof options.trace === 'function' ? options.trace : () => {};
+ this._log = typeof options.log === 'function' ? options.log : () => {};
+ this._setTimer =
+ typeof options.setTimer === 'function'
+ ? options.setTimer
+ : (callback, delay) => globalThis.setTimeout(callback, delay);
+ this._clearTimer =
+ typeof options.clearTimer === 'function' ? options.clearTimer : (timer) => globalThis.clearTimeout(timer);
+ this._now = typeof options.now === 'function' ? options.now : () => globalThis.performance?.now?.() ?? Date.now();
+ this._commitTimeoutMs = Number.isFinite(options.commitTimeoutMs) ? options.commitTimeoutMs : 80;
+ this._commitReplayWindowMs = Number.isFinite(options.commitReplayWindowMs) ? options.commitReplayWindowMs : 120;
+ this._onTab = typeof options.onTab === 'function' ? options.onTab : null;
+ this._onTabCancel = typeof options.onTabCancel === 'function' ? options.onTabCancel : () => {};
+
+ this._compositionActive = false;
+ this._compositionPending = false;
+ this._compositionEpoch = 0;
+ this._expectedCommit = null;
+ this._fallbackCommit = null;
+ this._fallbackSessionId = null;
+ this._fallbackCommitEpoch = -1;
+ this._fallbackCommitExpiresAt = -Infinity;
+ this._compositionCommitTimer = null;
+ this._pendingDelivery = '';
+ this._pendingDeliverySessionId = null;
+ this._deliveryFlushTimer = null;
+ this._lastKeystrokeTime = 0;
+ this._compositionInputCommittedEpoch = -1;
+ this._ignoredCompositionEndEpoch = -1;
+ this._helperMutationSnapshot = null;
+ this._keydownEchoTokens = [];
+ this._lastMobileEnterKeydownAt = -Infinity;
+ this._mobileLineBreakPending = false;
+ this._mobileLineBreakFallbackTimer = null;
+ this._textareaListeners = [];
+ this._lastRoutedPaste = '';
+ this._lastRoutedPasteAt = 0;
+ this._lastRoutedPasteSource = '';
+ this._multipartPasteUntil = 0;
+ this._multipartPasteCandidateUntil = 0;
+ this._ptyEditingSessionIds = new Set();
+ this._tabCompletionPending = false;
+ }
+
+ get state() {
+ return {
+ compositionActive: this._compositionActive,
+ compositionPending: this._compositionPending,
+ compositionEpoch: this._compositionEpoch,
+ expectedCommit: this._expectedCommit,
+ fallbackCommit: this._fallbackCommit,
+ fallbackSessionId: this._fallbackSessionId,
+ pendingDelivery: this._pendingDelivery,
+ };
+ }
+
+ beginComposition() {
+ this._cancelTabCompletionTracking();
+ this._clearMobileLineBreakTimer();
+ this._mobileLineBreakPending = false;
+ this._compositionActive = true;
+ this._compositionEpoch += 1;
+ this._compositionInputCommittedEpoch = -1;
+ this._helperMutationSnapshot = null;
+ this._compositionPending = false;
+ this._expectedCommit = null;
+ this.clearCompositionDelivery();
+ this._clearCompositionTimer();
+ const overlay = this._getOverlay();
+ if (this._usesLocalOverlay()) {
+ overlay?.setCompositionText?.('');
+ this._captureDraft();
+ }
+ this._trace('compositionstart', {
+ epoch: this._compositionEpoch,
+ helperLen: this._textarea?.value?.length || 0,
+ pendingLen: overlay?.pendingText?.length || 0,
+ local: this._usesLocalOverlay(),
+ });
+ }
+
+ updateComposition(text) {
+ if (!this._usesLocalOverlay()) return;
+ this._getOverlay()?.setCompositionText?.(typeof text === 'string' ? text : '');
+ this._captureDraft();
+ }
+
+ endComposition(text) {
+ const belongsToKnownComposition =
+ this._compositionActive ||
+ this._compositionInputCommittedEpoch === this._compositionEpoch ||
+ this._ignoredCompositionEndEpoch === this._compositionEpoch ||
+ this._mobileLineBreakPending;
+ if (!belongsToKnownComposition) {
+ this._trace('compositionend-orphan-drop', {
+ epoch: this._compositionEpoch,
+ dataLen: typeof text === 'string' ? text.length : 0,
+ });
+ this._resetHelperTextarea();
+ return;
+ }
+ this._compositionActive = false;
+ const overlay = this._getOverlay();
+ const fallbackText = (typeof text === 'string' && text) || overlay?.compositionText || '';
+ this._expectedCommit = this._usesLocalOverlay() && fallbackText ? fallbackText : null;
+ this._compositionPending = true;
+ this._clearCompositionTimer();
+ this._trace('compositionend', {
+ epoch: this._compositionEpoch,
+ dataLen: typeof text === 'string' ? text.length : 0,
+ helperLen: this._textarea?.value?.length || 0,
+ pendingLen: overlay?.pendingText?.length || 0,
+ local: this._usesLocalOverlay(),
+ });
+ if (this._ignoredCompositionEndEpoch === this._compositionEpoch) {
+ this._ignoredCompositionEndEpoch = -1;
+ this._compositionPending = false;
+ this._expectedCommit = null;
+ return;
+ }
+ if (this._compositionInputCommittedEpoch === this._compositionEpoch) {
+ this._compositionInputCommittedEpoch = -1;
+ this._compositionPending = false;
+ this._expectedCommit = null;
+ this._clearCompositionTimer();
+ return;
+ }
+
+ const endedEpoch = this._compositionEpoch;
+ if (!this._usesLocalOverlay()) {
+ this._compositionCommitTimer = this._setTimer(() => {
+ this._compositionCommitTimer = null;
+ if (endedEpoch === this._compositionEpoch) {
+ this._compositionPending = false;
+ }
+ }, this._commitTimeoutMs);
+ return;
+ }
+
+ if (this._mobileLineBreakPending) {
+ this._mobileLineBreakPending = false;
+ this._clearMobileLineBreakTimer();
+ this.insertDraftLineBreak(fallbackText);
+ return;
+ }
+
+ this._compositionCommitTimer = this._setTimer(() => {
+ this._compositionCommitTimer = null;
+ if (!this._compositionPending || endedEpoch !== this._compositionEpoch) {
+ return;
+ }
+ this.commitCompositionFallback(fallbackText);
+ }, this._commitTimeoutMs);
+ }
+
+ setCompositionPending(value, expectedText = null) {
+ this._compositionPending = value === true;
+ this._expectedCommit =
+ this._compositionPending && typeof expectedText === 'string' && expectedText ? expectedText : null;
+ if (!this._compositionPending) this._clearCompositionTimer();
+ }
+
+ commitCompositionFallback(text) {
+ if (!this._compositionPending) return false;
+ const overlay = this._getOverlay();
+ const finalText = (typeof text === 'string' && text) || overlay?.compositionText || '';
+ if (!finalText) {
+ this._compositionPending = false;
+ this._expectedCommit = null;
+ overlay?.clearComposition?.();
+ this._resetHelperTextarea();
+ this._captureDraft();
+ return false;
+ }
+ this._acceptComposition(finalText);
+ return true;
+ }
+
+ handleTerminalData(data, source = null) {
+ const sessionId = this._getSessionId();
+ if (!sessionId || !data) return false;
+ if (data !== '\t') this._cancelTabCompletionTracking();
+ this._expireCompositionDelivery();
+ const actualSource = source || 'xterm';
+ if (actualSource === 'xterm') {
+ this._markKeydownEchoDelivered(sessionId, data);
+ }
+ const localEcho = this._usesLocalOverlay(sessionId);
+ const firstCode = data.charCodeAt(0);
+ const isCompositionText = data !== '\x7f' && firstCode >= 32;
+ const inputKind = this._classifyData(data);
+
+ this._trace('ondata', {
+ source: actualSource,
+ kind: inputKind,
+ len: data.length,
+ local: localEcho,
+ pendingLen: this._getOverlay()?.pendingText?.length || 0,
+ markerLen: this._fallbackCommit?.length || 0,
+ markerEq: data === this._fallbackCommit,
+ compositionPending: this._compositionPending,
+ expectedLen: this._expectedCommit?.length || 0,
+ });
+
+ if (this._fallbackCommit !== null && this._fallbackSessionId !== sessionId) {
+ this._trace('marker-clear', { reason: 'session' });
+ this.clearCompositionDelivery();
+ }
+
+ if (localEcho && this._compositionPending && data === '\x7f') {
+ this._compositionPending = false;
+ this._compositionActive = false;
+ this._expectedCommit = null;
+ this._clearCompositionTimer();
+ this._getOverlay()?.clearComposition?.();
+ this.clearCompositionDelivery();
+ this._resetHelperTextarea();
+ this._trace('composition-cancel', { reason: 'delete' });
+ }
+
+ if (localEcho && this._compositionActive && isCompositionText) {
+ this._trace('composition-interim-drop', {
+ source: actualSource,
+ len: data.length,
+ });
+ return true;
+ }
+
+ const fallbackCommit = this._fallbackCommit;
+ if (fallbackCommit !== null && fallbackCommit.startsWith(data)) {
+ this._trace('marker-drop', {
+ reason: data.length === fallbackCommit.length ? 'match' : 'prefix',
+ len: data.length,
+ });
+ const remaining = fallbackCommit.slice(data.length);
+ if (remaining) {
+ this._fallbackCommit = remaining;
+ } else {
+ this.clearCompositionDelivery();
+ }
+ return true;
+ }
+
+ if (fallbackCommit !== null) {
+ const acceptsComposition =
+ data.length === 1 && data !== '\x7f' && (/\s/.test(data) || (firstCode >= 32 && !/[\p{L}\p{N}]/u.test(data)));
+ if (!acceptsComposition) {
+ this._trace('marker-clear', {
+ reason: 'substantive',
+ len: data.length,
+ kind: inputKind,
+ });
+ this.clearCompositionDelivery();
+ } else {
+ this._trace('marker-keep', {
+ reason: 'boundary',
+ kind: inputKind,
+ });
+ }
+ }
+
+ if (!localEcho && this._compositionPending && isCompositionText) {
+ this._compositionPending = false;
+ this._clearCompositionTimer();
+ this._rememberCompositionDelivery(data);
+ this._resetHelperTextarea();
+ this._trace('marker-remember', {
+ source: 'shell',
+ len: data.length,
+ });
+ }
+
+ if (localEcho) {
+ return this._handleLocalEchoData(data, isCompositionText, actualSource, sessionId);
+ }
+
+ this._queueNormalDelivery(data, sessionId);
+ this._finishPtyEditingIfNeeded(sessionId, data);
+ return true;
+ }
+
+ sendControl(data) {
+ const sessionId = this._getSessionId();
+ if (!data || !sessionId) return;
+ if (data !== '\t') this._cancelTabCompletionTracking();
+ if (this._usesLocalOverlay(sessionId)) {
+ const overlay = this._getOverlay();
+ const compositionText = overlay?.compositionText || '';
+ if (compositionText) {
+ this.setCompositionPending(true, compositionText);
+ this.commitCompositionFallback(compositionText);
+ }
+ this.handleTerminalData(data, 'terminal-control');
+ return;
+ }
+ this._flushDelivery();
+ this._deliver(sessionId, data);
+ this._finishPtyEditingIfNeeded(sessionId, data);
+ }
+
+ insertText(text) {
+ const sessionId = this._getSessionId();
+ if (!sessionId || !text) return;
+ this._cancelTabCompletionTracking();
+ this.clearCompositionDelivery();
+ if (this._usesLocalOverlay(sessionId)) {
+ this._getOverlay()?.appendText?.(text);
+ this._captureDraft();
+ return;
+ }
+ this._flushDelivery();
+ this._deliverDraftText(sessionId, text);
+ }
+
+ sendExternalText(text, options = {}) {
+ const sessionId = this._getSessionId();
+ if (!sessionId || !text) return;
+ this._cancelTabCompletionTracking();
+ this._flushDelivery();
+ this.clearCompositionDelivery();
+ if (this._usesLocalOverlay(sessionId)) {
+ const overlay = this._getOverlay();
+ const compositionText = overlay?.compositionText || '';
+ if (compositionText) {
+ this.setCompositionPending(true, compositionText);
+ this.commitCompositionFallback(compositionText);
+ }
+ const pendingText = overlay?.pendingText || '';
+ const flushed = overlay?.getFlushed?.() || { count: 0, text: '' };
+ const deliveredText = pendingText + text;
+ const flushedText = flushed.text + deliveredText;
+ overlay?.clear?.();
+ overlay?.setFlushed?.(flushedText.length, flushedText);
+ overlay?.suppressBufferDetection?.();
+ this._setDraft(sessionId, {
+ pendingText: '',
+ flushedText,
+ cjkText: '',
+ updatedAt: Date.now(),
+ });
+ this._deliverDraftText(sessionId, deliveredText, {
+ useMux: options.useMux !== false,
+ });
+ this._resetHelperTextarea();
+ return;
+ }
+ this._deliverDraftText(sessionId, text, {
+ useMux: options.useMux !== false,
+ });
+ }
+
+ sendCommand(command) {
+ if (!command || !this._getSessionId()) return;
+ this.insertText(command);
+ this.sendControl('\r');
+ }
+
+ sendPaste(text, options = {}) {
+ const sessionId = this._getSessionId();
+ if (!sessionId || !text) return;
+ this._cancelTabCompletionTracking();
+ const rawText = String(text);
+ if (this._usesLocalOverlay(sessionId) && !options.submit) {
+ this.insertText(rawText);
+ return;
+ }
+
+ this.clearCompositionDelivery();
+ let pasteText = rawText;
+ if (this._usesLocalOverlay(sessionId)) {
+ const overlay = this._getOverlay();
+ const compositionText = overlay?.compositionText || '';
+ if (compositionText) {
+ this.setCompositionPending(true, compositionText);
+ this.commitCompositionFallback(compositionText);
+ }
+ pasteText = (overlay?.pendingText || '') + rawText;
+ overlay?.clear?.();
+ overlay?.suppressBufferDetection?.();
+ this._clearDraft(sessionId);
+ this._resetHelperTextarea();
+ }
+
+ const mode = this._getSessionMode();
+ const input = this._preparePaste(pasteText, mode !== 'shell');
+ this._flushDelivery();
+ this._deliver(sessionId, input, { useMux: false });
+ if (options.submit) {
+ this._deliver(sessionId, '\r', { useMux: false });
+ this._finishPtyEditingIfNeeded(sessionId, '\r');
+ }
+ }
+
+ sendModifiedEnter(keyName) {
+ const sessionId = this._getSessionId();
+ if (!sessionId || (keyName !== 'C-Enter' && keyName !== 'S-Enter')) return;
+ this._cancelTabCompletionTracking();
+ const lineFeed = '\n';
+ const overlay = this._getOverlay();
+ if (!this._usesLocalOverlay(sessionId)) {
+ this._flushDelivery();
+ this._deliver(sessionId, lineFeed);
+ return;
+ }
+
+ const compositionText = overlay?.compositionText || '';
+ if (compositionText) {
+ this.setCompositionPending(true, compositionText);
+ this.commitCompositionFallback(compositionText);
+ }
+ const text = overlay?.pendingText || '';
+ const flushed = overlay?.getFlushed?.() || { count: 0, text: '' };
+ const flushedText = flushed.text + text + lineFeed;
+ overlay?.clear?.();
+ overlay?.setFlushed?.(flushedText.length, flushedText);
+ overlay?.suppressBufferDetection?.();
+ this._setDraft(sessionId, {
+ pendingText: '',
+ flushedText,
+ cjkText: '',
+ updatedAt: Date.now(),
+ });
+ if (text) this._deliverDraftText(sessionId, text);
+ this._deliver(sessionId, lineFeed);
+ this._resetHelperTextarea();
+ }
+
+ insertDraftLineBreak(fallbackText = '') {
+ const sessionId = this._getSessionId();
+ if (!sessionId) return;
+ const overlay = this._getOverlay();
+ if (!this._usesLocalOverlay(sessionId)) {
+ this._resetHelperTextarea();
+ this.sendControl('\r');
+ return;
+ }
+ const compositionText = fallbackText || overlay?.compositionText || '';
+ if (compositionText) {
+ this.setCompositionPending(true, compositionText);
+ this.commitCompositionFallback(compositionText);
+ } else {
+ overlay?.clearComposition?.();
+ }
+ overlay?.appendText?.('\n');
+ this._captureDraft();
+ this._resetHelperTextarea();
+ }
+
+ clearInput() {
+ const sessionId = this._getSessionId();
+ if (!sessionId) return;
+ this._cancelTabCompletionTracking();
+ this.clearDeliveryBuffer();
+ this._resetCompositionState();
+
+ const overlay = this._getOverlay();
+ if (this._usesLocalOverlay(sessionId) && overlay) {
+ const flushed = overlay.getFlushed?.() || {
+ count: 0,
+ text: '',
+ };
+ overlay.clear?.();
+ overlay.suppressBufferDetection?.();
+ this._clearDraft(sessionId);
+ const deleteCount = flushed.text ? countTerminalInputUnits(flushed.text) : flushed.count;
+ if (deleteCount > 0) {
+ this._deliver(sessionId, '\x7f'.repeat(deleteCount), { useMux: false });
+ }
+ } else {
+ this._deliver(sessionId, '\x15', { useMux: false });
+ this._finishPtyEditingIfNeeded(sessionId, '\x15');
+ }
+ this._resetHelperTextarea();
+ }
+
+ flushDraftText(text, sessionId = this._getSessionId()) {
+ if (!sessionId || !text) return false;
+ this._flushDelivery();
+ this._deliverDraftText(sessionId, text);
+ return true;
+ }
+
+ forgetSession(sessionId) {
+ if (sessionId) this._ptyEditingSessionIds.delete(sessionId);
+ }
+
+ isPtyEditing(sessionId = this._getSessionId()) {
+ return this._isPtyEditing(sessionId);
+ }
+
+ restorePtyEditing(sessionId, ptyOwned) {
+ if (!sessionId) return;
+ if (ptyOwned === true) {
+ this._ptyEditingSessionIds.add(sessionId);
+ } else {
+ this._ptyEditingSessionIds.delete(sessionId);
+ }
+ }
+
+ clearPtyEditingSessions() {
+ this._ptyEditingSessionIds.clear();
+ }
+
+ clearDeliveryBuffer() {
+ this._pendingDelivery = '';
+ this._pendingDeliverySessionId = null;
+ if (this._deliveryFlushTimer !== null) {
+ this._clearTimer(this._deliveryFlushTimer);
+ this._deliveryFlushTimer = null;
+ }
+ }
+
+ clearCompositionDelivery() {
+ this._fallbackCommit = null;
+ this._fallbackSessionId = null;
+ this._fallbackCommitEpoch = -1;
+ this._fallbackCommitExpiresAt = -Infinity;
+ }
+
+ reset(options = {}) {
+ this._cancelTabCompletionTracking();
+ if (options.flushDelivery !== false) {
+ this._cancelDeliveryFlush();
+ this._flushDelivery();
+ } else {
+ this.clearDeliveryBuffer();
+ }
+ this._resetCompositionState();
+ this._lastRoutedPaste = '';
+ this._lastRoutedPasteAt = 0;
+ this._lastRoutedPasteSource = '';
+ this._multipartPasteUntil = 0;
+ this._multipartPasteCandidateUntil = 0;
+ this._resetHelperTextarea();
+ }
+
+ attachTextarea(container, options = {}) {
+ if (!this._textarea || !container || this._textareaListeners.length) {
+ return false;
+ }
+ const mobile = options.mobile === true;
+ const on = (target, type, handler, listenerOptions) => {
+ target.addEventListener(type, handler, listenerOptions);
+ this._textareaListeners.push({
+ target,
+ type,
+ handler,
+ listenerOptions,
+ });
+ };
+
+ this._attachPasteListeners(on, container, {
+ segmentedFallback: mobile,
+ });
+ if (mobile) {
+ this._attachMobileCompositionListeners(on, container);
+ }
+ return true;
+ }
+
+ detachTextarea() {
+ for (const { target, type, handler, listenerOptions } of this._textareaListeners) {
+ target.removeEventListener(type, handler, listenerOptions);
+ }
+ this._textareaListeners = [];
+ this._clearMobileLineBreakTimer();
+ }
+
+ destroy() {
+ this.detachTextarea();
+ this.reset();
+ this._ptyEditingSessionIds.clear();
+ }
+
+ _attachMobileCompositionListeners(on, container) {
+ const textarea = this._textarea;
+
+ on(
+ container,
+ 'compositionstart',
+ (event) => {
+ if (event.target !== textarea) return;
+ this.beginComposition();
+ },
+ true
+ );
+ on(
+ container,
+ 'compositionupdate',
+ (event) => {
+ if (event.target !== textarea) return;
+ this.updateComposition(event.data || '');
+ },
+ true
+ );
+ on(
+ container,
+ 'compositionend',
+ (event) => {
+ if (event.target !== textarea) return;
+ this.endComposition(event.data || '');
+ },
+ true
+ );
+ on(
+ container,
+ 'keydown',
+ (event) => {
+ if (event.target !== textarea) return;
+ if (event.key === 'Enter') {
+ this._lastMobileEnterKeydownAt = this._now();
+ }
+ if (!event.isComposing && event.keyCode === 229) {
+ this._helperMutationSnapshot = this._captureHelperMutationSnapshot();
+ this._keydownEchoTokens = [];
+ return;
+ }
+ if (!event.isComposing) {
+ this._registerKeydownEchoCandidate(event);
+ }
+ },
+ true
+ );
+ on(
+ container,
+ 'beforeinput',
+ (event) => {
+ if (event.target !== textarea) return;
+ if (!event.isComposing && (event.inputType === 'insertText' || event.inputType === 'insertReplacementText')) {
+ this._helperMutationSnapshot = this._captureHelperMutationSnapshot();
+ }
+
+ const isLineBreak = event.inputType === 'insertLineBreak' || event.inputType === 'insertParagraph';
+ const followsMobileEnter = isLineBreak && this._now() - this._lastMobileEnterKeydownAt < 500;
+ if (followsMobileEnter && this._getSessionId()) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ this._lastMobileEnterKeydownAt = -Infinity;
+ if (!this._usesLocalOverlay()) {
+ this.insertDraftLineBreak();
+ return;
+ }
+ if (this._compositionActive || event.isComposing) {
+ this._mobileLineBreakPending = true;
+ const lineBreakEpoch = this._compositionEpoch;
+ const fallbackText = this._getOverlay()?.compositionText || '';
+ this._clearMobileLineBreakTimer();
+ this._mobileLineBreakFallbackTimer = this._setTimer(() => {
+ this._mobileLineBreakFallbackTimer = null;
+ if (!this._mobileLineBreakPending || lineBreakEpoch !== this._compositionEpoch) {
+ return;
+ }
+ this._mobileLineBreakPending = false;
+ this._ignoredCompositionEndEpoch = lineBreakEpoch;
+ this._compositionActive = false;
+ this.insertDraftLineBreak(fallbackText);
+ }, this._commitTimeoutMs);
+ } else {
+ this.insertDraftLineBreak();
+ }
+ return;
+ }
+
+ if (
+ this._compositionActive ||
+ event.isComposing ||
+ !this._getSessionId() ||
+ (event.inputType !== 'deleteContentBackward' && event.inputType !== 'deleteWordBackward')
+ ) {
+ return;
+ }
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ this._resetHelperTextarea();
+ if (this._consumeKeydownEcho(this._getSessionId(), '\x7f')) {
+ return;
+ }
+ this.handleTerminalData('\x7f', 'beforeinput-delete');
+ },
+ true
+ );
+ on(
+ container,
+ 'input',
+ (event) => {
+ if (
+ event.target !== textarea ||
+ (event.inputType !== 'insertText' && event.inputType !== 'insertReplacementText')
+ ) {
+ return;
+ }
+
+ // This controller is the single owner of helper-textarea mutations.
+ // The container capture runs before xterm's textarea capture listener.
+ event.stopImmediatePropagation();
+ this._trace('input-capture', {
+ type: event.inputType || 'none',
+ eventComp: !!event.isComposing,
+ stateComp: this._compositionActive,
+ eventLen: typeof event.data === 'string' ? event.data.length : -1,
+ helperLen: textarea.value.length,
+ snapshotLen: this._helperMutationSnapshot?.value?.length ?? -1,
+ pendingLen: this._getOverlay()?.pendingText?.length || 0,
+ markerLen: this._fallbackCommit?.length || 0,
+ });
+ if (event.isComposing) return;
+
+ const snapshot = this._helperMutationSnapshot;
+ this._helperMutationSnapshot = null;
+ const mutation = this._deriveTextareaMutation(snapshot, textarea.value);
+ const eventData = typeof event.data === 'string' ? event.data : '';
+ const data = snapshot
+ ? mutation.insertedText || eventData
+ : eventData || mutation.insertedText || textarea.value;
+ this._trace('input-route', {
+ eventLen: eventData.length,
+ insertedLen: mutation.insertedText.length,
+ removedLen: mutation.removedText.length,
+ routeLen: data.length,
+ markerLen: this._fallbackCommit?.length || 0,
+ markerEq: data === this._fallbackCommit,
+ pendingLen: this._getOverlay()?.pendingText?.length || 0,
+ });
+ if (!data && !mutation.removedText) return;
+
+ const finalizingComposition = this._compositionActive;
+ if (finalizingComposition) {
+ this._compositionActive = false;
+ this._compositionInputCommittedEpoch = this._compositionEpoch;
+ this._compositionPending = true;
+ this._expectedCommit = this._getOverlay()?.compositionText || null;
+ this._clearCompositionTimer();
+ }
+
+ if (
+ !finalizingComposition &&
+ event.inputType === 'insertText' &&
+ this._consumeKeydownEcho(this._getSessionId(), data)
+ ) {
+ this._resetHelperTextarea();
+ return;
+ }
+ if (!finalizingComposition && event.inputType !== 'insertText') {
+ this._keydownEchoTokens = [];
+ }
+
+ const pendingText = this._getOverlay()?.pendingText || '';
+ const shouldApplyReplacement =
+ mutation.removedText && (!this._usesLocalOverlay() || pendingText.endsWith(mutation.removedText));
+
+ this._resetHelperTextarea();
+ if (shouldApplyReplacement) {
+ const deleteCount = countTerminalInputUnits(mutation.removedText);
+ for (let index = 0; index < deleteCount; index += 1) {
+ this.handleTerminalData('\x7f', 'capture-replacement');
+ }
+ }
+ if (data) {
+ this.handleTerminalData(data, 'capture-input');
+ }
+ },
+ true
+ );
+ }
+
+ _attachPasteListeners(on, container, options) {
+ const textarea = this._textarea;
+ const multipartGapMs = 40;
+ const readPasteText = (event) => {
+ const candidates = [
+ event.clipboardData?.getData?.('text/plain'),
+ event.dataTransfer?.getData?.('text/plain'),
+ typeof event.data === 'string' ? event.data : '',
+ textarea.value,
+ ];
+ return candidates.reduce(
+ (longest, value) => (typeof value === 'string' && value.length > longest.length ? value : longest),
+ ''
+ );
+ };
+ const routeTextPaste = (event, source, explicitText) => {
+ if (event.target !== textarea) return false;
+ const sessionId = this._getSessionId();
+ if (!sessionId || this._getSessionMode() === 'shell') {
+ return false;
+ }
+ const text = explicitText ?? readPasteText(event);
+ if (!text) return false;
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ this._resetHelperTextarea();
+ const now = this._now();
+ const duplicateInputAfterCapture =
+ text === this._lastRoutedPaste &&
+ now - this._lastRoutedPasteAt < 100 &&
+ source.startsWith('input:') &&
+ (this._lastRoutedPasteSource === 'clipboard' || this._lastRoutedPasteSource.startsWith('beforeinput:'));
+ if (duplicateInputAfterCapture) return true;
+
+ this._lastRoutedPaste = text;
+ this._lastRoutedPasteAt = now;
+ this._lastRoutedPasteSource = source;
+ const lineBreaks = text.match(/\r\n|\r|\n/g)?.length || 0;
+ this._log(`XTERM_PASTE source=${source} len=${text.length} breaks=${lineBreaks}`);
+ if (this._usesLocalOverlay()) {
+ this.insertText(text);
+ } else {
+ this.sendPaste(text);
+ }
+ return true;
+ };
+ const routeInputPasteMutation = (event, phase) => {
+ if (event.target !== textarea) return;
+ const inputType = event.inputType || '';
+ const mutationText = typeof event.data === 'string' ? event.data : '';
+ const now = this._now();
+ const continuesMultipartPaste = now <= this._multipartPasteUntil;
+
+ if (inputType === 'insertFromPaste') {
+ if (routeTextPaste(event, `${phase}:paste`)) {
+ this._multipartPasteCandidateUntil = 0;
+ this._multipartPasteUntil = now + multipartGapMs;
+ }
+ return;
+ }
+
+ if (options.segmentedFallback && inputType === 'insertText' && mutationText) {
+ if (!continuesMultipartPaste) {
+ this._multipartPasteCandidateUntil = mutationText.length > 1 ? now + multipartGapMs : 0;
+ return;
+ }
+ if (routeTextPaste(event, `${phase}:text`, mutationText)) {
+ this._multipartPasteUntil = now + multipartGapMs;
+ }
+ return;
+ }
+ if (
+ options.segmentedFallback &&
+ (continuesMultipartPaste || now <= this._multipartPasteCandidateUntil) &&
+ (inputType === 'insertLineBreak' || inputType === 'insertParagraph')
+ ) {
+ if (routeTextPaste(event, `${phase}:break`, '\n')) {
+ this._multipartPasteCandidateUntil = 0;
+ this._multipartPasteUntil = now + multipartGapMs;
+ }
+ return;
+ }
+ this._multipartPasteCandidateUntil = 0;
+ };
+
+ on(
+ container,
+ 'paste',
+ (event) => {
+ routeTextPaste(event, 'clipboard');
+ },
+ true
+ );
+ on(
+ container,
+ 'beforeinput',
+ (event) => {
+ routeInputPasteMutation(event, 'beforeinput');
+ },
+ true
+ );
+ on(
+ container,
+ 'input',
+ (event) => {
+ routeInputPasteMutation(event, 'input');
+ },
+ true
+ );
+ }
+
+ _captureHelperMutationSnapshot() {
+ const value = this._textarea?.value || '';
+ const start = this._textarea?.selectionStart ?? value.length;
+ return {
+ value,
+ start,
+ end: this._textarea?.selectionEnd ?? this._textarea?.selectionStart ?? value.length,
+ };
+ }
+
+ _deriveTextareaMutation(snapshot, currentValue) {
+ const current = String(currentValue ?? '');
+ if (!snapshot) {
+ return {
+ insertedText: current,
+ removedText: '',
+ };
+ }
+ const previous = String(snapshot.value ?? '');
+ const start = Math.max(0, Math.min(previous.length, snapshot.start ?? previous.length));
+ const end = Math.max(start, Math.min(previous.length, snapshot.end ?? start));
+ const prefix = previous.slice(0, start);
+ const suffix = previous.slice(end);
+ if (current.startsWith(prefix) && current.endsWith(suffix) && current.length >= prefix.length + suffix.length) {
+ return {
+ insertedText: current.slice(prefix.length, current.length - suffix.length),
+ removedText: previous.slice(start, end),
+ };
+ }
+
+ let commonPrefix = 0;
+ while (
+ commonPrefix < previous.length &&
+ commonPrefix < current.length &&
+ previous[commonPrefix] === current[commonPrefix]
+ ) {
+ commonPrefix += 1;
+ }
+ let commonSuffix = 0;
+ while (
+ commonSuffix < previous.length - commonPrefix &&
+ commonSuffix < current.length - commonPrefix &&
+ previous[previous.length - 1 - commonSuffix] === current[current.length - 1 - commonSuffix]
+ ) {
+ commonSuffix += 1;
+ }
+ return {
+ insertedText: current.slice(commonPrefix, current.length - commonSuffix),
+ removedText: previous.slice(commonPrefix, previous.length - commonSuffix),
+ };
+ }
+
+ _handleLocalEchoData(data, isCompositionText, source, sessionId) {
+ const overlay = this._getOverlay();
+
+ if (this._isInteractionPrompt(sessionId) && data !== '\x7f' && data.charCodeAt(0) < 32) {
+ this._deliver(sessionId, data);
+ this._onInteractionControl(sessionId, data);
+ this._resetHelperTextarea();
+ return true;
+ }
+
+ if (this._compositionPending && isCompositionText) {
+ const expectedCommit = this._expectedCommit;
+ const hasStaleHelperPrefix =
+ expectedCommit && data.length > expectedCommit.length && data.endsWith(expectedCommit);
+ const finalText = hasStaleHelperPrefix ? expectedCommit : data;
+ if (hasStaleHelperPrefix) {
+ this._trace('composition-strip-stale-prefix', {
+ payloadLen: data.length,
+ finalLen: finalText.length,
+ });
+ }
+ this._trace('composition-accept', {
+ source,
+ len: finalText.length,
+ });
+ this._acceptComposition(finalText);
+ return true;
+ }
+
+ if (data === '\x7f') {
+ const removedFrom = overlay?.removeChar?.();
+ if (removedFrom !== 'pending') {
+ this._deliver(sessionId, data);
+ }
+ this._captureDraft();
+ return true;
+ }
+
+ if (/^[\r\n]+$/.test(data)) {
+ const text = overlay?.pendingText || '';
+ if (text) {
+ const lineBreaks = text.match(/\r\n|\r|\n/g)?.length || 0;
+ this._log(`LOCAL_ECHO_SUBMIT len=${text.length} breaks=${lineBreaks}`);
+ }
+ overlay?.clear?.();
+ overlay?.suppressBufferDetection?.();
+ this._clearDraft(sessionId);
+ this.clearDeliveryBuffer();
+ if (text) this._deliverDraftText(sessionId, text);
+ this._deliver(sessionId, '\r');
+ this._resetHelperTextarea();
+ return true;
+ }
+
+ if (data.length > 1 && data.charCodeAt(0) >= 32) {
+ overlay?.appendText?.(data);
+ this._captureDraft();
+ return true;
+ }
+
+ if (data.charCodeAt(0) < 32) {
+ if (data.length > 1 && data.charCodeAt(0) === 27) {
+ this._handoffOverlayToPty(sessionId, overlay);
+ this._deliver(sessionId, data);
+ return true;
+ }
+ if (this._isRestoringDraft()) {
+ this._deliver(sessionId, data);
+ return true;
+ }
+ if (data === '\t' && this._onTab) {
+ const text = overlay?.pendingText || '';
+ const flushed = overlay?.getFlushed?.() || { count: 0, text: '' };
+ const flushedText = flushed.text + text;
+ const handled =
+ this._onTab({
+ controller: this,
+ overlay,
+ sessionId,
+ text,
+ flushedText,
+ }) !== false;
+ if (handled) {
+ this._tabCompletionPending = true;
+ overlay?.clear?.();
+ if (flushedText) {
+ overlay?.setFlushed?.(flushedText.length, flushedText);
+ this._setDraft(sessionId, {
+ pendingText: '',
+ flushedText,
+ cjkText: '',
+ updatedAt: Date.now(),
+ });
+ } else {
+ this._clearDraft(sessionId);
+ }
+ overlay?.suppressBufferDetection?.();
+ if (text) this._deliverDraftText(sessionId, text);
+ this._deliver(sessionId, data);
+ this._resetHelperTextarea();
+ }
+ return handled;
+ }
+ if (data === '\x1b') {
+ this._deliver(sessionId, data);
+ this._resetHelperTextarea();
+ return true;
+ }
+ const text = overlay?.pendingText || '';
+ this._handoffOverlayToPty(sessionId, overlay);
+ if (text) this._trace('pty-editing-handoff', { reason: 'control', len: text.length });
+ this._deliver(sessionId, data);
+ this._finishPtyEditingIfNeeded(sessionId, data);
+ this._resetHelperTextarea();
+ return true;
+ }
+
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
+ overlay?.addChar?.(data);
+ this._captureDraft();
+ return true;
+ }
+
+ return false;
+ }
+
+ _usesLocalOverlay(sessionId = this._getSessionId()) {
+ return !!this._isLocalEchoEnabled() && !this._isPtyEditing(sessionId);
+ }
+
+ _isPtyEditing(sessionId) {
+ return !!sessionId && this._ptyEditingSessionIds.has(sessionId);
+ }
+
+ _deliverDraftText(sessionId, text, options = {}) {
+ const rawText = String(text || '');
+ if (!sessionId || !rawText) return;
+ const shouldFrame = /[\r\n]/.test(rawText) && this._getSessionMode() !== 'shell';
+ const input = shouldFrame ? this._preparePaste(rawText, true) : rawText;
+ const deliveryOptions = { ...options };
+ if (shouldFrame) deliveryOptions.useMux = false;
+ if (Object.prototype.hasOwnProperty.call(deliveryOptions, 'useMux')) {
+ this._deliver(sessionId, input, deliveryOptions);
+ } else {
+ this._deliver(sessionId, input);
+ }
+ }
+
+ _handoffOverlayToPty(sessionId, overlay = this._getOverlay()) {
+ if (!sessionId) return;
+ const text = overlay?.pendingText || '';
+ if (text) this._deliverDraftText(sessionId, text);
+ overlay?.clear?.();
+ overlay?.suppressBufferDetection?.();
+ this._setDraft(sessionId, {
+ pendingText: '',
+ flushedText: '',
+ cjkText: '',
+ ptyOwned: true,
+ updatedAt: Date.now(),
+ });
+ this._ptyEditingSessionIds.add(sessionId);
+ this._resetHelperTextarea();
+ }
+
+ _finishPtyEditingIfNeeded(sessionId, data) {
+ if (!this._isPtyEditing(sessionId)) return;
+ if (/^[\r\n]+$/.test(data) || data === '\x03' || data === '\x15') {
+ this._ptyEditingSessionIds.delete(sessionId);
+ this._clearDraft(sessionId);
+ }
+ }
+
+ resolveTabCompletion() {
+ this._tabCompletionPending = false;
+ }
+
+ _cancelTabCompletionTracking(handoffToPty = true) {
+ if (!this._tabCompletionPending) return;
+ this._tabCompletionPending = false;
+ if (handoffToPty) {
+ const sessionId = this._getSessionId();
+ if (this._usesLocalOverlay(sessionId)) {
+ this._handoffOverlayToPty(sessionId);
+ }
+ }
+ this._onTabCancel();
+ }
+
+ _registerKeydownEchoCandidate(event) {
+ const sessionId = this._getSessionId();
+ if (!sessionId) return;
+ let data = '';
+ if (event.key === 'Backspace') {
+ data = '\x7f';
+ } else if (
+ typeof event.key === 'string' &&
+ event.key.length === 1 &&
+ !event.ctrlKey &&
+ !event.altKey &&
+ !event.metaKey
+ ) {
+ data = event.key;
+ }
+ if (!data) return;
+ this._keydownEchoTokens.push({
+ sessionId,
+ data,
+ delivered: false,
+ });
+ if (this._keydownEchoTokens.length > 32) {
+ this._keydownEchoTokens.splice(0, this._keydownEchoTokens.length - 32);
+ }
+ }
+
+ _markKeydownEchoDelivered(sessionId, data) {
+ const token = this._keydownEchoTokens.find(
+ (candidate) => !candidate.delivered && candidate.sessionId === sessionId && candidate.data === data
+ );
+ if (token) token.delivered = true;
+ }
+
+ _consumeKeydownEcho(sessionId, data) {
+ const index = this._keydownEchoTokens.findIndex(
+ (candidate) => candidate.sessionId === sessionId && candidate.data === data
+ );
+ if (index < 0) {
+ this._keydownEchoTokens = [];
+ return false;
+ }
+ const [token] = this._keydownEchoTokens.splice(index, 1);
+ return token.delivered;
+ }
+
+ _queueNormalDelivery(data, sessionId) {
+ if (this._pendingDeliverySessionId && this._pendingDeliverySessionId !== sessionId) {
+ this._flushDelivery();
+ }
+ this._pendingDeliverySessionId = sessionId;
+ this._pendingDelivery += data;
+
+ if (data.charCodeAt(0) < 32 || data.length > 1) {
+ this._cancelDeliveryFlush();
+ this._flushDelivery();
+ return;
+ }
+
+ const now = this._now();
+ if (now - this._lastKeystrokeTime > 50) {
+ this._cancelDeliveryFlush();
+ this._lastKeystrokeTime = now;
+ this._flushDelivery();
+ return;
+ }
+
+ this._lastKeystrokeTime = now;
+ if (this._deliveryFlushTimer === null) {
+ this._deliveryFlushTimer = this._setTimer(() => {
+ this._deliveryFlushTimer = null;
+ this._flushDelivery();
+ }, 0);
+ }
+ }
+
+ _flushDelivery() {
+ if (!this._pendingDelivery) return;
+ const sessionId = this._pendingDeliverySessionId || this._getSessionId();
+ const data = this._pendingDelivery;
+ this._pendingDelivery = '';
+ this._pendingDeliverySessionId = null;
+ if (sessionId) this._deliver(sessionId, data);
+ }
+
+ _cancelDeliveryFlush() {
+ if (this._deliveryFlushTimer === null) return;
+ this._clearTimer(this._deliveryFlushTimer);
+ this._deliveryFlushTimer = null;
+ }
+
+ _acceptComposition(finalText) {
+ this._compositionPending = false;
+ this._expectedCommit = null;
+ this._clearCompositionTimer();
+ this._getOverlay()?.commitComposition?.(finalText);
+ this._resetHelperTextarea();
+ this._captureDraft();
+ this._rememberCompositionDelivery(finalText);
+ }
+
+ _rememberCompositionDelivery(finalText) {
+ this._fallbackCommit = finalText;
+ this._fallbackSessionId = this._getSessionId();
+ this._fallbackCommitEpoch = this._compositionEpoch;
+ this._fallbackCommitExpiresAt = this._now() + this._commitReplayWindowMs;
+ }
+
+ _expireCompositionDelivery() {
+ if (this._fallbackCommit === null) return;
+ if (this._fallbackCommitEpoch !== this._compositionEpoch || this._now() > this._fallbackCommitExpiresAt) {
+ this.clearCompositionDelivery();
+ }
+ }
+
+ _resetHelperTextarea() {
+ if (!this._textarea) return;
+ if (this._textarea.value !== '') {
+ this._textarea.value = '';
+ }
+ try {
+ this._textarea.setSelectionRange?.(0, 0);
+ } catch {
+ /* hidden textarea may be detached during terminal teardown */
+ }
+ }
+
+ _clearCompositionTimer() {
+ if (this._compositionCommitTimer === null) return;
+ this._clearTimer(this._compositionCommitTimer);
+ this._compositionCommitTimer = null;
+ }
+
+ _clearMobileLineBreakTimer() {
+ if (this._mobileLineBreakFallbackTimer === null) {
+ return;
+ }
+ this._clearTimer(this._mobileLineBreakFallbackTimer);
+ this._mobileLineBreakFallbackTimer = null;
+ }
+
+ _resetCompositionState() {
+ this._compositionActive = false;
+ this._compositionPending = false;
+ this._expectedCommit = null;
+ this.clearCompositionDelivery();
+ this._clearCompositionTimer();
+ this._clearMobileLineBreakTimer();
+ this._mobileLineBreakPending = false;
+ this._compositionInputCommittedEpoch = -1;
+ this._ignoredCompositionEndEpoch = -1;
+ this._helperMutationSnapshot = null;
+ this._keydownEchoTokens = [];
+ this._lastMobileEnterKeydownAt = -Infinity;
+ this._compositionEpoch += 1;
+ this._getOverlay()?.clearComposition?.();
+ }
+
+ _classifyData(data) {
+ const firstCode = data.charCodeAt(0);
+ if (data.length === 1) {
+ if (firstCode === 127) return 'delete';
+ if (firstCode < 32) return 'control';
+ if (/\s/.test(data)) return 'boundary';
+ return 'printable';
+ }
+ return firstCode === 27 ? 'escape' : 'text';
+ }
+}
+
+globalThis.TerminalInputController = TerminalInputController;
diff --git a/test/terminal-input-controller.test.ts b/test/terminal-input-controller.test.ts
new file mode 100644
index 00000000..fde37248
--- /dev/null
+++ b/test/terminal-input-controller.test.ts
@@ -0,0 +1,879 @@
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+import { describe, expect, it, vi } from 'vitest';
+
+type ControllerOptions = {
+ textarea: FakeTextarea;
+ terminal: { input: (data: string) => void };
+ overlay: FakeOverlay;
+ getSessionId: () => string;
+ getSessionMode: () => string;
+ isLocalEchoEnabled: () => boolean;
+ isRestoringDraft: () => boolean;
+ isInteractionPrompt: (sessionId: string) => boolean;
+ captureDraft: () => void;
+ setDraft: (sessionId: string, draft: Record) => void;
+ clearDraft: (sessionId: string) => void;
+ deliver: (sessionId: string, data: string, options?: { useMux?: boolean }) => void;
+ preparePaste: (text: string, bracketed: boolean) => string;
+ trace: (stage: string, fields?: Record) => void;
+ log: (message: string) => void;
+ onTab: (context: Record) => boolean;
+ onTabCancel: () => void;
+ onInteractionControl: (sessionId: string, data: string) => void;
+ setTimer: (callback: () => void, delay: number) => number;
+ clearTimer: (timer: number) => void;
+ now: () => number;
+ commitReplayWindowMs?: number;
+};
+
+type TerminalInputController = {
+ beginComposition: () => void;
+ updateComposition: (text: string) => void;
+ endComposition: (text: string) => void;
+ handleTerminalData: (data: string, source?: string) => boolean;
+ sendControl: (data: string) => void;
+ sendExternalText: (text: string) => void;
+ sendCommand: (command: string) => void;
+ sendPaste: (text: string, options?: { submit?: boolean }) => void;
+ sendModifiedEnter: (key: string) => void;
+ flushDraftText: (text: string, sessionId?: string) => boolean;
+ clearInput: () => void;
+ resolveTabCompletion: () => void;
+ isPtyEditing: (sessionId?: string) => boolean;
+ attachTextarea: (container: FakeEventTarget, options?: { mobile?: boolean }) => boolean;
+ reset: (options?: { flushDelivery?: boolean }) => void;
+ state: {
+ compositionActive: boolean;
+ compositionPending: boolean;
+ expectedCommit: string | null;
+ fallbackCommit: string | null;
+ };
+};
+
+type TerminalInputControllerConstructor = new (options: ControllerOptions) => TerminalInputController;
+
+type FakeListener = (event: Record) => void;
+
+class FakeEventTarget {
+ private listeners = new Map();
+ eventParent: FakeEventTarget | null = null;
+
+ addEventListener(type: string, listener: FakeListener): void {
+ this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
+ }
+
+ removeEventListener(type: string, listener: FakeListener): void {
+ this.listeners.set(
+ type,
+ (this.listeners.get(type) ?? []).filter((candidate) => candidate !== listener)
+ );
+ }
+
+ fire(type: string, fields: Record = {}) {
+ let immediatePropagationStopped = false;
+ let propagationStopped = false;
+ const event: Record = {
+ target: this,
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(() => {
+ propagationStopped = true;
+ }),
+ stopImmediatePropagation: vi.fn(() => {
+ immediatePropagationStopped = true;
+ propagationStopped = true;
+ }),
+ ...fields,
+ };
+ const dispatch = (target: FakeEventTarget) => {
+ for (const listener of target.listeners.get(type) ?? []) {
+ listener(event);
+ if (immediatePropagationStopped) break;
+ }
+ };
+ if (this.eventParent) dispatch(this.eventParent);
+ if (!propagationStopped) {
+ immediatePropagationStopped = false;
+ dispatch(this);
+ }
+ return event;
+ }
+}
+
+class FakeTextarea extends FakeEventTarget {
+ value = '';
+ selectionStart = 0;
+ selectionEnd = 0;
+
+ setSelectionRange(start: number, end: number): void {
+ this.selectionStart = start;
+ this.selectionEnd = end;
+ }
+}
+
+class FakeOverlay {
+ pendingText = '';
+ compositionText = '';
+ flushedText = '';
+
+ addChar(char: string): void {
+ this.pendingText += char;
+ }
+
+ appendText(text: string): void {
+ this.pendingText += text;
+ }
+
+ setCompositionText(text: string): void {
+ this.compositionText = text;
+ }
+
+ commitComposition(text: string): void {
+ this.compositionText = '';
+ this.pendingText += text;
+ }
+
+ clearComposition(): void {
+ this.compositionText = '';
+ }
+
+ private removeLastInputUnit(text: string): string {
+ if (!text) return '';
+ const segmenter = new Intl.Segmenter(undefined, {
+ granularity: 'grapheme',
+ });
+ const last = segmenter.segment(text).containing(text.length - 1);
+ return text.slice(0, last?.index ?? 0);
+ }
+
+ removeChar(): 'pending' | 'flushed' | false {
+ if (this.pendingText) {
+ this.pendingText = this.removeLastInputUnit(this.pendingText);
+ return 'pending';
+ }
+ if (this.flushedText) {
+ this.flushedText = this.removeLastInputUnit(this.flushedText);
+ return 'flushed';
+ }
+ return false;
+ }
+
+ getFlushed(): { count: number; text: string } {
+ return {
+ count: this.flushedText.length,
+ text: this.flushedText,
+ };
+ }
+
+ setFlushed(_count: number, text: string): void {
+ this.flushedText = text;
+ }
+
+ clear(): void {
+ this.pendingText = '';
+ this.compositionText = '';
+ this.flushedText = '';
+ }
+
+ suppressBufferDetection(): void {}
+}
+
+function loadController(): TerminalInputControllerConstructor {
+ const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-input-controller.js'), 'utf8');
+ const context = vm.createContext({
+ console,
+ globalThis: {},
+ });
+ vm.runInContext(source, context, { filename: 'terminal-input-controller.js' });
+ return (
+ context.globalThis as {
+ TerminalInputController: TerminalInputControllerConstructor;
+ }
+ ).TerminalInputController;
+}
+
+function createHarness(
+ localEcho = true,
+ options: {
+ commitReplayWindowMs?: number;
+ interactionPrompt?: boolean;
+ } = {}
+) {
+ const Controller = loadController();
+ const textarea = new FakeTextarea();
+ const container = new FakeEventTarget();
+ textarea.eventParent = container;
+ const overlay = new FakeOverlay();
+ const deliveries: string[] = [];
+ const deliveryEvents: Array<{
+ sessionId: string;
+ data: string;
+ options?: { useMux?: boolean };
+ }> = [];
+ const drafts: Array> = [];
+ const clearedDrafts: string[] = [];
+ const traces: string[] = [];
+ const tabContexts: Array> = [];
+ const tabCancels: number[] = [];
+ const interactionControls: string[] = [];
+ const timers = new Map void>();
+ let timerId = 0;
+ let now = 1000;
+ let sessionId = 'session-a';
+ let interactionPrompt = options.interactionPrompt === true;
+ const terminalInput = vi.fn();
+ const terminal = {
+ input: terminalInput,
+ };
+ const controller = new Controller({
+ textarea,
+ terminal,
+ overlay,
+ getSessionId: () => sessionId,
+ getSessionMode: () => (localEcho ? 'codex' : 'shell'),
+ isLocalEchoEnabled: () => localEcho,
+ isRestoringDraft: () => false,
+ isInteractionPrompt: () => interactionPrompt,
+ captureDraft: () => {},
+ setDraft: (_sessionId, draft) => drafts.push(draft),
+ clearDraft: (targetSessionId) => clearedDrafts.push(targetSessionId),
+ deliver: (targetSessionId, data, options) => {
+ deliveries.push(data);
+ deliveryEvents.push({
+ sessionId: targetSessionId,
+ data,
+ options,
+ });
+ },
+ preparePaste: (text, bracketed) => (bracketed ? `${text}` : text),
+ trace: (stage) => traces.push(stage),
+ log: () => {},
+ onTab: (tabContext) => {
+ tabContexts.push(tabContext);
+ return true;
+ },
+ onTabCancel: () => {
+ tabCancels.push(now);
+ },
+ onInteractionControl: (_sessionId, data) => {
+ interactionControls.push(data);
+ if (/^[\r\n]+$/.test(data) || data === '\x1b' || data === '\x03') {
+ interactionPrompt = false;
+ }
+ },
+ setTimer: (callback) => {
+ timerId += 1;
+ timers.set(timerId, callback);
+ return timerId;
+ },
+ clearTimer: (id) => {
+ timers.delete(id);
+ },
+ now: () => now,
+ commitReplayWindowMs: options.commitReplayWindowMs,
+ });
+ return {
+ controller,
+ container,
+ textarea,
+ overlay,
+ deliveries,
+ deliveryEvents,
+ drafts,
+ clearedDrafts,
+ traces,
+ tabContexts,
+ tabCancels,
+ interactionControls,
+ terminalInput,
+ timers,
+ advanceTime: (milliseconds: number) => {
+ now += milliseconds;
+ },
+ setSessionId: (nextSessionId: string) => {
+ sessionId = nextSessionId;
+ },
+ setInteractionPrompt: (active: boolean) => {
+ interactionPrompt = active;
+ },
+ };
+}
+
+describe('TerminalInputController', () => {
+ it('owns finalized composition reconciliation and clears retained helper context', () => {
+ const { controller, textarea, overlay, traces } = createHarness();
+ overlay.appendText('cd');
+
+ controller.beginComposition();
+ textarea.value = 'hcd home';
+ controller.updateComposition(' home');
+ controller.endComposition(' home');
+ const handled = controller.handleTerminalData('hcd home', 'xterm');
+
+ expect(handled).toBe(true);
+ expect(overlay.pendingText).toBe('cd home');
+ expect(overlay.compositionText).toBe('');
+ expect(textarea.value).toBe('');
+ expect(controller.state).toMatchObject({
+ compositionActive: false,
+ compositionPending: false,
+ expectedCommit: null,
+ fallbackCommit: ' home',
+ });
+ expect(traces).toContain('composition-strip-stale-prefix');
+ });
+
+ it('keeps repeated composition epochs independent of retained textarea values', () => {
+ const { controller, textarea, overlay } = createHarness();
+
+ for (const text of ['cd', ' home', ' cd', ' home']) {
+ controller.beginComposition();
+ textarea.value = `stale${text}`;
+ controller.updateComposition(text);
+ controller.endComposition(text);
+ controller.handleTerminalData(`stale${text}`, 'xterm');
+ expect(textarea.value).toBe('');
+ }
+
+ expect(overlay.pendingText).toBe('cd home cd home');
+ });
+
+ it('cancels an unfinished composition before applying one local Backspace', () => {
+ const { controller, overlay } = createHarness();
+ overlay.appendText('cd');
+ controller.beginComposition();
+ controller.updateComposition('candidate');
+ controller.endComposition('candidate');
+
+ controller.handleTerminalData('\x7f', 'beforeinput-delete');
+
+ expect(overlay.pendingText).toBe('c');
+ expect(overlay.compositionText).toBe('');
+ expect(controller.state).toMatchObject({
+ compositionActive: false,
+ compositionPending: false,
+ expectedCommit: null,
+ fallbackCommit: null,
+ });
+ });
+
+ it('routes ordinary text and Enter through one local-echo submission path', () => {
+ const { controller, overlay, deliveries } = createHarness();
+
+ controller.handleTerminalData('cd home', 'xterm');
+ controller.sendControl('\r');
+
+ expect(overlay.pendingText).toBe('');
+ expect(deliveries).toEqual(['cd home', '\r']);
+ });
+
+ it('processes a semantic CJK control without re-entering the xterm gate', () => {
+ const { controller, overlay, deliveries, terminalInput } = createHarness();
+ overlay.appendText('你好');
+
+ controller.sendControl('\r');
+
+ expect(terminalInput).not.toHaveBeenCalled();
+ expect(deliveries).toEqual(['你好', '\r']);
+ });
+
+ it('preserves a local draft while an interaction prompt owns controls', () => {
+ const { controller, overlay, deliveries, interactionControls, clearedDrafts } = createHarness(true, {
+ interactionPrompt: true,
+ });
+ overlay.appendText('keep this draft');
+
+ controller.sendControl('\x1b[B');
+ controller.sendControl('\r');
+
+ expect(deliveries).toEqual(['\x1b[B', '\r']);
+ expect(interactionControls).toEqual(['\x1b[B', '\r']);
+ expect(overlay.pendingText).toBe('keep this draft');
+ expect(clearedDrafts).toEqual([]);
+
+ controller.sendControl('\r');
+
+ expect(deliveries).toEqual(['\x1b[B', '\r', 'keep this draft', '\r']);
+ });
+
+ it('retains a composition marker across a boundary and drops its delayed replay', () => {
+ const { controller, overlay } = createHarness();
+ controller.beginComposition();
+ controller.updateComposition('home');
+ controller.endComposition('home');
+ controller.handleTerminalData('home', 'xterm');
+
+ controller.handleTerminalData(' ', 'capture-input');
+ controller.handleTerminalData('home', 'capture-input');
+
+ expect(overlay.pendingText).toBe('home ');
+ expect(controller.state.fallbackCommit).toBeNull();
+ });
+
+ it('expires a composition replay marker before accepting later identical text', () => {
+ const { controller, overlay, timers, advanceTime } = createHarness(true, {
+ commitReplayWindowMs: 120,
+ });
+ controller.beginComposition();
+ controller.updateComposition('home');
+ controller.endComposition('home');
+ for (const timer of [...timers.values()]) timer();
+
+ controller.handleTerminalData(' ', 'capture-input');
+ advanceTime(121);
+ controller.handleTerminalData('home', 'capture-input');
+
+ expect(overlay.pendingText).toBe('home home');
+ expect(controller.state.fallbackCommit).toBeNull();
+ });
+
+ it('drops one fragmented replay of a fallback composition commit', () => {
+ const { controller, overlay, timers } = createHarness();
+ controller.beginComposition();
+ controller.updateComposition('home');
+ controller.endComposition('home');
+ for (const timer of [...timers.values()]) timer();
+
+ controller.handleTerminalData('h', 'xterm');
+ controller.handleTerminalData('om', 'xterm');
+ controller.handleTerminalData('e', 'xterm');
+
+ expect(overlay.pendingText).toBe('home');
+ expect(controller.state.fallbackCommit).toBeNull();
+ });
+
+ it('delivers immediate-echo composition data only once', () => {
+ const { controller, deliveries } = createHarness(false);
+ controller.beginComposition();
+ controller.updateComposition('home');
+ controller.endComposition('home');
+
+ controller.handleTerminalData('home', 'xterm');
+ controller.handleTerminalData('home', 'capture-input');
+
+ expect(deliveries).toEqual(['home']);
+ });
+
+ it('flushes the editable draft before a modified Enter key', () => {
+ const { controller, overlay, deliveries, drafts } = createHarness();
+ overlay.appendText('first\nsecond');
+
+ controller.sendModifiedEnter('S-Enter');
+
+ expect(deliveries).toEqual(['first\nsecond', '\n']);
+ expect(drafts.at(-1)).toMatchObject({
+ pendingText: '',
+ flushedText: 'first\nsecond\n',
+ });
+ });
+
+ it('merges an existing editable draft into a submitted paste', () => {
+ const { controller, overlay, deliveries, clearedDrafts } = createHarness();
+ overlay.appendText('prefix ');
+
+ controller.sendPaste('first\n\nsecond', {
+ submit: true,
+ });
+
+ expect(deliveries).toEqual(['prefix first\n\nsecond', '\r']);
+ expect(overlay.pendingText).toBe('');
+ expect(clearedDrafts).toEqual(['session-a']);
+ });
+
+ it('hands cursor navigation to the PTY instead of appending to a stale overlay', () => {
+ const { controller, overlay, deliveries, drafts, clearedDrafts } = createHarness();
+ overlay.appendText('cla');
+
+ controller.sendControl('\x1b[D');
+ controller.handleTerminalData('X');
+
+ expect(deliveries).toEqual(['cla', '\x1b[D', 'X']);
+ expect(overlay.pendingText).toBe('');
+ expect(overlay.flushedText).toBe('');
+ expect(drafts.at(-1)).toMatchObject({
+ pendingText: '',
+ flushedText: '',
+ ptyOwned: true,
+ });
+
+ controller.sendControl('\r');
+ controller.handleTerminalData('Z');
+
+ expect(deliveries).toEqual(['cla', '\x1b[D', 'X', '\r']);
+ expect(overlay.pendingText).toBe('Z');
+ expect(clearedDrafts).toContain('session-a');
+ });
+
+ it('keeps history navigation and its following edit PTY-owned', () => {
+ const { controller, overlay, deliveries } = createHarness();
+ overlay.appendText('draft');
+
+ controller.sendControl('\x1b[A');
+ controller.handleTerminalData('!');
+
+ expect(deliveries).toEqual(['draft', '\x1b[A', '!']);
+ expect(overlay.pendingText).toBe('');
+ });
+
+ it('sends Escape without flushing or relinquishing the editable draft', () => {
+ const { controller, overlay, deliveries } = createHarness();
+ overlay.appendText('keep me');
+
+ controller.sendControl('\x1b');
+ controller.handleTerminalData('!');
+
+ expect(deliveries).toEqual(['\x1b']);
+ expect(overlay.pendingText).toBe('keep me!');
+ });
+
+ it('keeps an existing PTY-owned navigation edit through Escape', () => {
+ const { controller, overlay, deliveries } = createHarness();
+ overlay.appendText('draft');
+
+ controller.sendControl('\x1b[A');
+ controller.sendControl('\x1b');
+ controller.handleTerminalData('fresh');
+
+ expect(deliveries).toEqual(['draft', '\x1b[A', '\x1b', 'fresh']);
+ expect(overlay.pendingText).toBe('');
+ expect(controller.isPtyEditing()).toBe(true);
+ });
+
+ it('retains PTY-owned editing across session switches', () => {
+ const { controller, overlay, deliveries, setSessionId } = createHarness();
+ overlay.appendText('draft');
+ controller.sendControl('\x1b[A');
+
+ controller.reset();
+ setSessionId('session-b');
+ overlay.clear();
+ controller.handleTerminalData('local');
+ expect(overlay.pendingText).toBe('local');
+
+ overlay.clear();
+ controller.reset();
+ setSessionId('session-a');
+ controller.handleTerminalData('!');
+
+ expect(deliveries).toEqual(['draft', '\x1b[A', '!']);
+ expect(overlay.pendingText).toBe('');
+ });
+
+ it('frames a multiline draft before accessory and generic controls', () => {
+ const { controller, overlay, deliveryEvents } = createHarness();
+ overlay.appendText('first\n\nsecond');
+
+ controller.sendControl('\x1b[A');
+
+ expect(deliveryEvents).toEqual([
+ {
+ sessionId: 'session-a',
+ data: 'first\n\nsecond',
+ options: { useMux: false },
+ },
+ {
+ sessionId: 'session-a',
+ data: '\x1b[A',
+ options: undefined,
+ },
+ ]);
+
+ controller.sendControl('\r');
+ overlay.appendText('third\nfourth');
+ controller.sendControl('\x03');
+
+ expect(deliveryEvents.slice(-2)).toEqual([
+ {
+ sessionId: 'session-a',
+ data: 'third\nfourth',
+ options: { useMux: false },
+ },
+ {
+ sessionId: 'session-a',
+ data: '\x03',
+ options: undefined,
+ },
+ ]);
+ });
+
+ it('clears flushed local text with one Backspace per grapheme', () => {
+ const { controller, overlay, deliveryEvents } = createHarness();
+ overlay.flushedText = 'A👨👩👧👦e\u0301';
+
+ controller.clearInput();
+
+ expect(overlay.flushedText).toBe('');
+ expect(deliveryEvents).toEqual([
+ {
+ sessionId: 'session-a',
+ data: '\x7f\x7f\x7f',
+ options: { useMux: false },
+ },
+ ]);
+ });
+
+ it('flushes a queued normal-mode character before session reset', () => {
+ const { controller, deliveries } = createHarness(false);
+
+ controller.handleTerminalData('a');
+ controller.handleTerminalData('b');
+ controller.reset();
+
+ expect(deliveries).toEqual(['a', 'b']);
+ });
+
+ it('orders external input after the existing editable draft', () => {
+ const { controller, overlay, deliveries, drafts } = createHarness();
+ overlay.appendText('hello ');
+
+ controller.sendExternalText('world');
+
+ expect(deliveries).toEqual(['hello world']);
+ expect(overlay.pendingText).toBe('');
+ expect(overlay.flushedText).toBe('hello world');
+ expect(drafts.at(-1)).toMatchObject({
+ pendingText: '',
+ flushedText: 'hello world',
+ });
+ });
+
+ it('frames multiline external and session-handoff text at the delivery boundary', () => {
+ const { controller, overlay, deliveryEvents } = createHarness();
+ overlay.appendText('first\n');
+
+ controller.sendExternalText('second');
+ controller.flushDraftText('third\nfourth');
+
+ expect(deliveryEvents).toEqual([
+ {
+ sessionId: 'session-a',
+ data: 'first\nsecond',
+ options: { useMux: false },
+ },
+ {
+ sessionId: 'session-a',
+ data: 'third\nfourth',
+ options: { useMux: false },
+ },
+ ]);
+ expect(overlay.flushedText).toBe('first\nsecond');
+ });
+
+ it('preserves prior flushed ownership across Tab completion', () => {
+ const { controller, overlay, deliveries, drafts, tabContexts } = createHarness();
+ overlay.flushedText = 'hello ';
+ overlay.pendingText = 'wor';
+
+ controller.handleTerminalData('\t');
+
+ expect(tabContexts).toHaveLength(1);
+ expect(tabContexts[0]).toMatchObject({
+ sessionId: 'session-a',
+ text: 'wor',
+ flushedText: 'hello wor',
+ });
+ expect(deliveries).toEqual(['wor', '\t']);
+ expect(overlay.pendingText).toBe('');
+ expect(overlay.flushedText).toBe('hello wor');
+ expect(drafts.at(-1)).toMatchObject({
+ pendingText: '',
+ flushedText: 'hello wor',
+ });
+ });
+
+ it('cancels stale Tab completion tracking before later input', () => {
+ const { controller, overlay, deliveries, tabCancels } = createHarness();
+ overlay.pendingText = 'wor';
+
+ controller.handleTerminalData('\t');
+ controller.sendControl('\x1b[A');
+ controller.handleTerminalData('!');
+
+ expect(tabCancels).toHaveLength(1);
+ expect(deliveries).toEqual(['wor', '\t', '\x1b[A', '!']);
+ expect(controller.isPtyEditing()).toBe(true);
+ });
+
+ it('submits accessory commands through the current editable draft', () => {
+ const { controller, overlay, deliveries } = createHarness();
+ overlay.appendText('prefix ');
+
+ controller.sendCommand('/help');
+
+ expect(deliveries).toEqual(['prefix /help', '\r']);
+ });
+
+ it('routes real composition events through the attached DOM adapter', () => {
+ const { controller, container, textarea, overlay } = createHarness();
+ expect(controller.attachTextarea(container, { mobile: true })).toBe(true);
+
+ textarea.fire('compositionstart');
+ textarea.fire('compositionupdate', { data: 'home' });
+ textarea.value = 'home';
+ textarea.fire('input', {
+ inputType: 'insertText',
+ data: 'home',
+ isComposing: false,
+ });
+ textarea.fire('compositionend', { data: 'home' });
+
+ expect(overlay.pendingText).toBe('home');
+ expect(overlay.compositionText).toBe('');
+ expect(textarea.value).toBe('');
+ });
+
+ it('routes attached delete and multiline paste events exactly once', () => {
+ const { controller, container, textarea, overlay } = createHarness();
+ controller.attachTextarea(container, { mobile: true });
+ overlay.appendText('abc');
+
+ const deletion = textarea.fire('beforeinput', {
+ inputType: 'deleteContentBackward',
+ isComposing: false,
+ });
+ const paste = textarea.fire('paste', {
+ clipboardData: {
+ getData: () => 'first\n\nsecond',
+ },
+ });
+
+ expect(deletion.preventDefault).toHaveBeenCalledOnce();
+ expect(paste.preventDefault).toHaveBeenCalledOnce();
+ expect(overlay.pendingText).toBe('abfirst\n\nsecond');
+ });
+
+ it('prevents xterm from replaying a captured textarea mutation', () => {
+ const { controller, container, textarea, overlay } = createHarness();
+ controller.attachTextarea(container, { mobile: true });
+ const downstreamInput = vi.fn();
+ textarea.addEventListener('input', downstreamInput);
+ textarea.value = 'home';
+
+ const event = textarea.fire('input', {
+ inputType: 'insertText',
+ data: 'home',
+ isComposing: false,
+ });
+
+ expect(event.stopImmediatePropagation).toHaveBeenCalledOnce();
+ expect(downstreamInput).not.toHaveBeenCalled();
+ expect(overlay.pendingText).toBe('home');
+ });
+
+ it('consumes a delayed DOM echo only after matching xterm delivery', () => {
+ const { controller, container, textarea, overlay, advanceTime } = createHarness();
+ controller.attachTextarea(container, { mobile: true });
+
+ textarea.fire('keydown', {
+ key: 'a',
+ keyCode: 65,
+ isComposing: false,
+ });
+ controller.handleTerminalData('a', 'xterm');
+ advanceTime(5000);
+ textarea.value = 'a';
+ textarea.fire('input', {
+ inputType: 'insertText',
+ data: 'a',
+ isComposing: false,
+ });
+
+ expect(overlay.pendingText).toBe('a');
+ });
+
+ it('does not drop unrelated input after an unhandled keydown candidate', () => {
+ const { controller, container, textarea, overlay } = createHarness();
+ controller.attachTextarea(container, { mobile: true });
+
+ textarea.fire('keydown', {
+ key: 'a',
+ keyCode: 65,
+ isComposing: false,
+ });
+ textarea.value = '你';
+ textarea.fire('input', {
+ inputType: 'insertText',
+ data: '你',
+ isComposing: false,
+ });
+
+ expect(overlay.pendingText).toBe('你');
+ });
+
+ it('coalesces segmented Android paste input without duplicating later segments', () => {
+ const { controller, container, textarea, overlay } = createHarness();
+ controller.attachTextarea(container, { mobile: true });
+
+ textarea.value = 'References';
+ textarea.fire('input', {
+ inputType: 'insertText',
+ data: 'References',
+ isComposing: false,
+ });
+ textarea.fire('input', {
+ inputType: 'insertLineBreak',
+ data: null,
+ isComposing: false,
+ });
+ textarea.value = 'more';
+ textarea.fire('input', {
+ inputType: 'insertText',
+ data: 'more',
+ isComposing: false,
+ });
+
+ expect(overlay.pendingText).toBe('References\nmore');
+ });
+
+ it('applies a replacement deletion once per visible grapheme', () => {
+ const { controller, container, textarea, overlay } = createHarness();
+ controller.attachTextarea(container, { mobile: true });
+ overlay.pendingText = 'Ae\u0301';
+ textarea.value = 'e\u0301';
+ textarea.selectionStart = 0;
+ textarea.selectionEnd = textarea.value.length;
+
+ textarea.fire('beforeinput', {
+ inputType: 'insertReplacementText',
+ data: 'x',
+ isComposing: false,
+ });
+ textarea.value = 'x';
+ textarea.fire('input', {
+ inputType: 'insertReplacementText',
+ data: 'x',
+ isComposing: false,
+ });
+
+ expect(overlay.pendingText).toBe('Ax');
+ });
+
+ it('keeps a queued normal-mode character owned by its original session', () => {
+ const { controller, deliveries, deliveryEvents, setSessionId } = createHarness(false);
+
+ controller.handleTerminalData('a');
+ controller.handleTerminalData('b');
+ setSessionId('session-b');
+ controller.reset();
+
+ expect(deliveries).toEqual(['a', 'b']);
+ expect(deliveryEvents.map((event) => event.sessionId)).toEqual(['session-a', 'session-a']);
+ });
+
+ it('drops a compositionend that arrives after session state was reset', () => {
+ const { controller, overlay, textarea, timers } = createHarness();
+ controller.beginComposition();
+ controller.updateComposition('outgoing');
+
+ controller.reset({ flushDelivery: false });
+ textarea.value = 'outgoing';
+ controller.endComposition('outgoing');
+ for (const timer of timers.values()) timer();
+
+ expect(overlay.pendingText).toBe('');
+ expect(overlay.compositionText).toBe('');
+ expect(textarea.value).toBe('');
+ expect(controller.state.compositionPending).toBe(false);
+ });
+});
From 1281e5297cbd1ccfe943f76b2e28789ffbca8779 Mon Sep 17 00:00:00 2001
From: lior
Date: Wed, 29 Jul 2026 09:13:51 +0300
Subject: [PATCH 4/8] feat(input): integrate persistent terminal drafts
---
scripts/build.mjs | 107 ++++-
scripts/check-public-assets.mjs | 59 ++-
src/web/public/app.js | 233 +++++++--
src/web/public/constants.js | 5 +-
src/web/public/index.html | 2 +
src/web/public/mobile-handlers.js | 20 +-
src/web/public/mobile.css | 11 +-
src/web/public/styles.css | 7 +
src/web/public/terminal-ui.js | 654 ++++++++++----------------
test/input-send-order.test.ts | 147 +++++-
test/terminal-buffer-flush.test.ts | 23 +
test/terminal-flush-budget.test.ts | 22 +
test/terminal-input-lifecycle.test.ts | 288 ++++++++++++
13 files changed, 1048 insertions(+), 530 deletions(-)
create mode 100644 test/terminal-input-lifecycle.test.ts
diff --git a/scripts/build.mjs b/scripts/build.mjs
index 830d5ee1..5eb3d343 100644
--- a/scripts/build.mjs
+++ b/scripts/build.mjs
@@ -43,41 +43,100 @@ run('copy template', 'cp src/templates/case-template.md dist/templates/');
// 3. Vendor xterm bundles (xterm.js 6.x — @xterm scoped packages)
run('xterm css', 'cp node_modules/@xterm/xterm/css/xterm.css dist/web/public/vendor/');
-run('xterm js', 'npx esbuild node_modules/@xterm/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js');
-run('xterm-addon-fit', 'npx esbuild node_modules/@xterm/addon-fit/lib/addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js');
-run('xterm-addon-serialize', 'npx esbuild node_modules/@xterm/addon-serialize/lib/addon-serialize.js --minify --outfile=dist/web/public/vendor/xterm-addon-serialize.min.js');
-run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js');
-run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js');
-run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js');
+run(
+ 'xterm js',
+ 'npx esbuild node_modules/@xterm/xterm/lib/xterm.js --minify --outfile=dist/web/public/vendor/xterm.min.js'
+);
+run(
+ 'xterm-addon-fit',
+ 'npx esbuild node_modules/@xterm/addon-fit/lib/addon-fit.js --minify --outfile=dist/web/public/vendor/xterm-addon-fit.min.js'
+);
+run(
+ 'xterm-addon-serialize',
+ 'npx esbuild node_modules/@xterm/addon-serialize/lib/addon-serialize.js --minify --outfile=dist/web/public/vendor/xterm-addon-serialize.min.js'
+);
+run(
+ 'xterm-addon-webgl',
+ 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js'
+);
+run(
+ 'xterm-addon-unicode11',
+ 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js'
+);
+run(
+ 'xterm-zerolag-input',
+ 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js'
+);
// Append global aliases so app.js can use `new LocalEchoOverlay(terminal)`
appendFileSync(
join(ROOT, 'dist/web/public/vendor/xterm-zerolag-input.js'),
'\n// Global aliases for browser usage\n' +
- 'if(typeof window!=="undefined"){' +
+ 'if(typeof window!=="undefined"){' +
'window.ZerolagInputAddon=XtermZerolagInput.ZerolagInputAddon;' +
'window.LocalEchoOverlay=class extends XtermZerolagInput.ZerolagInputAddon{' +
- 'constructor(terminal){' +
- 'super({prompt:{type:"character",char:"\\u276f",offset:2}});' +
- 'this.activate(terminal);' +
- '}' +
+ 'constructor(terminal){' +
+ 'super({prompt:{type:"character",char:"\\u276f",offset:2}});' +
+ 'this.activate(terminal);' +
+ '}' +
'};' +
- '}\n'
+ '}\n'
);
// 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 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 input-cjk.js',
+ 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite'
+);
+run(
+ 'minify terminal-input-state.js',
+ 'npx esbuild dist/web/public/terminal-input-state.js --minify --outfile=dist/web/public/terminal-input-state.js --allow-overwrite'
+);
+run(
+ 'minify terminal-input-controller.js',
+ 'npx esbuild dist/web/public/terminal-input-controller.js --minify --outfile=dist/web/public/terminal-input-controller.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 app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.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');
-run('minify respawn-ui.js', 'npx esbuild dist/web/public/respawn-ui.js --minify --outfile=dist/web/public/respawn-ui.js --allow-overwrite');
-run('minify ralph-panel.js', 'npx esbuild dist/web/public/ralph-panel.js --minify --outfile=dist/web/public/ralph-panel.js --allow-overwrite');
-run('minify settings-ui.js', 'npx esbuild dist/web/public/settings-ui.js --minify --outfile=dist/web/public/settings-ui.js --allow-overwrite');
-run('minify panels-ui.js', 'npx esbuild dist/web/public/panels-ui.js --minify --outfile=dist/web/public/panels-ui.js --allow-overwrite');
-run('minify session-ui.js', 'npx esbuild dist/web/public/session-ui.js --minify --outfile=dist/web/public/session-ui.js --allow-overwrite');
-run('minify styles.css', 'npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite');
-run('minify mobile.css', 'npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --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'
+);
+run(
+ 'minify respawn-ui.js',
+ 'npx esbuild dist/web/public/respawn-ui.js --minify --outfile=dist/web/public/respawn-ui.js --allow-overwrite'
+);
+run(
+ 'minify ralph-panel.js',
+ 'npx esbuild dist/web/public/ralph-panel.js --minify --outfile=dist/web/public/ralph-panel.js --allow-overwrite'
+);
+run(
+ 'minify settings-ui.js',
+ 'npx esbuild dist/web/public/settings-ui.js --minify --outfile=dist/web/public/settings-ui.js --allow-overwrite'
+);
+run(
+ 'minify panels-ui.js',
+ 'npx esbuild dist/web/public/panels-ui.js --minify --outfile=dist/web/public/panels-ui.js --allow-overwrite'
+);
+run(
+ 'minify session-ui.js',
+ 'npx esbuild dist/web/public/session-ui.js --minify --outfile=dist/web/public/session-ui.js --allow-overwrite'
+);
+run(
+ 'minify styles.css',
+ 'npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite'
+);
+run(
+ 'minify mobile.css',
+ 'npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite'
+);
// 5. Content-hash cache busting
console.log('\n[build] content-hash cache busting');
@@ -93,6 +152,8 @@ console.log('\n[build] content-hash cache busting');
'notification-manager.js',
'keyboard-accessory.js',
'input-cjk.js',
+ 'terminal-input-state.js',
+ 'terminal-input-controller.js',
'sanitize-html.js',
'app.js',
'terminal-ui.js',
diff --git a/scripts/check-public-assets.mjs b/scripts/check-public-assets.mjs
index a9b5daeb..54ddc885 100644
--- a/scripts/check-public-assets.mjs
+++ b/scripts/check-public-assets.mjs
@@ -10,7 +10,7 @@ const publicRoot = resolve(repoRoot, 'src/web/public');
const prettierBin = resolve(repoRoot, 'node_modules/.bin/prettier');
const checkedExtensions = new Set(['.js', '.css', '.html', '.json']);
-function collectTextAssets(dir) {
+export function collectTextAssets(dir) {
const files = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
@@ -25,42 +25,49 @@ function collectTextAssets(dir) {
return files;
}
-function findNullByte(buffer) {
+export function findNullByte(buffer) {
for (let i = 0; i < buffer.length; i += 1) {
if (buffer[i] === 0) return i;
}
return -1;
}
-const files = collectTextAssets(publicRoot);
-const failures = [];
+function main() {
+ const files = collectTextAssets(publicRoot);
+ const failures = [];
-for (const file of files) {
- const rel = relative(repoRoot, file);
- const data = readFileSync(file);
- const nullByteIndex = findNullByte(data);
- if (nullByteIndex !== -1) {
- failures.push(`${rel}: contains literal NUL byte at offset ${nullByteIndex}`);
- }
+ for (const file of files) {
+ const rel = relative(repoRoot, file);
+ const data = readFileSync(file);
+ const nullByteIndex = findNullByte(data);
+ if (nullByteIndex !== -1) {
+ failures.push(`${rel}: contains literal NUL byte at offset ${nullByteIndex}`);
+ }
- if (extname(file) === '.js') {
- try {
- execFileSync(process.execPath, ['--check', file], { cwd: repoRoot, stdio: 'pipe' });
- } catch (err) {
- failures.push(`${rel}: JavaScript syntax check failed\n${String(err.stderr || err.message).trim()}`);
+ if (extname(file) === '.js') {
+ try {
+ execFileSync(process.execPath, ['--check', file], { cwd: repoRoot, stdio: 'pipe' });
+ } catch (err) {
+ failures.push(`${rel}: JavaScript syntax check failed\n${String(err.stderr || err.message).trim()}`);
+ }
}
}
-}
-try {
- execFileSync(prettierBin, ['--check', ...files], { cwd: repoRoot, stdio: 'pipe' });
-} catch (err) {
- failures.push(`Prettier public asset check failed\n${String(err.stdout || err.stderr || err.message).trim()}`);
-}
+ try {
+ execFileSync(prettierBin, ['--check', ...files], { cwd: repoRoot, stdio: 'pipe' });
+ } catch (err) {
+ failures.push(`Prettier public asset check failed\n${String(err.stdout || err.stderr || err.message).trim()}`);
+ }
+
+ if (failures.length > 0) {
+ console.error(failures.join('\n\n'));
+ process.exitCode = 1;
+ return;
+ }
-if (failures.length > 0) {
- console.error(failures.join('\n\n'));
- process.exit(1);
+ console.log(`Public asset checks passed (${files.length} files).`);
}
-console.log(`Public asset checks passed (${files.length} files).`);
+if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ main();
+}
diff --git a/src/web/public/app.js b/src/web/public/app.js
index 6f953be8..7181b9bc 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -42,9 +42,11 @@
* @dependency voice-input.js (VoiceInput, DeepgramProvider)
* @dependency notification-manager.js (NotificationManager class)
* @dependency keyboard-accessory.js (KeyboardAccessoryBar, FocusTrap)
+ * @dependency terminal-input-state.js (TerminalInputStateStore)
+ * @dependency terminal-input-controller.js (TerminalInputController)
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js, vendor/xterm-addon-webgl.js
* @dependency vendor/xterm-zerolag-input.iife.js (LocalEchoOverlay)
- * @loadorder 6 of 15 — loaded after keyboard-accessory.js, before terminal-ui.js
+ * @loadorder 6 of 16 — loaded after terminal-input-controller.js, before terminal-ui.js
*/
// Codeman App - Tab-based Terminal UI
@@ -642,6 +644,11 @@ class CodemanApp {
this.maxReconnectAttempts = 10;
this.isOnline = navigator.onLine;
+ // Editable input is one session-scoped state record, kept separate from
+ // submitted delivery records below. Terminal/CJK modules are view adapters;
+ // this store owns normalization, handoff transitions, and persistence.
+ this._inputState = new TerminalInputStateStore();
+
// Reliable, durable input delivery (replaces the old best-effort queue).
// Every input byte is recorded with a stable clientId + a monotonic
// per-session seq, persisted to localStorage, and only dropped once the
@@ -658,14 +665,25 @@ class CodemanApp {
this._postDraining = new Set(); // sessionIds with an in-flight POST drainer
this._persistReliableTimer = null;
this._reliableAckTimeoutMs = 4000; // unacked WS frame older than this ⇒ socket likely dead
- this._reliableMaxBytes = 256 * 1024; // cap on the persisted backlog
+ // Match the aggregate editable-draft budget. A supported 512 KiB draft may
+ // expand into several 60 KiB delivery frames, and all of those records must
+ // remain reload-durable until ACKed.
+ this._reliableMaxBytes = 2 * 1024 * 1024;
this._loadReliableState();
this._reliableSweepTimer = setInterval(() => this._redeliverSweep(), 2000);
// Flush the durable queue synchronously when the page is hidden/closed —
// debounced persistence may have a pending write we mustn't lose on reload.
- window.addEventListener('pagehide', () => this._persistReliableNow());
+ window.addEventListener('pagehide', () => {
+ this._captureActiveSessionDraft();
+ this._inputState.persistNow();
+ this._persistReliableNow();
+ });
document.addEventListener('visibilitychange', () => {
- if (document.visibilityState === 'hidden') this._persistReliableNow();
+ if (document.visibilityState === 'hidden') {
+ this._captureActiveSessionDraft();
+ this._inputState.persistNow();
+ this._persistReliableNow();
+ }
});
// Local echo overlay — DOM overlay positioned at the visible ❯ prompt
@@ -774,6 +792,27 @@ class CodemanApp {
this.renderSessionTabs();
}
+ _hasPendingInteractionPrompt(sessionId = this.activeSessionId) {
+ const hooks = sessionId ? this.pendingHooks.get(sessionId) : null;
+ return !!(
+ hooks?.has('permission_prompt') ||
+ hooks?.has('elicitation_dialog')
+ );
+ }
+
+ _handleInteractionPromptControl(sessionId, data) {
+ if (
+ !sessionId ||
+ (!/^[\r\n]+$/.test(data) &&
+ data !== '\x1b' &&
+ data !== '\x03')
+ ) {
+ return;
+ }
+ this.clearPendingHooks(sessionId, 'permission_prompt');
+ this.clearPendingHooks(sessionId, 'elicitation_dialog');
+ }
+
// ═══════════════════════════════════════════════════════════════
// Init — app bootstrap and mobile setup
// ═══════════════════════════════════════════════════════════════
@@ -2478,7 +2517,27 @@ class CodemanApp {
*/
_sendInputAsync(sessionId, input, opts) {
if (!sessionId || !input) return;
- this._reliableSend(sessionId, input, opts?.useMux === true);
+ const maxFrameLength = 60 * 1024;
+ let offset = 0;
+ while (offset < input.length) {
+ let end = Math.min(input.length, offset + maxFrameLength);
+ if (
+ end < input.length &&
+ end > offset &&
+ input.charCodeAt(end - 1) >= 0xd800 &&
+ input.charCodeAt(end - 1) <= 0xdbff &&
+ input.charCodeAt(end) >= 0xdc00 &&
+ input.charCodeAt(end) <= 0xdfff
+ ) {
+ end -= 1;
+ }
+ this._reliableSend(
+ sessionId,
+ input.slice(offset, end),
+ opts?.useMux === true
+ );
+ offset = end;
+ }
}
/**
@@ -2595,11 +2654,21 @@ class CodemanApp {
// rather than retry forever (not a "lost" prompt: the target is gone).
this._ackDelivery(sessionId, rec.seq);
} else {
+ // A WebSocket may have reconnected while this POST was in flight.
+ // Make the failed head frame eligible there before later seqs drain.
+ rec.sentAt = 0;
break; // offline / 5xx — leave queued; sweep + reconnect retry later
}
}
} finally {
this._postDraining.delete(sessionId);
+ if (
+ this._ws &&
+ this._ws.readyState === WebSocket.OPEN &&
+ this._wsSessionId === sessionId
+ ) {
+ this._drainSession(sessionId);
+ }
}
})();
}
@@ -2620,7 +2689,6 @@ class CodemanApp {
this._updateConnectionIndicator();
}
}
- this.clearPendingHooks?.(sessionId);
}
/** Server input-ACK frame ({t:'ia',seq}) over the WebSocket. */
@@ -2630,6 +2698,9 @@ class CodemanApp {
/** Called from ws.onopen — flush everything pending over the fresh socket. */
_onWsReady(sessionId) {
+ // Let an in-flight POST finish first. Otherwise the reconnect can send the
+ // same seq over WS while the mux-backed HTTP writer is still pending.
+ if (this._postDraining.has(sessionId)) return;
const list = this._pendingDeliveries.get(sessionId);
if (list) for (const r of list) r.sentAt = 0; // fresh socket ⇒ re-send all
this._drainSession(sessionId);
@@ -2775,9 +2846,9 @@ class CodemanApp {
}));
for (const r of list) bytes += r.data.length;
}
- // Bound the persisted backlog. On extreme overflow keep the seq counters
- // (so future input stays monotonic and dedup-safe) but skip the payloads —
- // the in-memory queue still delivers; only cross-reload durability is lost.
+ // Bound the persisted backlog at the same aggregate budget as editable
+ // drafts. Inputs inside the supported draft budget retain every ordered
+ // frame, including bracketed-paste delimiters, across reloads.
const payload =
bytes > this._reliableMaxBytes ? { seqs } : { seqs, pending };
localStorage.setItem('codeman:pendingInput', JSON.stringify(payload));
@@ -2786,6 +2857,74 @@ class CodemanApp {
}
}
+ // ---- durable editable drafts (localStorage; separate from submitted input) --
+
+ _setSessionDraft(sessionId, candidate, persist = true) {
+ return this._inputState.set(sessionId, candidate, { persist });
+ }
+
+ _captureActiveSessionDraft() {
+ const sessionId = this.activeSessionId;
+ const overlay = this._localEchoOverlay;
+ if (!sessionId || !overlay) return null;
+ const state = overlay.state || {};
+ let cjkText = '';
+ try {
+ cjkText = typeof CjkInput !== 'undefined' ? CjkInput.getPendingText?.() || '' : '';
+ } catch {
+ /* the optional textarea may not be initialized yet */
+ }
+ return this._inputState.capture(sessionId, {
+ pendingText: state.pendingText || '',
+ compositionText: state.compositionText || '',
+ flushedText: state.flushedText || '',
+ cjkText,
+ ptyOwned: this._terminalInputController?.isPtyEditing?.(sessionId) === true,
+ });
+ }
+
+ _restoreSessionDraft(sessionId, render = true, options = {}) {
+ if (
+ options.preserveLiveComposition &&
+ sessionId === this.activeSessionId &&
+ this._localEchoOverlay?.compositionText
+ ) {
+ _crashDiag.log('DRAFT_RESTORE_SKIP_ACTIVE_COMPOSITION');
+ if (render) this._localEchoOverlay.rerender();
+ return this._inputState.has(sessionId);
+ }
+ const draft = this._inputState.get(sessionId) || {
+ pendingText: '',
+ flushedText: '',
+ cjkText: '',
+ };
+ this._terminalInputController?.restorePtyEditing?.(
+ sessionId,
+ draft.ptyOwned === true
+ );
+ if (this._localEchoOverlay?.restoreDraft) {
+ this._localEchoOverlay.restoreDraft(
+ {
+ pendingText: draft.pendingText,
+ flushedText: draft.flushedText,
+ },
+ render
+ );
+ }
+ try {
+ if (typeof CjkInput !== 'undefined') {
+ CjkInput.restorePendingText?.(draft.cjkText);
+ }
+ } catch {
+ /* optional CJK input is unavailable */
+ }
+ return this._inputState.has(sessionId);
+ }
+
+ _clearSessionDraft(sessionId) {
+ this._inputState.clear(sessionId);
+ }
+
// Pure render of the header connection indicator: reads only `this.*` state,
// touches NO DOM. Returns the exact { display, dotClass, text, title } tuple the
// writer applies. When hidden (display:'none') the other three are normalized to
@@ -3904,6 +4043,14 @@ class CodemanApp {
}
_cleanupPreviousSession(newSessionId) {
+ const outgoingOverlayState = this._localEchoOverlay?.state || null;
+ let outgoingCjkText = '';
+ try {
+ outgoingCjkText = typeof CjkInput !== 'undefined' ? CjkInput.getPendingText?.() || '' : '';
+ } catch {
+ /* optional CJK input is unavailable */
+ }
+
// Snapshot the OUTGOING session's xterm rendered state (viewport + scrollback +
// colors/attrs) before the terminal gets cleared/reset. Lets us restore the
// exact view on switch-back rather than replaying codex's byte stream, which
@@ -4001,32 +4148,34 @@ class CodemanApp {
// Flush local echo text to PTY before switching tabs.
// Send as a single batch (no Enter) so it lands in the session's readline
// input buffer — avoids "old text resent on Enter" and overlay render bugs.
- // Track flushed length so _render() offsets the overlay correctly even before
- // the PTY echo arrives in the terminal buffer.
+ // The state store preserves the editable draft while pending bytes are
+ // handed to the outgoing PTY without submitting the prompt.
if (this.activeSessionId) {
- const echoText = this._localEchoOverlay?.pendingText || '';
- // Include buffer-detected flushed text (from Tab completion, etc.)
- // so it's preserved across tab switches.
- const existingFlushed = this._localEchoOverlay?.getFlushed()?.count || 0;
- const existingFlushedText = this._localEchoOverlay?.getFlushed()?.text || '';
- if (echoText) {
- this._sendInputAsync(this.activeSessionId, echoText);
- }
- const totalOffset = existingFlushed + echoText.length;
- if (totalOffset > 0) {
- if (!this._flushedOffsets) this._flushedOffsets = new Map();
- if (!this._flushedTexts) this._flushedTexts = new Map();
- this._flushedOffsets.set(this.activeSessionId, totalOffset);
- this._flushedTexts.set(this.activeSessionId, existingFlushedText + echoText);
+ const handoff = this._inputState.handoff(this.activeSessionId, {
+ pendingText: outgoingOverlayState?.pendingText || '',
+ compositionText: outgoingOverlayState?.compositionText || '',
+ flushedText: outgoingOverlayState?.flushedText || '',
+ cjkText: outgoingCjkText,
+ ptyOwned:
+ this._terminalInputController?.isPtyEditing?.(
+ this.activeSessionId
+ ) === true,
+ });
+ if (handoff.flushText) {
+ this._terminalInputController?.flushDraftText?.(
+ handoff.flushText,
+ this.activeSessionId
+ );
}
}
this._localEchoOverlay?.clear();
+ this._terminalInputController?.reset?.();
// Prevent _detectBufferText() from picking up Claude's Ink UI text
// (status bar, model info, etc.) as "user input" on fresh sessions.
// Only sessions with prior flushed text (from tab-switch-away) need detection.
// After the user's first Enter, clear() resets _bufferDetectDone = false,
// re-enabling detection for tab completion and other legitimate cases.
- if (this._localEchoOverlay && !this._flushedOffsets?.has(newSessionId)) {
+ if (this._localEchoOverlay && !this._inputState.hasFlushed(newSessionId)) {
this._localEchoOverlay.suppressBufferDetection();
}
}
@@ -4063,7 +4212,6 @@ class CodemanApp {
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
- this.activeSessionId = null;
}
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
// programmatic focus() within the user-gesture call stack (e.g. tab click).
@@ -4111,17 +4259,11 @@ class CodemanApp {
}
this._updateLocalEchoState();
- // Restore flushed offset AND text IMMEDIATELY so backspace/typing work during
- // the async buffer load. Without this, the offset is 0 during the
+ // Restore the editable draft IMMEDIATELY so backspace/typing work during
+ // the async buffer load. Without this, the offset is 0 during the
// fetch() gap: backspace is swallowed, and typing a space covers the
// canvas text with an opaque overlay showing only the new char.
- if (this._flushedOffsets?.has(sessionId) && this._localEchoOverlay) {
- this._localEchoOverlay.setFlushed(
- this._flushedOffsets.get(sessionId),
- this._flushedTexts?.get(sessionId) || '',
- false // render=false: buffer not loaded yet
- );
- }
+ this._restoreSessionDraft(sessionId, false);
// Glow the newly-active tab
const activeTab = document.querySelector(`.session-tab.active[data-id="${sessionId}"]`);
@@ -4372,12 +4514,12 @@ class CodemanApp {
// Restore flushed offset and text for this session so the overlay positions
// correctly even before the PTY echo arrives in the terminal buffer.
- if (this._flushedOffsets?.has(sessionId) && this._localEchoOverlay) {
- this._localEchoOverlay.setFlushed(
- this._flushedOffsets.get(sessionId),
- this._flushedTexts?.get(sessionId) || '',
- false // render=false: buffer just loaded, defer to rerender
- );
+ if (
+ this._restoreSessionDraft(sessionId, false, {
+ preserveLiveComposition: true,
+ }) &&
+ this._localEchoOverlay
+ ) {
// Trigger render after xterm.js finishes processing the buffer data.
// terminal.write('', callback) fires the callback after ALL previously
// queued writes have been parsed — so findPrompt() can find ❯ in the buffer.
@@ -4520,8 +4662,8 @@ class CodemanApp {
this._xtermSnapshots?.delete(sessionId);
try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {}
- this._flushedOffsets?.delete(sessionId);
- this._flushedTexts?.delete(sessionId);
+ this._clearSessionDraft(sessionId);
+ this._terminalInputController?.forgetSession?.(sessionId);
// Drop any durably-queued input for a session that's actually gone (deleted/
// exited). Not a lost prompt — the target no longer exists. Only reached on
// real session removal, never on a tab switch.
@@ -4706,6 +4848,9 @@ class CodemanApp {
this.terminalBufferCache.clear();
this.terminalLoadStates.clear();
this._xtermSnapshots?.clear();
+ this._inputState.clearAll({ persist: false });
+ this._inputState.persistNow();
+ this._terminalInputController?.clearPtyEditingSessions?.();
try {
for (const k of Object.keys(localStorage)) {
if (k.startsWith('codeman-xs-')) localStorage.removeItem(k);
diff --git a/src/web/public/constants.js b/src/web/public/constants.js
index 1db36064..97a7f0cf 100644
--- a/src/web/public/constants.js
+++ b/src/web/public/constants.js
@@ -14,8 +14,9 @@
* @globals {Array} BUILTIN_RESPAWN_PRESETS - Built-in respawn configuration presets
*
* @dependency None (first in load order)
- * @loadorder 1 of 15 — constants.js → mobile-handlers.js → voice-input.js → notification-manager.js
- * → keyboard-accessory.js → input-cjk.js → app.js → terminal-ui.js → respawn-ui.js
+ * @loadorder 1 of 16 — constants.js → mobile-handlers.js → voice-input.js → notification-manager.js
+ * → keyboard-accessory.js → input-cjk.js → terminal-input-state.js → terminal-input-controller.js
+ * → app.js → terminal-ui.js → respawn-ui.js
* → ralph-panel.js → settings-ui.js → panels-ui.js → session-ui.js → ralph-wizard.js
* → api-client.js → subagent-windows.js
*/
diff --git a/src/web/public/index.html b/src/web/public/index.html
index 224280db..6e2e6fc3 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -2612,6 +2612,8 @@ Save Respawn Preset
+
+
diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js
index 5b8b99a5..e540b716 100644
--- a/src/web/public/mobile-handlers.js
+++ b/src/web/public/mobile-handlers.js
@@ -333,20 +333,17 @@ const KeyboardHandler = {
const accessoryBar = document.querySelector('.keyboard-accessory-bar');
if (isSmallMedium) {
- // Phones/small tablets: toolbar and accessory bar are position:fixed
- // via CSS. Use translateY to lift them above the keyboard.
+ // Phones/small tablets: --app-height already constrains the fixed app to
+ // the visual viewport. Translating the bars by keyboard height again
+ // moves them off-screen and leaves dead space below the terminal.
const toolbar = document.querySelector('.toolbar');
const main = document.querySelector('.main');
- const layoutHeight = window.innerHeight;
- const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
- const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
-
if (toolbar) {
- toolbar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
+ toolbar.style.transform = '';
}
if (accessoryBar) {
- accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
+ accessoryBar.style.transform = '';
}
if (main && keyboardHeight > 0) {
const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
@@ -363,11 +360,8 @@ const KeyboardHandler = {
// CJK textarea positioning (always position:fixed on touch devices).
if (cjkInput?.classList.contains('cjk-input-visible') && keyboardHeight > 0) {
if (isSmallMedium) {
- // Phones: use translateY like toolbar/accessory bar.
- const layoutHeight = window.innerHeight;
- const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
- const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
- cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
+ // The fixed app is already constrained to the visual viewport.
+ cjkInput.style.transform = '';
cjkInput.style.bottom = '';
} else {
// iPad: direct bottom = keyboard + accessory bar height.
diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css
index 95c5064c..91d83fbc 100644
--- a/src/web/public/mobile.css
+++ b/src/web/public/mobile.css
@@ -687,10 +687,9 @@ html.mobile-init .file-browser-panel {
bottom: calc(var(--safe-area-bottom) + (100vh - var(--app-height, 100vh)));
}
- /* When keyboard is visible the JS translateY already accounts for the full
- distance from the visual-viewport bottom to the layout-viewport bottom
- (keyboard + Safari bar). Remove the CSS Safari-bar offset to avoid
- double-counting, which otherwise creates a visible gap above the keyboard. */
+ /* The fixed app is constrained to --app-height while the keyboard is visible.
+ Remove the normal Safari-bar offset so the bottom controls use that visual
+ viewport directly without creating a second gap above the keyboard. */
.keyboard-visible.ios-device.safari-browser .toolbar {
bottom: var(--safe-area-bottom);
}
@@ -1131,9 +1130,9 @@ html.mobile-init .file-browser-panel {
display: none !important;
}
- /* When keyboard is visible, also move accessory bar up */
+ /* Keyboard-visible placement is driven by the constrained app viewport. */
.keyboard-visible .keyboard-accessory-bar.visible {
- /* Position is handled by JS transform along with toolbar */
+ /* Kept as an explicit state hook for device-specific overrides. */
}
/* Paste overlay for iOS clipboard access */
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index 67e2a847..1a22ec7a 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -429,6 +429,13 @@ textarea:focus-visible {
font-size: 16px !important; /* prevent iOS auto-zoom on focus */
}
+/* Local echo owns IME candidate rendering at the detected CLI prompt.
+ * Keep xterm's composition helper active for input finalization, but suppress
+ * its cursor-anchored duplicate once the prompt-aware overlay is enabled. */
+.touch-device .xterm.codeman-local-echo .composition-view.active {
+ display: none !important;
+}
+
/* 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 3a9de819..7a7d9928 100644
--- a/src/web/public/terminal-ui.js
+++ b/src/web/public/terminal-ui.js
@@ -7,14 +7,17 @@
* @dependency app.js (CodemanApp class, this.terminal, this.fitAddon, this.sessions)
* @dependency constants.js (DEC_SYNC_STRIP_RE, TIMING constants)
* @dependency mobile-handlers.js (MobileDetection)
+ * @dependency terminal-input-controller.js (TerminalInputController)
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js, vendor/xterm-addon-webgl.js
* @dependency vendor/xterm-zerolag-input.js (LocalEchoOverlay)
- * @loadorder 7 of 15 — loaded after app.js, before respawn-ui.js
+ * @loadorder 7 of 16 — loaded after app.js, before respawn-ui.js
*/
(function (global) {
- const TERMINAL_QUERY_RESPONSE_PATTERN = /^\x1b\[[\?>=]?[\d;]*[cnR]$/;
+ const TERMINAL_CSI_QUERY_RESPONSE_PATTERN =
+ /^\x1b\[(?:[\?>=]?[\d;]*[cnR]|\??\d+;[0-4]\$y|[468];[\d;]+t)$/;
const TERMINAL_OSC_RESPONSE_PATTERN = /^\x1b\][\d;]*[^\x07\x1b]*(?:\x07|\x1b\\)$/;
+ const TERMINAL_DCS_RESPONSE_PATTERN = /^\x1bP[\s\S]*\x1b\\$/;
// Grace window after a manual scroll-up gesture during which sticky-scroll is
// suppressed, so high-frequency Codex status redraws don't snap the viewport
// back to the bottom while the user is inspecting earlier output.
@@ -25,7 +28,11 @@
const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450;
function isTerminalQueryResponse(data) {
- return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
+ return (
+ TERMINAL_CSI_QUERY_RESPONSE_PATTERN.test(data) ||
+ TERMINAL_OSC_RESPONSE_PATTERN.test(data) ||
+ TERMINAL_DCS_RESPONSE_PATTERN.test(data)
+ );
}
function shouldSuppressTerminalQueryResponse(data) {
@@ -126,6 +133,39 @@ Object.assign(CodemanApp.prototype, {
const container = document.getElementById('terminalContainer');
this.terminal.open(container);
this._installMobileTapMouseGuard();
+ this._terminalInputController?.destroy?.();
+ this._terminalInputController = new TerminalInputController({
+ textarea: this.terminal.textarea,
+ getOverlay: () => this._localEchoOverlay,
+ getSessionId: () => this.activeSessionId,
+ getSessionMode: () =>
+ this.activeSessionId
+ ? this.sessions?.get(this.activeSessionId)?.mode || ''
+ : '',
+ isLocalEchoEnabled: () => this._localEchoEnabled,
+ isRestoringDraft: () => this._restoringFlushedState,
+ isInteractionPrompt: (sessionId) =>
+ this._hasPendingInteractionPrompt(sessionId),
+ captureDraft: () => this._captureActiveSessionDraft(),
+ setDraft: (sessionId, draft) =>
+ this._setSessionDraft(sessionId, draft),
+ clearDraft: (sessionId) =>
+ this._clearSessionDraft(sessionId),
+ deliver: (sessionId, data, options) =>
+ this._sendInputAsync(sessionId, data, options),
+ preparePaste: (text, bracketed) =>
+ this._prepareTerminalPaste(text, bracketed),
+ onTab: (context) =>
+ this._handleTerminalInputTab(context),
+ onTabCancel: () =>
+ this._cancelTerminalInputTab(),
+ onInteractionControl: (sessionId, data) =>
+ this._handleInteractionPromptControl(sessionId, data),
+ log: (message) => _crashDiag.log(message),
+ });
+ this._terminalInputController.attachTextarea(container, {
+ mobile: MobileDetection.isTouchDevice(),
+ });
// Suppress xterm key handling during CJK IME composition.
// Without this, xterm processes raw keyDown events (e.g., "Process" key)
@@ -172,104 +212,19 @@ Object.assign(CodemanApp.prototype, {
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)
// which the inner application recognizes as "insert newline" vs carriage return.
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') {
- if (this.activeSessionId) {
- if (this._localEchoEnabled) {
- const text = this._localEchoOverlay?.pendingText || '';
- this._localEchoOverlay?.clear();
- this._localEchoOverlay?.suppressBufferDetection();
- this._flushedOffsets?.delete(this.activeSessionId);
- this._flushedTexts?.delete(this.activeSessionId);
- if (text) {
- this._pendingInput += text;
- flushInput();
- }
- setTimeout(() => {
- fetch(`/api/sessions/${this.activeSessionId}/send-key`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
- });
- }, text ? 80 : 0);
- } else {
- fetch(`/api/sessions/${this.activeSessionId}/send-key`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
- });
- }
- }
+ this._terminalInputController.sendModifiedEnter(
+ ev.ctrlKey ? 'C-Enter' : 'S-Enter'
+ );
return false;
}
return true;
});
- // Android virtual keyboard fix: catch non-composition input events.
- // On Android Chrome, typing symbols (e.g., "/" from Gboard's symbol keyboard)
- // sends keyCode 229 + input event WITHOUT compositionstart/end wrapping.
- // The custom key handler above returns false for keyCode 229, telling xterm
- // to ignore the keydown. xterm.js expects the character to arrive via
- // composition events, but since there's no composition, the character is lost.
- // This listener catches those orphaned input events and forwards them to onData.
- {
- const xtermTextarea = container.querySelector('.xterm-helper-textarea');
- if (xtermTextarea && MobileDetection.isTouchDevice()) {
- let composing = false;
- let lastKeydownHandled = 0;
- xtermTextarea.addEventListener('compositionstart', () => { composing = true; });
- xtermTextarea.addEventListener('compositionend', () => { composing = false; });
- // Track when xterm handles a keydown normally (non-229 keyCode).
- // If xterm processed the keydown, it will emit onData itself --
- // the input event handler below must NOT re-send the character.
- xtermTextarea.addEventListener('keydown', (e) => {
- if (!e.isComposing && e.keyCode !== 229) {
- lastKeydownHandled = Date.now();
- }
- });
- xtermTextarea.addEventListener('input', (e) => {
- // Only handle insertText events outside of composition -- these are
- // the ones xterm.js misses on Android virtual keyboards.
- if (composing || e.isComposing) return;
- if (e.inputType !== 'insertText' || !e.data) return;
- // If xterm just handled a keydown (within 50ms), it already sent the
- // char via onData. Skip to avoid double-send (e.g., Shift+A => AA).
- if (Date.now() - lastKeydownHandled < 50) return;
- // xterm.js may have already processed this via its own input handler.
- // Check if the textarea was cleared by xterm (value is empty or just
- // whitespace) -- if so, xterm handled it and we should not double-send.
- // Use a microtask to check after xterm's own handlers have run.
- const data = e.data;
- const pendingBefore = this._localEchoOverlay?.pendingText || '';
- Promise.resolve().then(() => {
- if (
- this._lastTerminalData?.data === data &&
- performance.now() - this._lastTerminalData.time < 100
- ) {
- xtermTextarea.value = '';
- return;
- }
- const pendingAfter = this._localEchoOverlay?.pendingText || '';
- if (
- this._localEchoEnabled &&
- pendingAfter.length > pendingBefore.length &&
- pendingAfter.endsWith(data)
- ) {
- xtermTextarea.value = '';
- return;
- }
- // If xterm cleared the textarea, it processed the input -- skip.
- const val = xtermTextarea.value;
- if (!val || (val.trim() === '' && data !== ' ')) return;
- // xterm didn't process it -- forward to terminal as if typed.
- // Emit via onData path by writing to terminal's input handler.
- this.terminal._core.coreService.triggerDataEvent(data, true);
- // Clear the textarea to prevent xterm from processing it later.
- xtermTextarea.value = '';
- });
- });
- }
- }
-
+ // Android IME, helper-textarea mutation, and composition arbitration
+ // are installed by TerminalInputController.attachTextarea() above.
+ // Paste capture and Android segmented-paste fallback use the same
+ // controller so clipboard mutations cannot race ordinary IME input.
// WebGL renderer for GPU-accelerated terminal rendering.
// Previously caused "page unresponsive" crashes from synchronous GPU stalls,
// but the 48KB/frame flush cap in flushPendingWrites() now prevents
@@ -339,8 +294,15 @@ Object.assign(CodemanApp.prototype, {
this._cjkInput = null;
if (typeof CjkInput !== 'undefined') {
this._cjkInput = CjkInput.init({
- send: (text) => {
- this._handleCjkInput(text);
+ sendText: (text) =>
+ this._terminalInputController.insertText(text),
+ sendControl: (data) =>
+ this._terminalInputController.sendControl(data),
+ paste: (text) => {
+ this.sendPastedText(text);
+ },
+ draftChanged: () => {
+ this._captureActiveSessionDraft();
},
});
}
@@ -691,266 +653,42 @@ Object.assign(CodemanApp.prototype, {
this.terminalResizeObserver = new ResizeObserver(throttledResize);
this.terminalResizeObserver.observe(container);
- // Handle keyboard input — send to PTY immediately, no local echo.
- // PTY/Ink handles all character echoing to avoid desync ("typing visible below" bug).
- this._pendingInput = '';
- this._inputFlushTimeout = null;
- this._lastKeystrokeTime = 0;
-
- const flushInput = () => {
- this._inputFlushTimeout = null;
- if (this._pendingInput && this.activeSessionId) {
- const input = this._pendingInput;
- const sessionId = this.activeSessionId;
- this._pendingInput = '';
- this._sendInputAsync(sessionId, input);
- }
- };
-
- // Local echo mode: buffer keystrokes locally (shown in overlay) and only
- // send to PTY on Enter. Avoids out-of-order delivery on high-latency
- // mobile connections. The overlay + localStorage persistence ensure input
- // survives tab switches and reconnects.
-
+ // xterm is an adapter only. The controller owns semantic input state,
+ // local echo, batching, control ordering, and IME deduplication.
this.terminal.onData((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
- // is on, because cjkActive stays true the whole time the field is visible.
const isMouseReport = /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data);
- // CJK input has focus — block xterm from sending keystrokes to PTY
- if (!isMouseReport && (window.cjkActive || document.activeElement?.id === 'cjkInput')) {
- // Self-heal: if the CJK field is visible but focus drifted to xterm's
- // hidden textarea (e.g. something called terminal.focus()), everything
- // typed lands HERE and is swallowed — keyboard shows the IME composing
- // while both the CJK field and the terminal stay empty. Route focus
- // back so the very next keystroke lands in the CJK field again.
- // Only GENUINE typed input qualifies: onData also fires for xterm's
- // self-generated query replies (DA/DSR/CPR/OSC during Ink redraws),
- // which arrive no matter what has focus — so require focus to be on
- // xterm's own textarea and bail on query replies, or this would steal
- // focus from the rename/search/settings inputs while output streams.
+ if (
+ !isMouseReport &&
+ (window.cjkActive ||
+ document.activeElement?.id === 'cjkInput')
+ ) {
const cjkEl = document.getElementById('cjkInput');
if (
cjkEl?.classList.contains('cjk-input-visible') &&
document.activeElement === this.terminal.textarea &&
- !window.CodemanTerminalInput?.shouldSuppressTerminalQueryResponse(data)
+ !window.CodemanTerminalInput?.shouldSuppressTerminalQueryResponse(
+ data
+ )
) {
- _crashDiag.log('CJK regain-focus (onData swallowed input)');
+ _crashDiag.log(
+ 'CJK regain-focus (onData swallowed input)'
+ );
cjkEl.focus();
}
return;
}
- if (this.activeSessionId) {
- // Filter terminal query replies generated by xterm.js itself.
- // Forwarding them through the WebSocket injects DA/DSR/CPR replies
- // into the foreground process as typed input (for example "0;276;0c").
- if (
- window.CodemanTerminalInput?.shouldSuppressTerminalQueryResponse(data)
- ) {
- return;
- }
- this._lastTerminalData = { data, time: performance.now() };
-
- // ── Local Echo Mode ──
- // 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) {
- if (data === '\x7f') {
- const source = this._localEchoOverlay?.removeChar();
- if (source === 'flushed') {
- // Sync app-level flushed Maps (per-session state for tab switching)
- const { count, text } = this._localEchoOverlay.getFlushed();
- if (this._flushedOffsets?.has(this.activeSessionId)) {
- if (count === 0) {
- this._flushedOffsets.delete(this.activeSessionId);
- this._flushedTexts?.delete(this.activeSessionId);
- } else {
- this._flushedOffsets.set(this.activeSessionId, count);
- this._flushedTexts?.set(this.activeSessionId, text);
- }
- }
- this._pendingInput += data;
- flushInput();
- }
- // 'pending' = removed unsent text (no PTY backspace needed)
- // false = nothing to remove (swallow the backspace)
- return;
- }
- if (/^[\r\n]+$/.test(data)) {
- // Enter: send full buffered text + \r to PTY in one shot
- const text = this._localEchoOverlay?.pendingText || '';
- this._localEchoOverlay?.clear();
- // Suppress detection so PTY-echoed text isn't re-detected as user input
- this._localEchoOverlay?.suppressBufferDetection();
- // Clear flushed offset and text — Enter commits all text
- this._flushedOffsets?.delete(this.activeSessionId);
- this._flushedTexts?.delete(this.activeSessionId);
- if (this._inputFlushTimeout) {
- clearTimeout(this._inputFlushTimeout);
- this._inputFlushTimeout = null;
- }
- if (text) {
- this._pendingInput += text;
- flushInput();
- }
- // Send \r after a short delay so text arrives first
- setTimeout(() => {
- this._pendingInput += '\r';
- flushInput();
- }, 80);
- return;
- }
- if (data.length > 1 && data.charCodeAt(0) >= 32) {
- // Paste: append to overlay only (sent on Enter)
- this._localEchoOverlay?.appendText(data);
- return;
- }
- if (data.charCodeAt(0) < 32) {
- // Skip xterm-generated terminal responses.
- // These arrive via triggerDataEvent when the terminal processes
- // buffer data (DA responses, OSC color queries, mode reports, etc.).
- // They are NOT user input and must not clear flushed text state.
- // Covers: CSI (\x1b[), OSC (\x1b]), DCS (\x1bP), APC (\x1b_),
- // PM (\x1b^), SOS (\x1bX), and any other multi-byte ESC sequence.
- // Single-byte ESC (user pressing Escape) still falls through to
- // the control char handler below.
- if (data.length > 1 && data.charCodeAt(0) === 27) {
- // Multi-byte escape sequence — forward to PTY without clearing
- // overlay/flushed state (terminal response, not user input)
- this._pendingInput += data;
- flushInput();
- return;
- }
- // During buffer load (tab switch), stray control chars from
- // terminal response processing must not wipe the flushed state
- // that selectSession() is actively restoring.
- if (this._restoringFlushedState) {
- this._pendingInput += data;
- flushInput();
- return;
- }
- // Tab key: send pending text + Tab to PTY for tab completion.
- // Set a flag so flushPendingWrites() re-detects buffer text when
- // the PTY response arrives (event-driven, no fixed timer).
- if (data === '\t') {
- const text = this._localEchoOverlay?.pendingText || '';
- this._localEchoOverlay?.clear();
- this._flushedOffsets?.delete(this.activeSessionId);
- this._flushedTexts?.delete(this.activeSessionId);
- if (text) {
- this._pendingInput += text;
- }
- this._pendingInput += data;
- if (this._inputFlushTimeout) {
- clearTimeout(this._inputFlushTimeout);
- this._inputFlushTimeout = null;
- }
- // Snapshot prompt line text BEFORE flushing — used to distinguish
- // real Tab completions from pre-existing Claude UI text.
- let baseText = '';
- try {
- const p = this._localEchoOverlay?.findPrompt?.();
- if (p) {
- const buf = this.terminal.buffer.active;
- const line = buf.getLine(buf.viewportY + p.row);
- if (line)
- baseText = line
- .translateToString(true)
- .slice(p.col + 2)
- .trimEnd();
- }
- } catch {}
- this._tabCompletionBaseText = baseText;
- flushInput();
- this._tabCompletionSessionId = this.activeSessionId;
- this._tabCompletionRetries = 0;
- // Fallback: if flushPendingWrites() detection misses the completion
- // (e.g., flicker filter delays data, or xterm hasn't processed writes
- // by the time the callback fires), retry detection after a delay.
- // This ensures the overlay renders even without further terminal data.
- if (this._tabCompletionFallback) clearTimeout(this._tabCompletionFallback);
- const selfTab = this;
- this._tabCompletionFallback = setTimeout(() => {
- selfTab._tabCompletionFallback = null;
- if (!selfTab._tabCompletionSessionId || selfTab._tabCompletionSessionId !== selfTab.activeSessionId)
- return;
- const ov = selfTab._localEchoOverlay;
- if (!ov || ov.pendingText) return;
- selfTab.terminal.write('', () => {
- if (!selfTab._tabCompletionSessionId) return;
- ov.resetBufferDetection();
- const detected = ov.detectBufferText();
- if (detected && detected !== selfTab._tabCompletionBaseText) {
- selfTab._tabCompletionSessionId = null;
- selfTab._tabCompletionRetries = 0;
- selfTab._tabCompletionBaseText = null;
- ov.rerender();
- }
- });
- }, 300);
- return;
- }
- // Control chars (Ctrl+C, single ESC): send buffered text + control char immediately
- const text = this._localEchoOverlay?.pendingText || '';
- this._localEchoOverlay?.clear();
- // Suppress detection so PTY-echoed text isn't re-detected as user input
- this._localEchoOverlay?.suppressBufferDetection();
- // Clear flushed offset and text — control chars (Ctrl+C, Escape) change
- // cursor position or abort readline, making flushed text tracking invalid.
- this._flushedOffsets?.delete(this.activeSessionId);
- this._flushedTexts?.delete(this.activeSessionId);
- if (text) {
- this._pendingInput += text;
- }
- this._pendingInput += data;
- if (this._inputFlushTimeout) {
- clearTimeout(this._inputFlushTimeout);
- this._inputFlushTimeout = null;
- }
- flushInput();
- return;
- }
- if (data.length === 1 && data.charCodeAt(0) >= 32) {
- // Printable char: add to overlay only (sent on Enter)
- this._localEchoOverlay?.addChar(data);
- return;
- }
- }
-
- // ── Normal Mode (echo disabled) ──
- this._pendingInput += data;
-
- // Control chars (Enter, Ctrl+C, escape sequences) — flush immediately
- if (data.charCodeAt(0) < 32 || data.length > 1) {
- if (this._inputFlushTimeout) {
- clearTimeout(this._inputFlushTimeout);
- this._inputFlushTimeout = null;
- }
- flushInput();
- return;
- }
-
- // Regular chars — flush immediately if typed after a gap (>50ms),
- // otherwise batch via microtask to coalesce rapid keystrokes (paste).
- const now = performance.now();
- if (now - this._lastKeystrokeTime > 50) {
- // Single char after a gap — send immediately, no setTimeout latency
- if (this._inputFlushTimeout) {
- clearTimeout(this._inputFlushTimeout);
- this._inputFlushTimeout = null;
- }
- this._lastKeystrokeTime = now;
- flushInput();
- } else {
- // Rapid sequence (paste or fast typing) — coalesce via microtask
- this._lastKeystrokeTime = now;
- if (!this._inputFlushTimeout) {
- this._inputFlushTimeout = setTimeout(flushInput, 0);
- }
- }
+ if (!this.activeSessionId) return;
+ if (
+ window.CodemanTerminalInput?.shouldSuppressTerminalQueryResponse(
+ data
+ )
+ ) {
+ return;
}
+ this._terminalInputController.handleTerminalData(
+ data,
+ 'xterm'
+ );
});
},
@@ -2074,11 +1812,12 @@ Object.assign(CodemanApp.prototype, {
const settings = this.loadAppSettingsFromStorage();
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
- const shouldEnable = !!(echoEnabled && session);
+ const shouldEnable = !!(echoEnabled && session && session.mode !== 'shell');
if (this._localEchoEnabled && !shouldEnable) {
this._localEchoOverlay?.clear();
}
this._localEchoEnabled = shouldEnable;
+ this.terminal?.element?.classList.toggle('codeman-local-echo', shouldEnable);
// Swap prompt finder based on session mode
if (this._localEchoOverlay && session) {
@@ -2110,27 +1849,36 @@ Object.assign(CodemanApp.prototype, {
this._localEchoOverlay.clear();
this._localEchoEnabled = false;
} else {
- // Codex/Claude-style TUIs usually expose a ❯ prompt. During active
- // redraws or compact mobile layouts that marker may not be present in
- // the viewport, while xterm's cursor still marks the editable input
- // position. Fall back to cursor coordinates so phone typing appears at
- // the terminal cursor instead of disappearing into pending state.
+ // Codex/Claude-style TUIs expose an editable prompt as › or ❯. During
+ // initial buffer replay xterm's provisional cursor is at row zero; it
+ // is parser state, not an input anchor, so retain the draft invisibly
+ // until the authoritative frame has loaded. Once loaded, cursor fallback
+ // is allowed only near the bottom where these TUIs place marker-less
+ // input during compact redraws.
this._localEchoOverlay.setPrompt({
type: 'custom',
offset: 0,
find: (terminal) => {
try {
+ if (this._isLoadingBuffer) return null;
const buf = terminal.buffer.active;
+ const rows = Math.max(1, terminal.rows || 1);
+ let hasVisibleContent = false;
for (let row = terminal.rows - 1; row >= 0; row--) {
const line = buf.getLine(buf.viewportY + row);
if (!line) continue;
const text = line.translateToString(true);
- const idx = text.lastIndexOf('\u276f');
- if (idx >= 0) return { row, col: idx + 2 };
+ if (text.trim()) hasVisibleContent = true;
+ const prompt = text.match(/^(\s*)[\u203a\u276f]/);
+ if (prompt) return { row, col: prompt[1].length + 2 };
}
+ const cursorRow = Math.max(0, Math.min(rows - 1, buf.cursorY || 0));
+ const cursorCol = Math.max(0, Math.min(terminal.cols - 1, buf.cursorX || 0));
+ if (!hasVisibleContent && cursorRow === 0 && cursorCol === 0) return null;
+ if (cursorRow < Math.max(0, rows - 3)) return null;
return {
- row: Math.max(0, Math.min(terminal.rows - 1, buf.cursorY)),
- col: Math.max(0, Math.min(terminal.cols - 1, buf.cursorX)),
+ row: cursorRow,
+ col: cursorCol,
};
} catch {
return null;
@@ -2141,17 +1889,6 @@ Object.assign(CodemanApp.prototype, {
}
},
- // CJK textarea already provides visual feedback — bypass local echo
- // buffering so each composed word reaches the PTY immediately.
- _handleCjkInput(text) {
- if (!this.activeSessionId) {
- _crashDiag.log(`CJK send DROP no-session len=${text.length}`);
- return;
- }
- _crashDiag.log(`CJK send→${this.activeSessionId.slice(0, 8)} len=${text.length}`);
- this._sendInputAsync(this.activeSessionId, text);
- },
-
/**
* Flush pending writes to terminal, processing DEC 2026 sync markers.
* Strips markers and writes content atomically within a single frame.
@@ -2176,16 +1913,23 @@ Object.assign(CodemanApp.prototype, {
const activeSession = this.activeSessionId && this.sessions ? this.sessions.get(this.activeSessionId) : null;
const MAX_FRAME_BYTES = activeSession?.mode === 'codex' ? 32768 : 65536;
let deferred = false;
+ const rerenderPendingDraft = this._localEchoOverlay?.hasPending
+ ? () => {
+ if (this._localEchoOverlay?.hasPending) {
+ this._localEchoOverlay.rerender();
+ }
+ }
+ : undefined;
// If the user recently scrolled up, remember the viewport so we can restore
// it after the write — Codex status redraws would otherwise jump it.
const preserveViewportY =
this._hasRecentUserScrollUp() && this.terminal.buffer?.active ? this.terminal.buffer.active.viewportY : null;
if (_joinedLen <= MAX_FRAME_BYTES) {
- this.terminal.write(joined);
+ this.terminal.write(joined, rerenderPendingDraft);
} else {
// Write first chunk now, defer rest to next frame
- this.terminal.write(joined.slice(0, MAX_FRAME_BYTES));
+ this.terminal.write(joined.slice(0, MAX_FRAME_BYTES), rerenderPendingDraft);
this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES));
deferred = true;
if (!this.writeFrameScheduled) {
@@ -2218,12 +1962,6 @@ Object.assign(CodemanApp.prototype, {
this.terminal.scrollToBottom();
}
- // Re-position local echo overlay after terminal writes — Ink redraws can
- // move the ❯ prompt to a different row, making the overlay invisible.
- if (this._localEchoOverlay?.hasPending) {
- this._localEchoOverlay.rerender();
- }
-
// After Tab completion: detect the completed text in the overlay.
// Use terminal.write('', callback) to defer detection until xterm.js
// finishes processing ALL queued writes — direct buffer reads after
@@ -2246,32 +1984,93 @@ Object.assign(CodemanApp.prototype, {
overlay.undoDetection();
self._tabCompletionRetries = (self._tabCompletionRetries || 0) + 1;
if (self._tabCompletionRetries > 60) {
- self._tabCompletionSessionId = null;
- self._tabCompletionRetries = 0;
+ self._cancelTerminalInputTab();
}
} else {
// Text changed — real completion happened
self._tabCompletionSessionId = null;
self._tabCompletionRetries = 0;
self._tabCompletionBaseText = null;
+ self._terminalInputController?.resolveTabCompletion?.();
if (self._tabCompletionFallback) {
clearTimeout(self._tabCompletionFallback);
self._tabCompletionFallback = null;
}
overlay.rerender();
+ self._captureActiveSessionDraft();
}
} else {
// No text found yet — retry on next flush.
self._tabCompletionRetries = (self._tabCompletionRetries || 0) + 1;
if (self._tabCompletionRetries > 60) {
- self._tabCompletionSessionId = null;
- self._tabCompletionRetries = 0;
+ self._cancelTerminalInputTab();
}
}
});
}
},
+ _handleTerminalInputTab({ overlay, sessionId }) {
+ let baseText = '';
+ try {
+ const prompt = overlay?.findPrompt?.();
+ if (prompt) {
+ const buffer = this.terminal.buffer.active;
+ const line = buffer.getLine(
+ buffer.viewportY + prompt.row
+ );
+ if (line) {
+ baseText = line
+ .translateToString(true)
+ .slice(prompt.col + 2)
+ .trimEnd();
+ }
+ }
+ } catch {}
+ this._tabCompletionBaseText = baseText;
+ this._tabCompletionSessionId = sessionId;
+ this._tabCompletionRetries = 0;
+
+ this._clearTimer('_tabCompletionFallback');
+ this._tabCompletionFallback = setTimeout(() => {
+ this._tabCompletionFallback = null;
+ if (
+ !this._tabCompletionSessionId ||
+ this._tabCompletionSessionId !==
+ this.activeSessionId
+ ) {
+ return;
+ }
+ const liveOverlay = this._localEchoOverlay;
+ if (!liveOverlay || liveOverlay.pendingText) return;
+ this.terminal.write('', () => {
+ if (!this._tabCompletionSessionId) return;
+ liveOverlay.resetBufferDetection();
+ const detected = liveOverlay.detectBufferText();
+ if (
+ detected &&
+ detected !== this._tabCompletionBaseText
+ ) {
+ this._tabCompletionSessionId = null;
+ this._tabCompletionRetries = 0;
+ this._tabCompletionBaseText = null;
+ this._terminalInputController?.resolveTabCompletion?.();
+ liveOverlay.rerender();
+ this._captureActiveSessionDraft();
+ }
+ });
+ }, 300);
+ return true;
+ },
+
+ _cancelTerminalInputTab() {
+ this._tabCompletionSessionId = null;
+ this._tabCompletionRetries = 0;
+ this._tabCompletionBaseText = null;
+ this._clearTimer('_tabCompletionFallback');
+ this._terminalInputController?.resolveTabCompletion?.();
+ },
+
/**
* Schedule cb via THREE racing primitives so data-pacing makes progress
* regardless of which scheduling primitive Chrome is throttling:
@@ -2495,6 +2294,18 @@ Object.assign(CodemanApp.prototype, {
this.batchTerminalWrite(data);
}
}
+ // A user can type while selectSession() is replaying its initial frame.
+ // Local echo retains those characters but deliberately hides them while
+ // xterm's cursor is provisional. Re-anchor after all already-queued writes
+ // are parsed; queued live prompt events schedule their own later rerender.
+ const overlay = this._localEchoOverlay;
+ if (overlay?.hasPending && this.terminal?.write) {
+ this.terminal.write('', () => {
+ if (!this._isLoadingBuffer && overlay === this._localEchoOverlay && overlay.hasPending) {
+ overlay.rerender();
+ }
+ });
+ }
return true;
},
@@ -2509,11 +2320,7 @@ Object.assign(CodemanApp.prototype, {
/** Insert editable text at the active prompt without pressing Enter. */
insertTerminalText(text) {
if (!this.activeSessionId || !text) return;
- if (this._localEchoEnabled && this._localEchoOverlay) {
- this._localEchoOverlay.appendText(text);
- } else {
- this.sendInput(text).catch(() => {});
- }
+ this._terminalInputController.insertText(text);
this.terminal?.focus();
},
@@ -2525,27 +2332,7 @@ Object.assign(CodemanApp.prototype, {
if (!this.activeSessionId) return;
if (typeof CjkInput !== 'undefined') CjkInput.clear();
- if (this._inputFlushTimeout) {
- clearTimeout(this._inputFlushTimeout);
- this._inputFlushTimeout = null;
- }
- this._pendingInput = '';
-
- if (this._localEchoEnabled && this._localEchoOverlay) {
- const flushed = this._localEchoOverlay.getFlushed?.() || { count: 0, text: '' };
- this._localEchoOverlay.clear();
- this._localEchoOverlay.suppressBufferDetection();
- this._flushedOffsets?.delete(this.activeSessionId);
- this._flushedTexts?.delete(this.activeSessionId);
- if (flushed.count > 0) {
- this.sendInput('\x7f'.repeat(flushed.count)).catch(() => {});
- }
- } else {
- // In non-local-echo mode the TUI already owns the editable buffer. Ctrl+U
- // is the conventional kill-line key supported by shells and agent TUIs.
- this.sendInput('\x15').catch(() => {});
- }
-
+ this._terminalInputController.clearInput();
this.showToast?.('Input cleared', 'success');
this.terminal?.focus();
},
@@ -2815,6 +2602,16 @@ Object.assign(CodemanApp.prototype, {
this._sendSyntheticSgrTap(ev.clientX, ev.clientY);
},
+ /**
+ * Send one terminal control key without focusing xterm.
+ * The existing durable input queue preserves ordering and reconnect safety.
+ */
+ sendTerminalKey(input) {
+ const sessionId = this.activeSessionId;
+ if (!sessionId || !input) return;
+ this._terminalInputController.sendControl(input);
+ },
+
_installMobileTapMouseGuard() {
const el = this.terminal?.element;
if (!el || el._codemanTapMouseGuardInstalled) return;
@@ -2939,10 +2736,51 @@ Object.assign(CodemanApp.prototype, {
*/
async sendInput(input) {
if (!this.activeSessionId || !input) return;
- // Route through the durable, exactly-once delivery layer (useMux for the
- // POST fallback) so voice / keyboard-accessory / paste input also survives a
- // dropped link instead of being lost in a single best-effort fetch.
- this._sendInputAsync(this.activeSessionId, input, { useMux: true });
+ this._terminalInputController.sendExternalText(input);
+ },
+
+ /** Submit a command through the same draft and Enter ordering as typed input. */
+ sendTerminalCommand(command) {
+ this._terminalInputController?.sendCommand(command);
+ },
+
+ /**
+ * Normalize clipboard line endings and optionally wrap them in the terminal's
+ * bracketed-paste protocol. Replayed TUI buffers often omit the one-time
+ * DECSET 2004 enable, so browser xterm state alone cannot be trusted here.
+ */
+ _prepareTerminalPaste(text, bracketed) {
+ const normalized = String(text ?? '').replace(/\r?\n/g, '\r');
+ return bracketed ? `\x1b[200~${normalized}\x1b[201~` : normalized;
+ },
+
+ /**
+ * Deliver clipboard/compose text as one ordered terminal paste operation.
+ * TUI sessions receive explicit bracketed-paste framing so embedded blank
+ * lines remain part of the draft instead of acting as Enter submissions.
+ *
+ * @param {string} text
+ * @param {{submit?: boolean}} [options]
+ */
+ async sendPastedText(text, options = {}) {
+ const sessionId = this.activeSessionId;
+ if (!sessionId || !text) return;
+ const mode = this.sessions?.get(sessionId)?.mode;
+ const lineBreaks = String(text).match(/\r\n|\r|\n/g)?.length || 0;
+ _crashDiag.log(`PASTE_SEND mode=${mode || 'unknown'} len=${String(text).length} breaks=${lineBreaks}`);
+ if (mode === 'shell') {
+ const input = this._prepareTerminalPaste(
+ text,
+ this.terminal?.modes?.bracketedPasteMode === true
+ );
+ this._terminalInputController.handleTerminalData(
+ input,
+ 'shell-paste'
+ );
+ if (options.submit) this._terminalInputController.sendControl('\r');
+ return;
+ }
+ this._terminalInputController.sendPaste(text, options);
},
// ═══════════════════════════════════════════════════════════════
diff --git a/test/input-send-order.test.ts b/test/input-send-order.test.ts
index 1644f37d..b9f706a8 100644
--- a/test/input-send-order.test.ts
+++ b/test/input-send-order.test.ts
@@ -19,6 +19,14 @@ import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
+const reliableStorage = {
+ length: 0,
+ key: vi.fn(),
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+};
+
function loadCodemanAppClass() {
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
@@ -34,13 +42,7 @@ function loadCodemanAppClass() {
WebSocket: { OPEN: 1 },
fetch: (...args: Parameters) => global.fetch(...args),
document: { addEventListener: vi.fn() },
- localStorage: {
- length: 0,
- key: vi.fn(),
- getItem: vi.fn(),
- setItem: vi.fn(),
- removeItem: vi.fn(),
- },
+ localStorage: reliableStorage,
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
MobileDetection: {},
});
@@ -62,7 +64,11 @@ type PostBody = { input: string; seq: number; clientId: string };
type App = {
_sendInputAsync: (sessionId: string, input: string, opts?: { useMux?: boolean }) => void;
+ _persistReliableNow: () => void;
_pendingDeliveries: Map>;
+ _reliableMaxBytes: number;
+ _postDraining: Set;
+ _onWsReady: (sessionId: string) => void;
_ws: { readyState: number; send: (data: string) => void } | null;
_wsSessionId: string | null;
activeSessionId: string | null;
@@ -75,12 +81,12 @@ function makeApp(): App {
app._pendingDeliveries = new Map();
app._postDraining = new Set();
app._persistReliableState = vi.fn();
- app._persistReliableNow = vi.fn();
app._updateConnectionIndicator = vi.fn();
app.clearPendingHooks = vi.fn();
app.activeSessionId = 'session-1';
app.isOnline = true;
app._connectionStatus = 'connected';
+ app._reliableMaxBytes = 2 * 1024 * 1024;
app._ws = null;
app._wsSessionId = null;
return app as unknown as App;
@@ -103,6 +109,26 @@ describe('durable input delivery — send ordering', () => {
expect(frames.every((f) => f.t === 'i' && f.cid === 'c-test')).toBe(true);
});
+ it('splits a large WebSocket payload without breaking Unicode or order', () => {
+ const app = makeApp();
+ const frames: Frame[] = [];
+ app._ws = {
+ readyState: 1,
+ send: (data: string) => frames.push(JSON.parse(data)),
+ };
+ app._wsSessionId = 'session-1';
+ const input = 'x'.repeat(60 * 1024 - 1) + '😀' + 'y'.repeat(10 * 1024);
+
+ app._sendInputAsync('session-1', input);
+
+ expect(frames).toHaveLength(2);
+ expect(frames.every((frame) => frame.d.length <= 60 * 1024)).toBe(true);
+ expect(frames.map((frame) => frame.d).join('')).toBe(input);
+ expect(frames.map((frame) => frame.seq)).toEqual([1, 2]);
+ expect(frames[0].d.endsWith('\ud83d')).toBe(false);
+ expect(frames[1].d.startsWith('\ude00')).toBe(false);
+ });
+
it('POSTs queued input one frame at a time in seq order when no socket is open', async () => {
const app = makeApp();
const calls: PostBody[] = [];
@@ -129,6 +155,111 @@ describe('durable input delivery — send ordering', () => {
await waitForCalls(calls, 2);
});
+ it('finishes an in-flight POST before resuming delivery over WebSocket', async () => {
+ const app = makeApp();
+ const calls: PostBody[] = [];
+ const frames: Frame[] = [];
+ let finishPost!: () => void;
+ global.fetch = vi.fn(async (_url, init) => {
+ calls.push(JSON.parse(String(init?.body)) as PostBody);
+ await new Promise((resolve) => {
+ finishPost = resolve;
+ });
+ return new Response('{}', { status: 200 });
+ });
+
+ app._sendInputAsync('session-1', 'a');
+ app._sendInputAsync('session-1', 'b');
+ await waitForCalls(calls, 1);
+
+ app._ws = {
+ readyState: 1,
+ send: (data: string) => frames.push(JSON.parse(data)),
+ };
+ app._wsSessionId = 'session-1';
+ app._onWsReady('session-1');
+ expect(frames).toEqual([]);
+
+ finishPost();
+ await vi.waitFor(() => {
+ expect(frames.map((frame) => frame.d)).toEqual(['b']);
+ });
+ });
+
+ it('retries a failed in-flight POST before later WebSocket frames', async () => {
+ const app = makeApp();
+ const calls: PostBody[] = [];
+ const frames: Frame[] = [];
+ let finishPost!: () => void;
+ global.fetch = vi.fn(async (_url, init) => {
+ calls.push(JSON.parse(String(init?.body)) as PostBody);
+ await new Promise((resolve) => {
+ finishPost = resolve;
+ });
+ return new Response('busy', { status: 503 });
+ });
+
+ app._sendInputAsync('session-1', 'a');
+ app._sendInputAsync('session-1', 'b');
+ await waitForCalls(calls, 1);
+
+ app._ws = {
+ readyState: 1,
+ send: (data: string) => frames.push(JSON.parse(data)),
+ };
+ app._wsSessionId = 'session-1';
+ app._onWsReady('session-1');
+ expect(frames).toEqual([]);
+
+ finishPost();
+ await vi.waitFor(() => {
+ expect(frames.map((frame) => frame.d)).toEqual(['a', 'b']);
+ });
+ expect(frames.map((frame) => frame.seq)).toEqual([1, 2]);
+ });
+
+ it('serializes every large POST frame in order', async () => {
+ const app = makeApp();
+ const calls: PostBody[] = [];
+ global.fetch = vi.fn(async (_url, init) => {
+ calls.push(JSON.parse(String(init?.body)) as PostBody);
+ return new Response('{}', { status: 200 });
+ });
+ const input = 'x'.repeat(60 * 1024 - 1) + '😀' + 'y'.repeat(10 * 1024);
+
+ app._sendInputAsync('session-1', input);
+ await waitForCalls(calls, 2);
+
+ expect(calls).toHaveLength(2);
+ expect(calls.every((call) => call.input.length <= 60 * 1024)).toBe(true);
+ expect(calls.map((call) => call.input).join('')).toBe(input);
+ expect(calls.map((call) => call.seq)).toEqual([1, 2]);
+ });
+
+ it('persists all frames of a supported large paste before ACK', () => {
+ const app = makeApp();
+ app._ws = {
+ readyState: 1,
+ send: vi.fn(),
+ };
+ app._wsSessionId = 'session-1';
+ const input = `\x1b[200~${'x'.repeat(300 * 1024)}\x1b[201~`;
+ reliableStorage.setItem.mockClear();
+
+ app._sendInputAsync('session-1', input);
+ app._persistReliableNow();
+
+ const persistedCall = reliableStorage.setItem.mock.calls.find(([key]) => key === 'codeman:pendingInput');
+ expect(persistedCall).toBeDefined();
+ const persisted = JSON.parse(String(persistedCall?.[1]));
+ const records = persisted.pending['session-1'] as Array<{
+ data: string;
+ seq: number;
+ }>;
+ expect(records.map((record) => record.data).join('')).toBe(input);
+ expect(records.map((record) => record.seq)).toEqual([1, 2, 3, 4, 5, 6]);
+ });
+
it('leaves a frame queued (unacked) when HTTP delivery fails', async () => {
const app = makeApp();
const calls: PostBody[] = [];
diff --git a/test/terminal-buffer-flush.test.ts b/test/terminal-buffer-flush.test.ts
index 54267beb..c620fa3f 100644
--- a/test/terminal-buffer-flush.test.ts
+++ b/test/terminal-buffer-flush.test.ts
@@ -169,4 +169,27 @@ describe('buffer-load flush (COD-144)', () => {
expect(app.batchTerminalWrite).not.toHaveBeenCalled();
expect(writes).toEqual([]);
});
+
+ it('repositions a pending local draft after the loaded frame becomes authoritative', () => {
+ const { app } = makeApp();
+ const rerender = vi.fn();
+ let writeDone: (() => void) | undefined;
+ Object.assign(app, {
+ _localEchoOverlay: { hasPending: true, rerender },
+ terminal: {
+ write: vi.fn((_data: string, callback?: () => void) => {
+ writeDone = callback;
+ }),
+ },
+ });
+ const owner = app._beginBufferLoad('load-with-draft');
+
+ expect(app._finishBufferLoad(owner)).toBe(true);
+ expect(writeDone).toBeTypeOf('function');
+ expect(rerender).not.toHaveBeenCalled();
+
+ writeDone?.();
+
+ expect(rerender).toHaveBeenCalledOnce();
+ });
});
diff --git a/test/terminal-flush-budget.test.ts b/test/terminal-flush-budget.test.ts
index 1af721ab..82fabe93 100644
--- a/test/terminal-flush-budget.test.ts
+++ b/test/terminal-flush-budget.test.ts
@@ -69,6 +69,28 @@ describe('terminal flush budget', () => {
expect(app.pendingWrites.join('')).toHaveLength(32 * 1024);
});
+ it('repositions a pending local draft only after xterm applies agent output', () => {
+ const { app, writes } = loadTerminalUiHarness('claude');
+ let writeDone: (() => void) | undefined;
+ const rerender = vi.fn();
+ app._localEchoOverlay = { hasPending: true, rerender };
+ app.terminal.write = (data: string, callback?: () => void) => {
+ writes.push(data);
+ writeDone = callback;
+ };
+ app.pendingWrites.push('agent output that moves the prompt');
+
+ app.flushPendingWrites();
+
+ expect(writes).toEqual(['agent output that moves the prompt']);
+ expect(writeDone).toBeTypeOf('function');
+ expect(rerender).not.toHaveBeenCalled();
+
+ writeDone?.();
+
+ expect(rerender).toHaveBeenCalledOnce();
+ });
+
it('waits for xterm to process small buffer replays before completing buffer load', async () => {
const { app, writes } = loadTerminalUiHarness('codex');
let writeDone: (() => void) | undefined;
diff --git a/test/terminal-input-lifecycle.test.ts b/test/terminal-input-lifecycle.test.ts
new file mode 100644
index 00000000..d1851835
--- /dev/null
+++ b/test/terminal-input-lifecycle.test.ts
@@ -0,0 +1,288 @@
+import { readFileSync } from 'node:fs';
+import { performance } from 'node:perf_hooks';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+import { describe, expect, it, vi } from 'vitest';
+
+class MemoryStorage {
+ private data = new Map();
+
+ get length(): number {
+ return this.data.size;
+ }
+
+ key(index: number): string | null {
+ return Array.from(this.data.keys())[index] ?? null;
+ }
+
+ getItem(key: string): string | null {
+ return this.data.get(key) ?? null;
+ }
+
+ setItem(key: string, value: string): void {
+ this.data.set(key, String(value));
+ }
+
+ removeItem(key: string): void {
+ this.data.delete(key);
+ }
+}
+
+type Draft = {
+ pendingText: string;
+ flushedText: string;
+ cjkText: string;
+ updatedAt: number;
+ ptyOwned?: boolean;
+};
+
+type InputLifecycleApp = {
+ activeSessionId: string | null;
+ sessions: Map;
+ pendingWrites: string[];
+ _inputState: {
+ get: (sessionId: string) => Draft | null;
+ };
+ _localEchoOverlay: {
+ state?: Record;
+ clear?: ReturnType;
+ restoreDraft?: ReturnType;
+ suppressBufferDetection?: ReturnType;
+ } | null;
+ _terminalInputController: {
+ flushDraftText?: ReturnType;
+ reset?: ReturnType;
+ isPtyEditing?: ReturnType;
+ restorePtyEditing?: ReturnType;
+ } | null;
+ _serializeAddon: null;
+ _xtermSnapshots: Map;
+ _disconnectWs: ReturnType;
+ _restoreSessionDraft: (sessionId: string, render?: boolean) => boolean;
+ _cleanupPreviousSession: (newSessionId: string) => void;
+ selectSession: (sessionId: string, options?: { forceReload?: boolean }) => Promise;
+ terminalBufferCache: Map;
+ _shouldFocusTerminalForTabSwitch: () => boolean;
+ _setTerminalLoadState: (sessionId: string, generation: number, phase: string) => void;
+};
+
+function loadHarness(storage: MemoryStorage) {
+ const pagehideListeners: Array<() => void> = [];
+ const visibilityListeners: Array<() => void> = [];
+ const cjk = {
+ pendingText: '',
+ getPendingText: vi.fn(() => cjk.pendingText),
+ restorePendingText: vi.fn((text: string) => {
+ cjk.pendingText = text;
+ }),
+ clear: vi.fn(() => {
+ cjk.pendingText = '';
+ }),
+ };
+ const document = {
+ visibilityState: 'visible',
+ addEventListener: vi.fn((type: string, listener: () => void) => {
+ if (type === 'visibilitychange') visibilityListeners.push(listener);
+ }),
+ getElementById: vi.fn(() => null),
+ };
+ const window = {
+ addEventListener: vi.fn((type: string, listener: () => void) => {
+ if (type === 'pagehide') pagehideListeners.push(listener);
+ }),
+ removeEventListener: vi.fn(),
+ };
+ class FakeCanvas {
+ getContext(): null {
+ return null;
+ }
+
+ addEventListener(): void {}
+ }
+
+ const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
+ const state = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-input-state.js'), 'utf8');
+ const appSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
+ const context = vm.createContext({
+ console,
+ performance,
+ Date,
+ Math,
+ crypto: { randomUUID: () => 'test-client' },
+ setInterval: vi.fn(),
+ clearInterval: vi.fn(),
+ setTimeout,
+ clearTimeout,
+ requestAnimationFrame: vi.fn(),
+ HTMLCanvasElement: FakeCanvas,
+ navigator: {
+ onLine: true,
+ sendBeacon: vi.fn(),
+ },
+ location: {
+ pathname: '/',
+ protocol: 'https:',
+ host: 'test.local',
+ },
+ document,
+ window,
+ localStorage: storage,
+ NotificationManager: class NotificationManager {},
+ MobileDetection: {},
+ CjkInput: cjk,
+ });
+ vm.runInContext(
+ `${constants}\n${state}\n${appSource}\n` +
+ `CodemanApp.prototype.init = function () {};\n` +
+ `globalThis.__CodemanApp = CodemanApp;`,
+ context
+ );
+ const CodemanApp = (
+ context as {
+ __CodemanApp: new () => InputLifecycleApp;
+ }
+ ).__CodemanApp;
+ return {
+ CodemanApp,
+ cjk,
+ firePagehide: () => {
+ for (const listener of pagehideListeners) listener();
+ },
+ };
+}
+
+describe('terminal input lifecycle', () => {
+ it('persists pagehide input and reconstructs it in a fresh app instance', () => {
+ const storage = new MemoryStorage();
+ const firstHarness = loadHarness(storage);
+ const first = new firstHarness.CodemanApp();
+ first.activeSessionId = 'session-a';
+ first._localEchoOverlay = {
+ state: {
+ pendingText: 'typed',
+ compositionText: '候補',
+ flushedText: 'tabbed',
+ },
+ };
+ firstHarness.cjk.pendingText = '未提交';
+
+ firstHarness.firePagehide();
+
+ const secondHarness = loadHarness(storage);
+ const second = new secondHarness.CodemanApp();
+ const restoreDraft = vi.fn();
+ second.activeSessionId = 'session-a';
+ second._localEchoOverlay = { restoreDraft };
+
+ expect(second._restoreSessionDraft('session-a', false)).toBe(true);
+ expect(restoreDraft).toHaveBeenCalledWith(
+ {
+ pendingText: 'typed候補',
+ flushedText: 'tabbed',
+ },
+ false
+ );
+ expect(secondHarness.cjk.restorePendingText).toHaveBeenCalledWith('未提交');
+ });
+
+ it('hands pending text to the outgoing PTY and restores the remaining draft', () => {
+ const storage = new MemoryStorage();
+ const harness = loadHarness(storage);
+ const app = new harness.CodemanApp();
+ const flushDraftText = vi.fn();
+ const reset = vi.fn();
+ const clear = vi.fn();
+ app.activeSessionId = 'session-a';
+ app.sessions.set('session-a', { mode: 'codex' });
+ app._serializeAddon = null;
+ app._xtermSnapshots = new Map();
+ app._disconnectWs = vi.fn();
+ app._terminalInputController = { flushDraftText, reset };
+ app._localEchoOverlay = {
+ state: {
+ pendingText: 'first\nsecond',
+ compositionText: '候補',
+ flushedText: 'older ',
+ },
+ clear,
+ suppressBufferDetection: vi.fn(),
+ };
+ harness.cjk.pendingText = '中文';
+
+ app._cleanupPreviousSession('session-b');
+
+ expect(flushDraftText).toHaveBeenCalledWith('first\nsecond', 'session-a');
+ expect(app._inputState.get('session-a')).toMatchObject({
+ pendingText: '候補',
+ flushedText: 'older first\nsecond',
+ cjkText: '中文',
+ });
+ expect(clear).toHaveBeenCalledOnce();
+ expect(reset).toHaveBeenCalledOnce();
+
+ const restoreDraft = vi.fn();
+ app._localEchoOverlay = { restoreDraft };
+ expect(app._restoreSessionDraft('session-a', false)).toBe(true);
+ expect(restoreDraft).toHaveBeenCalledWith(
+ {
+ pendingText: '候補',
+ flushedText: 'older first\nsecond',
+ },
+ false
+ );
+ expect(harness.cjk.restorePendingText).toHaveBeenCalledWith('中文');
+ });
+
+ it('restores PTY-owned editing after browser recreation', () => {
+ const storage = new MemoryStorage();
+ const firstHarness = loadHarness(storage);
+ const first = new firstHarness.CodemanApp();
+ first.activeSessionId = 'session-a';
+ first._localEchoOverlay = {
+ state: {
+ pendingText: '',
+ compositionText: '',
+ flushedText: '',
+ },
+ };
+ first._terminalInputController = {
+ isPtyEditing: vi.fn(() => true),
+ };
+
+ firstHarness.firePagehide();
+
+ const secondHarness = loadHarness(storage);
+ const second = new secondHarness.CodemanApp();
+ const restorePtyEditing = vi.fn();
+ second.activeSessionId = 'session-a';
+ second._localEchoOverlay = { restoreDraft: vi.fn() };
+ second._terminalInputController = { restorePtyEditing };
+
+ expect(second._restoreSessionDraft('session-a', false)).toBe(true);
+ expect(restorePtyEditing).toHaveBeenCalledWith('session-a', true);
+ expect(second._inputState.get('session-a')).toMatchObject({
+ ptyOwned: true,
+ });
+ });
+
+ it('keeps the active owner visible to same-session force-reload cleanup', async () => {
+ const harness = loadHarness(new MemoryStorage());
+ const app = new harness.CodemanApp();
+ const stop = new Error('stop after cleanup ownership check');
+ let cleanupOwner: string | null = null;
+ app.activeSessionId = 'session-a';
+ app.sessions.set('session-a', { mode: 'codex' });
+ app.terminalBufferCache = new Map([['session-a', 'cached frame']]);
+ app._xtermSnapshots = new Map([['session-a', 'serialized frame']]);
+ app._shouldFocusTerminalForTabSwitch = () => false;
+ app._setTerminalLoadState = () => {};
+ app._cleanupPreviousSession = () => {
+ cleanupOwner = app.activeSessionId;
+ throw stop;
+ };
+
+ await expect(app.selectSession('session-a', { forceReload: true })).rejects.toBe(stop);
+
+ expect(cleanupOwner).toBe('session-a');
+ });
+});
From fbf2ef6699c52f3c4cb677c56387939409de6f08 Mon Sep 17 00:00:00 2001
From: lior
Date: Wed, 29 Jul 2026 09:14:20 +0300
Subject: [PATCH 5/8] refactor(input): route producers through controller
---
src/web/public/image-input.js | 13 ++-
src/web/public/input-cjk.js | 113 ++++++++++++++++++++++----
src/web/public/keyboard-accessory.js | 18 +----
src/web/public/session-ui.js | 26 +-----
src/web/public/voice-input.js | 23 ++----
test/frontend-public-tooling.test.ts | 44 +++++++---
test/input-cjk.test.ts | 117 +++++++++++++++++++++++++--
test/path-picker-ui.test.ts | 71 ++++++++--------
8 files changed, 292 insertions(+), 133 deletions(-)
diff --git a/src/web/public/image-input.js b/src/web/public/image-input.js
index 550e30b3..f7ea8170 100644
--- a/src/web/public/image-input.js
+++ b/src/web/public/image-input.js
@@ -87,15 +87,12 @@ Object.assign(CodemanApp.prototype, {
e.preventDefault();
self._uploadAndInsertImages(imageFiles);
} else {
- // No image -- route text through xterm's paste() so bracketed-paste
- // markers (CSI 200~ ... CSI 201~) survive when the inner application
- // has enabled bracketed-paste mode (Claude Code does). Sending text
- // via raw sendInput() strips those markers and makes pasted input
- // indistinguishable from typed input, weakening the CLI's
- // prompt-injection defenses.
+ // No image: route through Codeman's session-aware paste boundary.
+ // Replayed browser buffers can miss the CLI's one-time DECSET 2004
+ // enable, so relying on xterm's local mode would omit bracket framing.
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
e.preventDefault();
- if (text && self.terminal) self.terminal.paste(text);
+ if (text) self.sendPastedText(text);
}
});
@@ -166,7 +163,7 @@ Object.assign(CodemanApp.prototype, {
const paths = results.filter(Boolean);
if (paths.length > 0) {
// Insert all paths in one shot, space-separated, in selection order.
- await this.sendInput(paths.join(' '));
+ this.insertTerminalText(paths.join(' '));
}
// Final status: successes, plus any failures / cap so nothing is silent.
diff --git a/src/web/public/input-cjk.js b/src/web/public/input-cjk.js
index 7db12e6b..9288f4aa 100644
--- a/src/web/public/input-cjk.js
+++ b/src/web/public/input-cjk.js
@@ -43,13 +43,16 @@
*
* @dependency index.html (#cjkInput textarea)
* @globals {object} CjkInput — window.cjkActive (boolean) signals app.js to block xterm onData
- * @loadorder 5.5 of 15 — loaded after keyboard-accessory.js, before app.js
+ * @loadorder 5.5 of 16 — loaded after keyboard-accessory.js, before terminal-input-state.js
*/
// eslint-disable-next-line no-unused-vars
const CjkInput = (() => {
let _textarea = null;
- let _send = null;
+ let _sendText = null;
+ let _sendControl = null;
+ let _paste = null;
+ let _draftChanged = null;
let _initialized = false;
let _composing = false;
let _flushTimer = null;
@@ -138,12 +141,22 @@ const CjkInput = (() => {
}
_textarea.value = PHANTOM;
_textarea.setSelectionRange(1, 1);
+ _draftChanged?.();
}
function _isEffectivelyEmpty() {
return !_strip(_textarea.value);
}
+ function _sendCommittedText(text) {
+ if (!text) return;
+ if (/[\r\n]/.test(text)) {
+ _paste(text);
+ } else {
+ _sendText(text);
+ }
+ }
+
/** Flush textarea: send real text to PTY and reset to phantom */
function _flush() {
// Never flush mid-composition: reading the value would send the IME's
@@ -157,9 +170,7 @@ const CjkInput = (() => {
}
const val = _strip(_textarea.value);
_t(`flush ${val ? 'send len=' + val.length : 'empty'}`);
- if (val) {
- _send(val);
- }
+ _sendCommittedText(val);
_resetToPhantom();
}
@@ -192,10 +203,14 @@ const CjkInput = (() => {
}
return {
- init({ send }) {
+ init({ send, sendText, sendControl, paste, draftChanged }) {
if (_initialized) this.destroy();
- _send = send;
+ const fallbackSend = typeof send === 'function' ? send : () => {};
+ _sendText = typeof sendText === 'function' ? sendText : fallbackSend;
+ _sendControl = typeof sendControl === 'function' ? sendControl : fallbackSend;
+ _paste = typeof paste === 'function' ? paste : _sendText;
+ _draftChanged = typeof draftChanged === 'function' ? draftChanged : null;
_composing = false;
_flushTimer = null;
_textarea = document.getElementById('cjkInput');
@@ -249,6 +264,23 @@ const CjkInput = (() => {
_textarea.addEventListener('focus', _listeners.focus);
_textarea.addEventListener('blur', _listeners.blur);
+ // Clipboard text is a single semantic paste, even when it contains blank
+ // lines. Route it separately so the app can add bracketed-paste framing
+ // instead of flushing each newline as a real Enter key.
+ _listeners.paste = (e) => {
+ const text = e.clipboardData?.getData?.('text/plain') || '';
+ if (!text) return;
+ e.preventDefault();
+ _t(`paste len=${text.length}`);
+ _composing = false;
+ _cancelDebouncedFlush();
+ clearTimeout(_compositionFlushTimer);
+ _compositionFlushTimer = null;
+ _paste(text);
+ _resetToPhantom();
+ };
+ _textarea.addEventListener('paste', _listeners.paste);
+
// ── Composition tracking (keyboard IME — works for CJK typing) ──
_listeners.compositionstart = () => {
_t(`compstart ${_vdesc(_textarea.value)}`);
@@ -282,12 +314,9 @@ const CjkInput = (() => {
_composing = false;
_cancelDebouncedFlush();
const val = _strip(_textarea.value);
- if (val) {
- _send(val + '\r');
- } else {
- _send('\r');
- }
_resetToPhantom();
+ _sendCommittedText(val);
+ _sendControl('\r');
return;
}
@@ -296,12 +325,18 @@ const CjkInput = (() => {
_composing = false;
_cancelDebouncedFlush();
_resetToPhantom();
+ _sendControl('\x1b');
return;
}
if (e.ctrlKey && CTRL_KEYS[e.key]) {
e.preventDefault();
- _send(CTRL_KEYS[e.key]);
+ _composing = false;
+ _cancelDebouncedFlush();
+ const val = _strip(_textarea.value);
+ _resetToPhantom();
+ _sendCommittedText(val);
+ _sendControl(CTRL_KEYS[e.key]);
return;
}
@@ -313,7 +348,7 @@ const CjkInput = (() => {
// Backspace: forward to PTY when no real text in textarea
if (e.key === 'Backspace' && _isEffectivelyEmpty()) {
e.preventDefault();
- _send('\x7f');
+ _sendControl('\x7f');
_resetToPhantom();
return;
}
@@ -321,7 +356,7 @@ const CjkInput = (() => {
// Arrow/function keys: forward to PTY when no real text
if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) {
e.preventDefault();
- _send(PASSTHROUGH_KEYS[e.key]);
+ _sendControl(PASSTHROUGH_KEYS[e.key]);
return;
}
@@ -331,7 +366,7 @@ const CjkInput = (() => {
// tells the input handler to skip that echo.
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) {
e.preventDefault();
- _send(e.key);
+ _sendText(e.key);
_keydownSentAt = performance.now();
_keydownSentText = e.key;
_resetToPhantom();
@@ -342,6 +377,10 @@ const CjkInput = (() => {
// ── Input event: primary path for virtual keyboards + dictation ──
_listeners.input = (e) => {
+ // The DOM value has already changed by the time this event runs. Capture
+ // it before an immediate flush/reset so a lifecycle event in this turn
+ // cannot lose an unfinished IME or dictation update.
+ _draftChanged?.();
_t(`input ${e.inputType || '?'} ic=${e.isComposing} c=${_composing} ${_vdesc(_textarea.value)}`);
// ── Stuck-composition recovery ──
// Some IMEs (WeChat/Sogou keyboards) fire compositionstart without a
@@ -359,12 +398,23 @@ const CjkInput = (() => {
_composing = false;
}
+ // Some Android clipboard surfaces emit no ClipboardEvent. They insert
+ // the complete value and report only inputType=insertFromPaste.
+ if (e.inputType === 'insertFromPaste') {
+ _cancelDebouncedFlush();
+ const text = _strip(_textarea.value);
+ _t(`input-paste len=${text.length}`);
+ if (text) _paste(text);
+ _resetToPhantom();
+ return;
+ }
+
// ── Backspace / delete detection ──
if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteWordBackward') {
if (_composing) return;
if (_isEffectivelyEmpty()) {
_cancelDebouncedFlush();
- _send('\x7f');
+ _sendControl('\x7f');
_resetToPhantom();
return;
}
@@ -428,6 +478,31 @@ const CjkInput = (() => {
_resetToPhantom();
},
+ /** Real editable text currently held behind the phantom character. */
+ getPendingText() {
+ return _initialized && _textarea ? _strip(_textarea.value) : '';
+ },
+
+ /**
+ * Restore committed draft text without trying to recreate an OS-level IME
+ * composition. The next input/Enter event treats it as ordinary text.
+ */
+ restorePendingText(text) {
+ if (!_initialized || !_textarea) return;
+ _cancelDebouncedFlush();
+ clearTimeout(_compositionFlushTimer);
+ _compositionFlushTimer = null;
+ _composing = false;
+ const restored = String(text || '');
+ if (!restored) {
+ _resetToPhantom();
+ return;
+ }
+ _textarea.value = PHANTOM + restored;
+ const end = _textarea.value.length;
+ _textarea.setSelectionRange(end, end);
+ },
+
/** Diagnostic: recent IME event trace (ring buffer). */
getTrace() {
return _trace.slice();
@@ -446,6 +521,10 @@ const CjkInput = (() => {
}
window.cjkActive = false;
_composing = false;
+ _sendText = null;
+ _sendControl = null;
+ _paste = null;
+ _draftChanged = null;
for (const key of Object.keys(_listeners)) delete _listeners[key];
_initialized = false;
},
diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js
index 68e08fd2..3e7bd5bf 100644
--- a/src/web/public/keyboard-accessory.js
+++ b/src/web/public/keyboard-accessory.js
@@ -590,22 +590,13 @@ const KeyboardAccessoryBar = {
* Sends text and Enter separately so Ink processes them as distinct events. */
sendCommand(command) {
if (!app.activeSessionId) return;
- // Send command text first (without Enter)
- app.sendInput(command);
- // Send Enter separately after a brief delay so Ink has time to process the text.
- setTimeout(() => app.sendInput('\r'), 120);
+ app.sendTerminalCommand(command);
},
- /** Send a special key (arrow, escape, etc.) directly to the PTY.
- * Bypasses tmux send-keys -l (literal mode) since escape sequences
- * must be written raw to be interpreted as key presses by Ink. */
+ /** Send a special key through the controller-owned ordered input path. */
sendKey(escapeSequence) {
if (!app.activeSessionId) return;
- fetch(`/api/sessions/${app.activeSessionId}/input`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ input: escapeSequence })
- }).catch(() => {});
+ app.sendTerminalKey(escapeSequence);
},
/** Browse the active session's workspace and insert a selected path without Enter. */
@@ -662,8 +653,7 @@ const KeyboardAccessoryBar = {
const text = textarea.value;
close();
if (text) {
- app.sendInput(text);
- setTimeout(() => app.sendInput('\r'), 80);
+ app.sendPastedText(text, { submit: true });
}
};
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js
index d3cbec61..0b671df3 100644
--- a/src/web/public/session-ui.js
+++ b/src/web/public/session-ui.js
@@ -507,26 +507,10 @@ Object.assign(CodemanApp.prototype, {
}
},
- /** Send Enter to the active session (phone toolbar button).
- *
- * MUST go through xterm's onData path, NOT straight to sendInput()/the API.
- * With local echo on (the mobile default) the characters you typed are still
- * buffered in the LocalEchoOverlay and have NEVER reached the PTY. The onData
- * Enter branch (terminal-ui.js) is what flushes that pending text and only
- * then sends \r. Send a bare \r instead and you submit an empty line while the
- * typed text stays stranded on screen — which reads as "the button does
- * nothing". triggerDataEvent replays it exactly as if the key were pressed,
- * so overlay flush, flushed-offset cleanup and ordering are all reused. */
+ /** Send Enter through the controller-owned draft and delivery path. */
sendEnterKey() {
if (!this.activeSessionId) return;
- const coreService = this.terminal?._core?.coreService;
- if (coreService && typeof coreService.triggerDataEvent === 'function') {
- coreService.triggerDataEvent('\r', true);
- return;
- }
- // Fallback only if xterm's private core API moves: correct when local echo
- // is off, and still better than doing nothing.
- this.sendInput('\r');
+ this._terminalInputController?.sendControl('\r');
},
_initRunMode() {
@@ -774,11 +758,7 @@ Object.assign(CodemanApp.prototype, {
btn.innerHTML = btn.dataset.origHtml;
delete btn.dataset.origHtml;
btn.classList.remove('confirming');
- fetch(`/api/sessions/${this.activeSessionId}/input`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ input: '\x03' })
- });
+ this.sendTerminalKey('\x03');
} else {
// First tap — enter confirm state
btn.dataset.origHtml = btn.innerHTML;
diff --git a/src/web/public/voice-input.js b/src/web/public/voice-input.js
index 4f79d038..e75ad9a2 100644
--- a/src/web/public/voice-input.js
+++ b/src/web/public/voice-input.js
@@ -569,12 +569,9 @@ const VoiceInput = {
this._showComposeOverlay(trimmed);
}
} else {
- // Direct mode: inject into local echo overlay if available, else send to PTY
- if (app._localEchoEnabled && app._localEchoOverlay) {
- app._localEchoOverlay.appendText(trimmed);
- } else {
- app.sendInput(trimmed).catch(() => {});
- }
+ // Direct mode still enters the controller so draft state and transport
+ // ordering stay coherent with keyboard input.
+ app.insertTerminalText(trimmed);
this._showVoiceSendBtn();
setTimeout(() => { if (app.terminal) app.terminal.focus(); }, 150);
}
@@ -603,17 +600,7 @@ const VoiceInput = {
// Click handler
this._voiceSendHandler = () => {
if (!app.activeSessionId) return;
- // Simulate Enter key: if local echo is active, flush its buffer + send \r;
- // otherwise just send \r directly to the PTY
- if (app._localEchoEnabled && app._localEchoOverlay) {
- const text = app._localEchoOverlay.pendingText || '';
- app._localEchoOverlay.clear();
- app._localEchoOverlay.suppressBufferDetection();
- if (text) app.sendInput(text).catch(() => {});
- setTimeout(() => app.sendInput('\r').catch(() => {}), 80);
- } else {
- app.sendInput('\r').catch(() => {});
- }
+ app.sendEnterKey();
// Blink then restore
gear.classList.add('voice-send-blink');
setTimeout(() => this._hideVoiceSendBtn(), 400);
@@ -656,7 +643,7 @@ const VoiceInput = {
const send = () => {
const val = textarea.value.trim();
overlay.remove();
- if (val) app.sendInput(val + '\r').catch(() => {});
+ if (val) app.sendPastedText(val, { submit: true }).catch(() => {});
};
const cancel = () => overlay.remove();
const newInput = () => {
diff --git a/test/frontend-public-tooling.test.ts b/test/frontend-public-tooling.test.ts
index 0be11f45..6b95b771 100644
--- a/test/frontend-public-tooling.test.ts
+++ b/test/frontend-public-tooling.test.ts
@@ -1,6 +1,6 @@
-import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
-import { resolve } from 'node:path';
+import { relative, resolve } from 'node:path';
+import { pathToFileURL } from 'node:url';
import { describe, expect, it } from 'vitest';
const repoRoot = resolve(import.meta.dirname, '..');
@@ -20,12 +20,38 @@ describe('frontend public asset tooling', () => {
expect(appJs.includes(0)).toBe(false);
});
- it('runs the public asset check script', () => {
- expect(() => {
- execFileSync('npm', ['run', 'check:public-assets', '--silent'], {
- cwd: repoRoot,
- stdio: 'pipe',
- });
- }).not.toThrow();
+ it('exposes self-contained asset discovery and NUL checks', async () => {
+ const checker = (await import(pathToFileURL(resolve(repoRoot, 'scripts/check-public-assets.mjs')).href)) as {
+ collectTextAssets: (directory: string) => string[];
+ findNullByte: (data: Buffer) => number;
+ };
+ const files = checker
+ .collectTextAssets(resolve(repoRoot, 'src/web/public'))
+ .map((file) => relative(repoRoot, file));
+
+ expect(files).toContain('src/web/public/terminal-input-state.js');
+ expect(files).toContain('src/web/public/terminal-input-controller.js');
+ expect(checker.findNullByte(Buffer.from('valid source'))).toBe(-1);
+ expect(checker.findNullByte(Buffer.from([0x61, 0, 0x62]))).toBe(1);
+ });
+
+ it('routes interactive producers through the terminal input facade', () => {
+ const producerPaths = [
+ 'src/web/public/image-input.js',
+ 'src/web/public/keyboard-accessory.js',
+ 'src/web/public/session-ui.js',
+ 'src/web/public/voice-input.js',
+ ];
+
+ for (const path of producerPaths) {
+ const source = readFileSync(resolve(repoRoot, path), 'utf8');
+ expect(source, `${path} bypasses TerminalInputController via sendInput()`).not.toMatch(
+ /\b(?:app|this)\.sendInput\(/
+ );
+ expect(source, `${path} bypasses TerminalInputController transport`).not.toContain('._sendInputAsync(');
+ expect(source, `${path} posts interactive input without the controller`).not.toMatch(
+ /fetch\(\s*`\/api\/sessions\/\$\{[^}]+\}\/input`/
+ );
+ }
});
});
diff --git a/test/input-cjk.test.ts b/test/input-cjk.test.ts
index 54b250b7..c05f92e6 100644
--- a/test/input-cjk.test.ts
+++ b/test/input-cjk.test.ts
@@ -66,6 +66,9 @@ const IOS_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebK
function loadCjkHarness({ ua = ANDROID_UA }: { ua?: string } = {}) {
const textarea = makeTextarea();
const sent: string[] = [];
+ const controls: string[] = [];
+ const pasted: string[] = [];
+ const draftValues: string[] = [];
const windowObj: Record = {};
const documentObj = {
getElementById: (id: string) => (id === 'cjkInput' ? textarea : null),
@@ -88,10 +91,15 @@ function loadCjkHarness({ ua = ANDROID_UA }: { ua?: string } = {}) {
vm.runInContext(src, context, { filename: 'input-cjk.js' });
const CjkInput = vm.runInContext('CjkInput', context);
- CjkInput.init({ send: (text: string) => sent.push(text) });
+ CjkInput.init({
+ sendText: (text: string) => sent.push(text),
+ sendControl: (data: string) => controls.push(data),
+ paste: (text: string) => pasted.push(text),
+ draftChanged: () => draftValues.push(textarea.value),
+ });
textarea.fire('focus');
- return { CjkInput, textarea, sent, windowObj, documentObj };
+ return { CjkInput, textarea, sent, controls, pasted, draftValues, windowObj, documentObj };
}
describe('CJK input module', () => {
@@ -184,6 +192,54 @@ describe('CJK input module', () => {
expect(textarea.value).toBe(PHANTOM);
});
+ it('routes multiline clipboard text through the dedicated paste boundary', () => {
+ const { textarea, sent, pasted } = loadCjkHarness();
+ const preventDefault = vi.fn();
+
+ textarea.fire('paste', {
+ preventDefault,
+ clipboardData: {
+ getData: (type: string) => (type === 'text/plain' ? 'first\n\nsecond' : ''),
+ },
+ });
+
+ expect(preventDefault).toHaveBeenCalledOnce();
+ expect(pasted).toEqual(['first\n\nsecond']);
+ expect(sent).toEqual([]);
+ expect(textarea.value).toBe(PHANTOM);
+ });
+
+ it('routes Android input-only paste events through the dedicated paste boundary', () => {
+ const { textarea, sent, pasted } = loadCjkHarness();
+ textarea.value = PHANTOM + 'first\n\nReferences\nmore after references';
+
+ textarea.fire('input', {
+ isComposing: false,
+ inputType: 'insertFromPaste',
+ });
+
+ expect(pasted).toEqual(['first\n\nReferences\nmore after references']);
+ expect(sent).toEqual([]);
+ expect(textarea.value).toBe(PHANTOM);
+ });
+
+ it('keeps multiline text bracketed when Enter wins the race with the paste event', () => {
+ const { textarea, sent, controls, pasted } = loadCjkHarness();
+ textarea.value = PHANTOM + 'first\n\nReferences\nmore after references';
+
+ textarea.fire('keydown', {
+ key: 'Enter',
+ ctrlKey: false,
+ altKey: false,
+ metaKey: false,
+ });
+
+ expect(pasted).toEqual(['first\n\nReferences\nmore after references']);
+ expect(sent).toEqual([]);
+ expect(controls).toEqual(['\r']);
+ expect(textarea.value).toBe(PHANTOM);
+ });
+
it('re-tapping the focused empty field restarts the IME session (wedged-IME recovery)', () => {
const { textarea, documentObj } = loadCjkHarness();
documentObj.activeElement = textarea;
@@ -265,6 +321,36 @@ describe('CJK input module', () => {
expect(textarea.valueWrites).toBe(before);
});
+ it('round-trips a restored CJK draft without submitting it', () => {
+ const { CjkInput, textarea, sent, pasted } = loadCjkHarness();
+
+ CjkInput.restorePendingText('未提交\n草稿');
+
+ expect(CjkInput.getPendingText()).toBe('未提交\n草稿');
+ expect(textarea.value).toBe(PHANTOM + '未提交\n草稿');
+ expect(sent).toEqual([]);
+ expect(pasted).toEqual([]);
+
+ CjkInput.clear();
+ expect(CjkInput.getPendingText()).toBe('');
+ expect(textarea.value).toBe(PHANTOM);
+ });
+
+ it('notifies the draft owner before a CJK input mutation is debounced', () => {
+ const { textarea, sent, draftValues } = loadCjkHarness();
+ draftValues.length = 0;
+ textarea.fire('compositionstart');
+ textarea.value = PHANTOM + '未提交';
+
+ textarea.fire('input', {
+ isComposing: true,
+ inputType: 'insertCompositionText',
+ });
+
+ expect(draftValues).toEqual([PHANTOM + '未提交']);
+ expect(sent).toEqual([]);
+ });
+
it('routes terminal.focus() to the CJK field while it is visible (focus router)', () => {
// Regression guard for the intermittent "Chinese input goes nowhere" bug:
// session-select / SSE-reconnect paths call terminal.focus(), which lands
@@ -283,18 +369,33 @@ describe('CJK input module', () => {
// redraws), which arrive while e.g. the rename input or search box has
// focus — refocusing on those steals focus mid-typing. Guard: focus must
// be on xterm's own textarea AND the data must not be a query reply.
- const selfHeal = src.slice(src.indexOf('Self-heal'), src.indexOf('CJK regain-focus'));
- expect(selfHeal).toContain('document.activeElement === this.terminal.textarea');
- expect(selfHeal).toContain('shouldSuppressTerminalQueryResponse(data)');
+ expect(src).toMatch(
+ /document\.activeElement === this\.terminal\.textarea[\s\S]*?!window\.CodemanTerminalInput\?\.shouldSuppressTerminalQueryResponse\([\s\S]*?data[\s\S]*?\)[\s\S]*?CJK regain-focus \(onData swallowed input\)/
+ );
});
- it('sends text plus carriage return on Enter', () => {
- const { textarea, sent } = loadCjkHarness();
+ it('routes Enter text and control through separate semantic callbacks', () => {
+ const { textarea, sent, controls } = loadCjkHarness();
textarea.value = PHANTOM + '你好';
textarea.fire('keydown', { key: 'Enter' });
- expect(sent).toEqual(['你好\r']);
+ expect(sent).toEqual(['你好']);
+ expect(controls).toEqual(['\r']);
+ expect(textarea.value).toBe(PHANTOM);
+ });
+
+ it('flushes pending CJK text before routing a control action', () => {
+ const { textarea, sent, controls } = loadCjkHarness();
+ textarea.value = PHANTOM + 'draft';
+
+ textarea.fire('keydown', {
+ key: 'c',
+ ctrlKey: true,
+ });
+
+ expect(sent).toEqual(['draft']);
+ expect(controls).toEqual(['\x03']);
expect(textarea.value).toBe(PHANTOM);
});
});
diff --git a/test/path-picker-ui.test.ts b/test/path-picker-ui.test.ts
index b64633e6..de0503b4 100644
--- a/test/path-picker-ui.test.ts
+++ b/test/path-picker-ui.test.ts
@@ -27,6 +27,7 @@ function loadTerminalMixin() {
requestAnimationFrame: vi.fn(),
CodemanApp: FakeCodemanApp,
CjkInput: { clear: cjkClear },
+ _crashDiag: { log: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
document: { addEventListener: vi.fn() },
});
@@ -114,76 +115,74 @@ describe('mobile filesystem picker actions', () => {
});
it('inserts a selected path into the editable local-echo prompt without sending it', () => {
- const appendText = vi.fn();
- const sendInput = vi.fn();
+ const insertText = vi.fn();
const focus = vi.fn();
const app = {
activeSessionId: 'session-1',
- _localEchoEnabled: true,
- _localEchoOverlay: { appendText },
+ _terminalInputController: { insertText },
terminal: { focus },
- sendInput,
};
terminalHarness.mixin.insertTerminalText.call(app, '/mnt/d/AI/project');
- expect(appendText).toHaveBeenCalledWith('/mnt/d/AI/project');
- expect(sendInput).not.toHaveBeenCalled();
+ expect(insertText).toHaveBeenCalledWith('/mnt/d/AI/project');
expect(focus).toHaveBeenCalledOnce();
});
it('clears pending and already-flushed prompt text without invoking /clear', () => {
- const clear = vi.fn();
- const suppressBufferDetection = vi.fn();
- const sendInput = vi.fn(() => Promise.resolve());
+ const clearInput = vi.fn();
const showToast = vi.fn();
const focus = vi.fn();
const app = {
activeSessionId: 'session-1',
- _inputFlushTimeout: null,
- _pendingInput: 'pending text',
- _localEchoEnabled: true,
- _localEchoOverlay: {
- getFlushed: () => ({ count: 4, text: 'sent' }),
- clear,
- suppressBufferDetection,
- },
- _flushedOffsets: new Map([['session-1', 4]]),
- _flushedTexts: new Map([['session-1', 'sent']]),
- sendInput,
+ _terminalInputController: { clearInput },
showToast,
terminal: { focus },
};
terminalHarness.mixin.clearTerminalInput.call(app);
- expect(app._pendingInput).toBe('');
- expect(clear).toHaveBeenCalledOnce();
- expect(suppressBufferDetection).toHaveBeenCalledOnce();
- expect(sendInput).toHaveBeenCalledWith('\x7f'.repeat(4));
- expect(sendInput).not.toHaveBeenCalledWith('/clear');
- expect(app._flushedOffsets.size).toBe(0);
- expect(app._flushedTexts.size).toBe(0);
+ expect(clearInput).toHaveBeenCalledOnce();
expect(showToast).toHaveBeenCalledWith('Input cleared', 'success');
expect(focus).toHaveBeenCalledOnce();
expect(terminalHarness.cjkClear).toHaveBeenCalled();
});
- it('uses Ctrl+U to clear the TUI-owned prompt when local echo is disabled', () => {
- const sendInput = vi.fn(() => Promise.resolve());
+ it('delegates non-local prompt clearing to the controller', () => {
+ const clearInput = vi.fn();
const app = {
activeSessionId: 'session-1',
- _inputFlushTimeout: null,
- _pendingInput: '',
- _localEchoEnabled: false,
- _localEchoOverlay: null,
- sendInput,
+ _terminalInputController: { clearInput },
showToast: vi.fn(),
terminal: { focus: vi.fn() },
};
terminalHarness.mixin.clearTerminalInput.call(app);
- expect(sendInput).toHaveBeenCalledWith('\x15');
+ expect(clearInput).toHaveBeenCalledOnce();
+ });
+
+ it('routes multiline shell paste directly with xterm bracket framing', async () => {
+ const handleTerminalData = vi.fn();
+ const sendControl = vi.fn();
+ const app = {
+ activeSessionId: 'session-1',
+ sessions: new Map([['session-1', { mode: 'shell' }]]),
+ terminal: {
+ modes: {
+ bracketedPasteMode: true,
+ },
+ },
+ _terminalInputController: {
+ handleTerminalData,
+ sendControl,
+ },
+ _prepareTerminalPaste: terminalHarness.mixin._prepareTerminalPaste,
+ };
+
+ await terminalHarness.mixin.sendPastedText.call(app, 'first\n\nReferences\nmore', { submit: true });
+
+ expect(handleTerminalData).toHaveBeenCalledWith('\x1b[200~first\r\rReferences\rmore\x1b[201~', 'shell-paste');
+ expect(sendControl).toHaveBeenCalledWith('\r');
});
});
From 55b7026cbdc7004cbe138eff2f19a3680158726e Mon Sep 17 00:00:00 2001
From: lior
Date: Wed, 29 Jul 2026 09:14:36 +0300
Subject: [PATCH 6/8] test(mobile): cover persistent terminal input
---
test/mobile/keyboard.test.ts | 1439 +++++++++++++++++++++++++++++++++-
1 file changed, 1423 insertions(+), 16 deletions(-)
diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts
index 5032f653..ff38c03a 100644
--- a/test/mobile/keyboard.test.ts
+++ b/test/mobile/keyboard.test.ts
@@ -329,7 +329,7 @@ describe('Virtual Keyboard', () => {
(button) => (button as HTMLElement).dataset.action
);
});
- expect(actions).toEqual(['scroll-up', 'scroll-down', 'init', 'clear', 'paste', 'dismiss']);
+ expect(actions).toEqual(['scroll-up', 'scroll-down', 'init', 'clear', 'paste', 'esc', 'dismiss']);
});
it('double-tap confirm on /clear button', async () => {
@@ -514,8 +514,8 @@ describe('Virtual Keyboard', () => {
pendingText: app._localEchoOverlay.pendingText,
sentInputs: window.__sentInputs,
}));
- expect(beforeEnter.visibleText).toBe('hello');
- expect(beforeEnter.pendingText).toBe('');
+ expect(beforeEnter.visibleText).toBe('');
+ expect(beforeEnter.pendingText).toBe('hello');
expect(beforeEnter.sentInputs).toEqual([]);
await page.keyboard.press('Enter');
@@ -718,28 +718,1401 @@ describe('Virtual Keyboard', () => {
});
await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } });
+ await page.evaluate(() => {
+ window.__sentInputs = [];
+ });
await page.keyboard.type('find bug');
- const beforeEnter = await page.evaluate(() => ({
- activeClass: document.activeElement?.className,
- cjkDisplay: getComputedStyle(document.getElementById('cjkInput') as HTMLElement).display,
+ const beforeEnter = await page.evaluate(() => ({
+ activeClass: document.activeElement?.className,
+ cjkDisplay: getComputedStyle(document.getElementById('cjkInput') as HTMLElement).display,
+ pendingText: app._localEchoOverlay?.pendingText,
+ sentInputs: window.__sentInputs,
+ }));
+ expect(beforeEnter.activeClass).toContain('xterm-helper-textarea');
+ expect(beforeEnter.cjkDisplay).toBe('none');
+ expect(beforeEnter.pendingText).toBe('find bug');
+ expect(beforeEnter.sentInputs).toEqual([]);
+
+ await page.keyboard.press('Enter');
+ await page.waitForFunction(() => window.__sentInputs?.join('') === 'find bug\r');
+
+ const afterEnter = await page.evaluate(() => ({
+ pendingText: app._localEchoOverlay?.pendingText,
+ sentInputs: window.__sentInputs,
+ }));
+ expect(afterEnter.pendingText).toBe('');
+ expect(afterEnter.sentInputs.join('')).toBe('find bug\r');
+ });
+
+ it('rehydrates an unsent session draft after the page is backgrounded', async () => {
+ const state = await page.evaluate(() => {
+ const sessionId = 'mobile-durable-draft-test';
+ const storageKey = `codeman:sessionDrafts:draft:${encodeURIComponent(sessionId)}`;
+ localStorage.removeItem('codeman:sessionDrafts');
+ localStorage.removeItem(storageKey);
+ app._inputState.clearAll({ persist: false });
+ app.activeSessionId = sessionId;
+ app.sessions.set(sessionId, {
+ id: sessionId,
+ mode: 'codex',
+ status: 'running',
+ });
+ app._localEchoOverlay.clear();
+ app._localEchoOverlay.appendText('first paragraph\n\nsecond paragraph');
+
+ window.dispatchEvent(new PageTransitionEvent('pagehide'));
+ const persisted = JSON.parse(localStorage.getItem(storageKey) || 'null')?.draft;
+
+ // Simulate the in-memory state loss caused by a discarded mobile tab.
+ app._localEchoOverlay.clear();
+ app._inputState.clearAll({ persist: false });
+ app._inputState.load();
+ app._restoreSessionDraft(sessionId, false);
+
+ return {
+ persisted,
+ restored: app._localEchoOverlay.state,
+ };
+ });
+
+ expect(state.persisted).toMatchObject({
+ pendingText: 'first paragraph\n\nsecond paragraph',
+ flushedText: '',
+ cjkText: '',
+ });
+ expect(state.restored).toMatchObject({
+ pendingText: 'first paragraph\n\nsecond paragraph',
+ flushedLength: 0,
+ flushedText: '',
+ });
+ });
+
+ it('preserves a live composition when session replay finishes late', async () => {
+ const state = await page.evaluate(() => {
+ const sessionId = 'mobile-replay-composition-race-test';
+ app.activeSessionId = sessionId;
+ app.sessions.set(sessionId, {
+ id: sessionId,
+ mode: 'codex',
+ status: 'running',
+ });
+ app._localEchoEnabled = true;
+ app._localEchoOverlay.clear();
+ app._localEchoOverlay.appendText('cd hom');
+ app._localEchoOverlay.setCompositionText('e cd hom');
+
+ // Crash recovery deliberately folds the visible composition into its
+ // persisted snapshot. A late session replay must not feed that folded
+ // snapshot back into the still-live overlay.
+ app._captureActiveSessionDraft();
+ const persisted = app._inputState.get(sessionId);
+ const restored = app._restoreSessionDraft(sessionId, false, {
+ preserveLiveComposition: true,
+ });
+
+ return {
+ persisted,
+ restored,
+ overlay: app._localEchoOverlay.state,
+ };
+ });
+
+ expect(state).toMatchObject({
+ persisted: {
+ pendingText: 'cd home cd hom',
+ },
+ restored: true,
+ overlay: {
+ pendingText: 'cd hom',
+ compositionText: 'e cd hom',
+ },
+ });
+ });
+
+ it('does not commit Android DEL as finalized composition text', async () => {
+ const state = await page.evaluate(() => {
+ const sessionId = 'mobile-composition-delete-test';
+ app.activeSessionId = sessionId;
+ app.sessions.set(sessionId, {
+ id: sessionId,
+ mode: 'codex',
+ status: 'running',
+ });
+ app._localEchoEnabled = true;
+ app._localEchoOverlay.clear();
+ app._localEchoOverlay.appendText('cd');
+ app._localEchoOverlay.setCompositionText('candidate');
+ app._terminalInputController.clearCompositionDelivery();
+ app._terminalInputController.setCompositionPending(true, 'candidate');
+
+ app.terminal._core.coreService.triggerDataEvent('\x7f', true);
+
+ return {
+ pendingText: app._localEchoOverlay.pendingText,
+ compositionText: app._localEchoOverlay.compositionText,
+ compositionPending: app._terminalInputController.state.compositionPending,
+ fallbackCommit: app._terminalInputController.state.fallbackCommit,
+ };
+ });
+
+ expect(state).toEqual({
+ pendingText: 'c',
+ compositionText: '',
+ compositionPending: false,
+ fallbackCommit: null,
+ });
+ });
+
+ it('does not append interim xterm data during an active composition', async () => {
+ const state = await page.evaluate(async () => {
+ const sessionId = 'mobile-active-composition-data-test';
+ app.activeSessionId = sessionId;
+ app.sessions.set(sessionId, {
+ id: sessionId,
+ mode: 'codex',
+ status: 'running',
+ });
+ app._localEchoEnabled = true;
+ app._localEchoOverlay.clear();
+ app._terminalInputController.clearCompositionDelivery();
+ app._terminalInputController.setCompositionPending(false);
+
+ const textarea = app.terminal.textarea;
+ textarea.value = '';
+ textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
+ textarea.value = 'home';
+ textarea.dispatchEvent(
+ new CompositionEvent('compositionupdate', {
+ bubbles: true,
+ data: 'home',
+ })
+ );
+ app.terminal._core.coreService.triggerDataEvent('home', true);
+ const duringComposition = app._localEchoOverlay.pendingText;
+
+ textarea.dispatchEvent(
+ new CompositionEvent('compositionend', {
+ bubbles: true,
+ data: 'home',
+ })
+ );
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ return {
+ duringComposition,
+ pendingText: app._localEchoOverlay.pendingText,
+ compositionText: app._localEchoOverlay.compositionText,
+ };
+ });
+
+ expect(state).toEqual({
+ duringComposition: '',
+ pendingText: 'home',
+ compositionText: '',
+ });
+ });
+
+ it('strips a stale helper prefix from finalized Android composition data', async () => {
+ const state = await page.evaluate(async () => {
+ const sessionId = 'mobile-stale-composition-prefix-test';
+ app.activeSessionId = sessionId;
+ app.sessions.set(sessionId, {
+ id: sessionId,
+ mode: 'codex',
+ status: 'running',
+ });
+ app._localEchoEnabled = true;
+ app._localEchoOverlay.clear();
+ app._localEchoOverlay.appendText('cd');
+ app._terminalInputController.clearCompositionDelivery();
+ app._terminalInputController.setCompositionPending(false);
+
+ const textarea = app.terminal.textarea;
+ textarea.value = '';
+ textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
+ textarea.value = 'hcd home';
+ textarea.dispatchEvent(
+ new CompositionEvent('compositionupdate', {
+ bubbles: true,
+ data: ' home',
+ })
+ );
+ textarea.dispatchEvent(
+ new CompositionEvent('compositionend', {
+ bubbles: true,
+ data: ' home',
+ })
+ );
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ return {
+ pendingText: app._localEchoOverlay.pendingText,
+ compositionText: app._localEchoOverlay.compositionText,
+ helperValue: textarea.value,
+ };
+ });
+
+ expect(state).toEqual({
+ pendingText: 'cd home',
+ compositionText: '',
+ helperValue: '',
+ });
+ });
+
+ it('keeps a switched-away draft editable after browser state is reloaded', async () => {
+ const state = await page.evaluate(() => {
+ const firstId = 'mobile-draft-session-a';
+ const secondId = 'mobile-draft-session-b';
+ localStorage.removeItem('codeman:sessionDrafts');
+ app._inputState.clearAll({ persist: false });
+ app.sessions.set(firstId, { id: firstId, mode: 'codex', status: 'running' });
+ app.sessions.set(secondId, { id: secondId, mode: 'codex', status: 'running' });
+ app.activeSessionId = firstId;
+ app._localEchoEnabled = true;
+ app._localEchoOverlay.clear();
+ app._localEchoOverlay.appendText('switch-safe draft');
+ const sent: string[] = [];
+ app._sendInputAsync = (_sessionId: string, input: string) => sent.push(input);
+
+ app._cleanupPreviousSession(secondId);
+ app._inputState.persistNow();
+
+ // Rehydrate the input store exactly as a fresh browser page does.
+ app._localEchoOverlay.clear();
+ app._inputState.clearAll({ persist: false });
+ app._inputState.load();
+ app.activeSessionId = firstId;
+ app._restoreSessionDraft(firstId, false);
+ const restoredDraft = app._inputState.get(firstId);
+
+ return {
+ sent,
+ restored: app._localEchoOverlay.state,
+ flushedOffset: restoredDraft?.flushedText.length,
+ flushedText: restoredDraft?.flushedText,
+ };
+ });
+
+ expect(state).toMatchObject({
+ sent: ['switch-safe draft'],
+ restored: {
+ pendingText: '',
+ flushedLength: 'switch-safe draft'.length,
+ flushedText: 'switch-safe draft',
+ },
+ flushedOffset: 'switch-safe draft'.length,
+ flushedText: 'switch-safe draft',
+ });
+ });
+
+ it('removes a persisted draft once Enter submits it', async () => {
+ const state = await page.evaluate(() => {
+ const sessionId = 'mobile-cleared-draft-test';
+ const storageKey = `codeman:sessionDrafts:draft:${encodeURIComponent(sessionId)}`;
+ localStorage.removeItem('codeman:sessionDrafts');
+ localStorage.removeItem(storageKey);
+ app._inputState.clearAll({ persist: false });
+ app.activeSessionId = sessionId;
+ app.sessions.set(sessionId, { id: sessionId, mode: 'codex', status: 'running' });
+ app._localEchoEnabled = true;
+ app._localEchoOverlay.clear();
+ app._localEchoOverlay.appendText('submit me');
+ app._captureActiveSessionDraft();
+ app._inputState.persistNow();
+ const before = JSON.parse(localStorage.getItem(storageKey) || 'null')?.draft;
+
+ app._sendInputAsync = () => {};
+ app.terminal.input('\r');
+ app._inputState.persistNow();
+ const after = localStorage.getItem(storageKey);
+
+ return {
+ before: before?.pendingText,
+ after,
+ };
+ });
+
+ expect(state).toEqual({
+ before: 'submit me',
+ after: null,
+ });
+ });
+
+ it('shows and commits each live Android composition after the first word', async () => {
+ const preview = await page.evaluate(async () => {
+ app.activeSessionId = 'mobile-composition-test';
+ app.sessions.set('mobile-composition-test', {
+ id: 'mobile-composition-test',
+ mode: 'claude',
+ status: 'running',
+ });
+ app.hideWelcome();
+ const settings = app.loadAppSettingsFromStorage();
+ settings.cjkInputEnabled = false;
+ settings.localEchoEnabled = true;
+ app.saveAppSettingsToStorage(settings);
+ app._updateCjkInputState();
+ app._updateLocalEchoState();
+ app.terminal.reset();
+ await new Promise((resolve) => {
+ app.terminal.write('\x1b[2J\x1b[H\u276f ', resolve);
+ });
+ app._localEchoOverlay.clear();
+ app._localEchoOverlay.appendText('first ');
+
+ const textarea = app.terminal.textarea;
+ textarea.value = '';
+ textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
+ textarea.value = 'sec';
+ textarea.dispatchEvent(
+ new CompositionEvent('compositionupdate', {
+ bubbles: true,
+ data: 'sec',
+ })
+ );
+
+ const overlay = Array.from(app.terminal.element.querySelector('.xterm-screen')?.children || []).find(
+ (element) => (element as HTMLElement).style.zIndex === '7'
+ );
+ return {
+ pendingText: app._localEchoOverlay.pendingText,
+ compositionText: app._localEchoOverlay.compositionText,
+ visibleText: overlay?.textContent,
+ };
+ });
+
+ expect(preview).toEqual({
+ pendingText: 'first ',
+ compositionText: 'sec',
+ visibleText: expect.stringContaining('first sec'),
+ });
+
+ await page.evaluate(() => {
+ const textarea = app.terminal.textarea;
+ textarea.value = 'second';
+ textarea.dispatchEvent(
+ new CompositionEvent('compositionupdate', {
+ bubbles: true,
+ data: 'second',
+ })
+ );
+ textarea.dispatchEvent(
+ new CompositionEvent('compositionend', {
+ bubbles: true,
+ data: 'second',
+ })
+ );
+ });
+
+ await expect
+ .poll(() =>
+ page.evaluate(() => ({
+ pendingText: app._localEchoOverlay.pendingText,
+ compositionText: app._localEchoOverlay.compositionText,
+ }))
+ )
+ .toEqual({
+ pendingText: 'first second',
+ compositionText: '',
+ });
+
+ const nextPreview = await page.evaluate(async () => {
+ const textarea = app.terminal.textarea;
+ textarea.value = '';
+ textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
+ textarea.dispatchEvent(
+ new CompositionEvent('compositionupdate', {
+ bubbles: true,
+ data: 'abandoned',
+ })
+ );
+ textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: '' }));
+ textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
+ textarea.dispatchEvent(
+ new CompositionEvent('compositionupdate', {
+ bubbles: true,
+ data: 'third',
+ })
+ );
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ const nativeComposition = app.terminal.element.querySelector('.composition-view');
+ return {
+ pendingText: app._localEchoOverlay.pendingText,
+ compositionText: app._localEchoOverlay.compositionText,
+ nativeCompositionActive: nativeComposition?.classList.contains('active'),
+ nativeCompositionDisplay: nativeComposition ? getComputedStyle(nativeComposition).display : null,
+ localEchoClass: app.terminal.element.classList.contains('codeman-local-echo'),
+ };
+ });
+
+ expect(nextPreview).toEqual({
+ pendingText: 'first second',
+ compositionText: 'third',
+ nativeCompositionActive: true,
+ nativeCompositionDisplay: 'none',
+ localEchoClass: true,
+ });
+
+ const fallback = await page.evaluate(async () => {
+ const textarea = app.terminal.textarea;
+ const coreService = app.terminal._core.coreService;
+ const triggerDataEvent = coreService.triggerDataEvent;
+ coreService.triggerDataEvent = () => {};
+ try {
+ textarea.dispatchEvent(
+ new CompositionEvent('compositionend', {
+ bubbles: true,
+ data: 'third',
+ })
+ );
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ } finally {
+ coreService.triggerDataEvent = triggerDataEvent;
+ }
+ triggerDataEvent.call(coreService, ' ', true);
+ triggerDataEvent.call(coreService, 'third', true);
+ return {
+ pendingText: app._localEchoOverlay.pendingText,
+ compositionText: app._localEchoOverlay.compositionText,
+ fallbackCommit: app._terminalInputController.state.fallbackCommit,
+ };
+ });
+
+ expect(fallback).toEqual({
+ pendingText: 'first secondthird ',
+ compositionText: '',
+ fallbackCommit: null,
+ });
+ });
+
+ it('dedupes Android suggestion acceptance inside the replay window', async () => {
+ const state = await page.evaluate(async () => {
+ app.activeSessionId = 'mobile-composition-paused-space-test';
+ app.sessions.set('mobile-composition-paused-space-test', {
+ id: 'mobile-composition-paused-space-test',
+ mode: 'codex',
+ status: 'running',
+ });
+ app.hideWelcome();
+ const settings = app.loadAppSettingsFromStorage();
+ settings.cjkInputEnabled = false;
+ settings.localEchoEnabled = true;
+ app.saveAppSettingsToStorage(settings);
+ app._updateCjkInputState();
+ app._updateLocalEchoState();
+ app._localEchoOverlay.clear();
+
+ const textarea = app.terminal.textarea;
+ textarea.value = '';
+ textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
+ textarea.value = 'cd';
+ textarea.dispatchEvent(
+ new CompositionEvent('compositionupdate', {
+ bubbles: true,
+ data: 'cd',
+ })
+ );
+ textarea.dispatchEvent(
+ new CompositionEvent('compositionend', {
+ bubbles: true,
+ data: 'cd',
+ })
+ );
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ const afterComposition = app._localEchoOverlay.pendingText;
+
+ // Pressing Space can replay the finalized suggestion through xterm's
+ // alternate callback while the commit marker is still active.
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ app.terminal.input(' ');
+ textarea.value = 'cd';
+ textarea.dispatchEvent(
+ new InputEvent('input', {
+ bubbles: true,
+ composed: false,
+ inputType: 'insertText',
+ data: 'cd',
+ })
+ );
+ await new Promise((resolve) => setTimeout(resolve, 20));
+
+ return {
+ afterComposition,
+ pendingText: app._localEchoOverlay.pendingText,
+ };
+ });
+
+ expect(state).toEqual({
+ afterComposition: 'cd',
+ pendingText: 'cd ',
+ });
+ });
+
+ it('accepts identical Android text after the replay window expires', async () => {
+ const state = await page.evaluate(async () => {
+ app.activeSessionId = 'mobile-composition-delayed-replay-test';
+ app.sessions.set('mobile-composition-delayed-replay-test', {
+ id: 'mobile-composition-delayed-replay-test',
+ mode: 'codex',
+ status: 'running',
+ });
+ app.hideWelcome();
+ const settings = app.loadAppSettingsFromStorage();
+ settings.cjkInputEnabled = false;
+ settings.localEchoEnabled = true;
+ app.saveAppSettingsToStorage(settings);
+ app._updateCjkInputState();
+ app._updateLocalEchoState();
+ app._localEchoOverlay.clear();
+
+ const textarea = app.terminal.textarea;
+ textarea.value = '';
+ textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
+ textarea.value = 'cd';
+ textarea.dispatchEvent(
+ new CompositionEvent('compositionupdate', {
+ bubbles: true,
+ data: 'cd',
+ })
+ );
+ textarea.dispatchEvent(
+ new CompositionEvent('compositionend', {
+ bubbles: true,
+ data: 'cd',
+ })
+ );
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ app.terminal.input(' ');
+
+ // Once the bounded marker expires, identical input is treated as a new
+ // user action rather than suppressing legitimate repeated text forever.
+ await new Promise((resolve) => setTimeout(resolve, 1100));
+ textarea.value = 'cd';
+ textarea.dispatchEvent(
+ new InputEvent('input', {
+ bubbles: true,
+ composed: false,
+ inputType: 'insertText',
+ data: 'cd',
+ })
+ );
+ await new Promise((resolve) => setTimeout(resolve, 20));
+
+ return {
+ pendingText: app._localEchoOverlay.pendingText,
+ };
+ });
+
+ expect(state).toEqual({
+ pendingText: 'cd cd',
+ });
+ });
+
+ it('does not recommit Android final input when compositionend arrives afterward', async () => {
+ const state = await page.evaluate(async () => {
+ app.activeSessionId = 'mobile-input-before-compositionend-test';
+ app.sessions.set('mobile-input-before-compositionend-test', {
+ id: 'mobile-input-before-compositionend-test',
+ mode: 'codex',
+ status: 'running',
+ });
+ app.hideWelcome();
+ const settings = app.loadAppSettingsFromStorage();
+ settings.cjkInputEnabled = false;
+ settings.localEchoEnabled = true;
+ app.saveAppSettingsToStorage(settings);
+ app._updateCjkInputState();
+ app._updateLocalEchoState();
+ app._localEchoOverlay.clear();
+
+ const textarea = app.terminal.textarea;
+ textarea.value = '';
+ textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
+ textarea.value = 'cd';
+ textarea.dispatchEvent(
+ new CompositionEvent('compositionupdate', {
+ bubbles: true,
+ data: 'cd',
+ })
+ );
+
+ // Some Android keyboards expose the finalized word as a non-composing
+ // input mutation before they dispatch compositionend.
+ textarea.dispatchEvent(
+ new InputEvent('input', {
+ bubbles: true,
+ composed: false,
+ inputType: 'insertText',
+ data: 'cd',
+ })
+ );
+ const afterInput = app._localEchoOverlay.pendingText;
+ textarea.dispatchEvent(
+ new CompositionEvent('compositionend', {
+ bubbles: true,
+ data: 'cd',
+ })
+ );
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ return {
+ afterInput,
+ pendingText: app._localEchoOverlay.pendingText,
+ compositionText: app._localEchoOverlay.compositionText,
+ };
+ });
+
+ expect(state).toEqual({
+ afterInput: 'cd',
+ pendingText: 'cd',
+ compositionText: '',
+ });
+ });
+
+ it('delivers an Android composition once to an immediate-echo shell', async () => {
+ const state = await page.evaluate(async () => {
+ window.__sentInputs = [];
+ app.activeSessionId = 'mobile-shell-composition-test';
+ app.sessions.set('mobile-shell-composition-test', {
+ id: 'mobile-shell-composition-test',
+ mode: 'shell',
+ status: 'running',
+ });
+ app.hideWelcome();
+ app._sendInputAsync = (_sessionId: string, input: string) => {
+ window.__sentInputs.push(input);
+ };
+ const settings = app.loadAppSettingsFromStorage();
+ settings.cjkInputEnabled = false;
+ settings.localEchoEnabled = true;
+ app.saveAppSettingsToStorage(settings);
+ app._updateCjkInputState();
+ app._updateLocalEchoState();
+
+ const textarea = app.terminal.textarea;
+ textarea.value = '';
+ textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
+ textarea.value = 'cd';
+ textarea.dispatchEvent(
+ new CompositionEvent('compositionupdate', {
+ bubbles: true,
+ data: 'cd',
+ })
+ );
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ textarea.dispatchEvent(
+ new CompositionEvent('compositionend', {
+ bubbles: true,
+ data: 'cd',
+ })
+ );
+ await new Promise((resolve) => setTimeout(resolve, 20));
+
+ // Gboard can repeat the finalized word as a non-composing input event
+ // when the following space accepts its suggestion.
+ textarea.value = 'cd';
+ textarea.dispatchEvent(
+ new InputEvent('input', {
+ bubbles: true,
+ composed: false,
+ inputType: 'insertText',
+ data: 'cd',
+ })
+ );
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ app.terminal.input(' ');
+ await new Promise((resolve) => setTimeout(resolve, 20));
+
+ // xterm's helper textarea is an IME scratch buffer and may retain the
+ // earlier command prefix. InputEvent.data still identifies the newly
+ // inserted word; routing the whole textarea would resend "cd ".
+ textarea.value = 'cd home';
+ textarea.dispatchEvent(
+ new InputEvent('input', {
+ bubbles: true,
+ composed: false,
+ inputType: 'insertText',
+ data: 'home',
+ })
+ );
+ await new Promise((resolve) => setTimeout(resolve, 20));
+
+ return {
+ sentInputs: window.__sentInputs,
+ localEchoEnabled: app._localEchoEnabled,
+ localEchoClass: app.terminal.element?.classList.contains('codeman-local-echo'),
+ };
+ });
+
+ expect(state).toEqual({
+ sentInputs: ['cd', ' ', 'home'],
+ localEchoEnabled: false,
+ localEchoClass: false,
+ });
+ });
+
+ it('routes retained Android helper text once for a local-echo agent', async () => {
+ const state = await page.evaluate(async () => {
+ app.activeSessionId = 'mobile-agent-retained-helper-test';
+ app.sessions.set('mobile-agent-retained-helper-test', {
+ id: 'mobile-agent-retained-helper-test',
+ mode: 'codex',
+ status: 'running',
+ });
+ app.hideWelcome();
+ const settings = app.loadAppSettingsFromStorage();
+ settings.cjkInputEnabled = false;
+ settings.localEchoEnabled = true;
+ app.saveAppSettingsToStorage(settings);
+ app._updateCjkInputState();
+ app._updateLocalEchoState();
+ app._localEchoOverlay.clear();
+ app._localEchoOverlay.appendText('cd ');
+
+ const textarea = app.terminal.textarea;
+ textarea.value = 'cd home';
+ textarea.dispatchEvent(
+ new InputEvent('input', {
+ bubbles: true,
+ composed: false,
+ inputType: 'insertText',
+ data: 'home',
+ })
+ );
+ await Promise.resolve();
+
+ return {
+ pendingText: app._localEchoOverlay.pendingText,
+ helperValue: textarea.value,
+ };
+ });
+
+ expect(state).toEqual({
+ pendingText: 'cd home',
+ helperValue: '',
+ });
+ });
+
+ it('keeps routing Android textarea text after an interrupted composition', async () => {
+ const state = await page.evaluate(async () => {
+ app.activeSessionId = 'mobile-null-input-data-test';
+ app.sessions.set('mobile-null-input-data-test', {
+ id: 'mobile-null-input-data-test',
+ mode: 'claude',
+ status: 'running',
+ });
+ app.hideWelcome();
+ const settings = app.loadAppSettingsFromStorage();
+ settings.cjkInputEnabled = false;
+ settings.localEchoEnabled = true;
+ app.saveAppSettingsToStorage(settings);
+ app._updateCjkInputState();
+ app._updateLocalEchoState();
+ app.terminal.reset();
+ await new Promise((resolve) => {
+ app.terminal.write('\x1b[2J\x1b[H\u276f ', resolve);
+ });
+ app._localEchoOverlay.clear();
+ app.terminal._core.coreService.triggerDataEvent('a', true);
+
+ const textarea = app.terminal.textarea;
+ for (const data of ['b', 'c']) {
+ textarea.value = data;
+ textarea.dispatchEvent(
+ new InputEvent('input', {
+ bubbles: true,
+ inputType: 'insertText',
+ data: null,
+ })
+ );
+ await Promise.resolve();
+ }
+
+ const firstSequence = app._localEchoOverlay.pendingText;
+ textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
+ textarea.dispatchEvent(
+ new CompositionEvent('compositionupdate', {
+ bubbles: true,
+ data: 'interrupted',
+ })
+ );
+ app._localEchoOverlay.clear();
+ textarea.value = 'd';
+ textarea.dispatchEvent(
+ new InputEvent('input', {
+ bubbles: true,
+ inputType: 'insertText',
+ data: null,
+ })
+ );
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ return {
+ firstSequence,
+ pendingText: app._localEchoOverlay.pendingText,
+ helperValue: textarea.value,
+ };
+ });
+
+ expect(state).toEqual({
+ firstSequence: 'abc',
+ pendingText: 'd',
+ helperValue: '',
+ });
+ });
+
+ it('applies only the new Android helper-textarea mutation from cumulative values', async () => {
+ const state = await page.evaluate(async () => {
+ app.activeSessionId = 'mobile-cumulative-input-test';
+ app.sessions.set('mobile-cumulative-input-test', {
+ id: 'mobile-cumulative-input-test',
+ mode: 'claude',
+ status: 'running',
+ });
+ app.hideWelcome();
+ const settings = app.loadAppSettingsFromStorage();
+ settings.cjkInputEnabled = false;
+ settings.localEchoEnabled = true;
+ app.saveAppSettingsToStorage(settings);
+ app._updateCjkInputState();
+ app._updateLocalEchoState();
+ app._localEchoOverlay.clear();
+ app._localEchoOverlay.appendText('seed');
+
+ const textarea = app.terminal.textarea;
+ const beginMutation = (value: string) => {
+ textarea.value = value;
+ textarea.setSelectionRange(value.length, value.length);
+ textarea.dispatchEvent(
+ new KeyboardEvent('keydown', {
+ bubbles: true,
+ key: 'Process',
+ keyCode: 229,
+ })
+ );
+ textarea.dispatchEvent(
+ new InputEvent('beforeinput', {
+ bubbles: true,
+ cancelable: true,
+ inputType: 'insertText',
+ data: null,
+ })
+ );
+ };
+
+ beginMutation('seed');
+ textarea.value = 'seed next';
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length);
+ textarea.dispatchEvent(
+ new InputEvent('input', {
+ bubbles: true,
+ composed: true,
+ inputType: 'insertText',
+ data: null,
+ })
+ );
+ await Promise.resolve();
+ const afterNullData = app._localEchoOverlay.pendingText;
+
+ beginMutation('seed next');
+ textarea.value = 'seed next more';
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length);
+ textarea.dispatchEvent(
+ new InputEvent('input', {
+ bubbles: true,
+ composed: false,
+ inputType: 'insertText',
+ data: 'seed next more',
+ })
+ );
+ const immediateAfterCumulative = app._localEchoOverlay.pendingText;
+ await Promise.resolve();
+
+ return {
+ afterNullData,
+ immediateAfterCumulative,
+ pendingText: app._localEchoOverlay.pendingText,
+ helperValue: textarea.value,
+ };
+ });
+
+ expect(state).toEqual({
+ afterNullData: 'seed next',
+ immediateAfterCumulative: 'seed next more',
+ pendingText: 'seed next more',
+ helperValue: '',
+ });
+ });
+
+ it('filters xterm status replies without swallowing user navigation keys', async () => {
+ const state = await page.evaluate(() => {
+ const suppress = window.CodemanTerminalInput.shouldSuppressTerminalQueryResponse;
+ return {
+ deviceAttributes: suppress('\x1b[>0;276;0c'),
+ modeReport: suppress('\x1b[?2026;1$y'),
+ windowReport: suppress('\x1b[8;24;80t'),
+ statusString: suppress('\x1bP1$r0m\x1b\\'),
+ oscColor: suppress('\x1b]10;rgb:ffff/ffff/ffff\x1b\\'),
+ arrowUp: suppress('\x1b[A'),
+ escape: suppress('\x1b'),
+ };
+ });
+
+ expect(state).toEqual({
+ deviceAttributes: true,
+ modeReport: true,
+ windowReport: true,
+ statusString: true,
+ oscColor: true,
+ arrowUp: false,
+ escape: false,
+ });
+ });
+
+ it('routes Backspace past a stale autocomplete textarea buffer', async () => {
+ const state = await page.evaluate(() => {
+ window.__sentInputs = [];
+ app.activeSessionId = 'mobile-autocomplete-delete-test';
+ app.sessions.set('mobile-autocomplete-delete-test', {
+ id: 'mobile-autocomplete-delete-test',
+ mode: 'claude',
+ status: 'running',
+ });
+ app.hideWelcome();
+ app._sendInputAsync = (_sessionId: string, input: string) => {
+ window.__sentInputs.push(input);
+ };
+ const settings = app.loadAppSettingsFromStorage();
+ settings.cjkInputEnabled = false;
+ settings.localEchoEnabled = true;
+ app.saveAppSettingsToStorage(settings);
+ app._updateCjkInputState();
+ app._updateLocalEchoState();
+ app._localEchoOverlay.clear();
+ app._localEchoOverlay.appendText('draft');
+
+ const textarea = app.terminal.textarea;
+ const dispatchDelete = (inputType: string) => {
+ textarea.value = 'invisible autocomplete';
+ return textarea.dispatchEvent(
+ new InputEvent('beforeinput', {
+ bubbles: true,
+ cancelable: true,
+ inputType,
+ })
+ );
+ };
+
+ const firstAccepted = dispatchDelete('deleteContentBackward');
+ const secondAccepted = dispatchDelete('deleteWordBackward');
+ const pendingText = app._localEchoOverlay.pendingText;
+ app._localEchoOverlay.clear();
+ const remoteAccepted = dispatchDelete('deleteContentBackward');
+ return {
+ firstAccepted,
+ secondAccepted,
+ remoteAccepted,
+ pendingText,
+ helperValue: textarea.value,
+ sentInputs: window.__sentInputs,
+ };
+ });
+
+ expect(state).toEqual({
+ firstAccepted: false,
+ secondAccepted: false,
+ remoteAccepted: false,
+ pendingText: 'dra',
+ helperValue: '',
+ sentInputs: ['\x7f'],
+ });
+ });
+
+ it('keeps a pending phone draft anchored to the prompt after agent output', async () => {
+ await page.evaluate(async () => {
+ window.__sentInputs = [];
+ app.activeSessionId = 'mobile-draft-anchor-test';
+ app.sessions.set('mobile-draft-anchor-test', {
+ id: 'mobile-draft-anchor-test',
+ mode: 'codex',
+ status: 'running',
+ });
+ app.hideWelcome();
+ app._sendInputAsync = (_sessionId: string, input: string) => {
+ window.__sentInputs.push(input);
+ };
+ const settings = app.loadAppSettingsFromStorage();
+ settings.cjkInputEnabled = false;
+ settings.localEchoEnabled = true;
+ app.saveAppSettingsToStorage(settings);
+ app._updateCjkInputState();
+ app._updateLocalEchoState();
+ app.terminal.reset();
+ await new Promise((resolve) => {
+ app.terminal.write('\x1b[2J\x1b[H\u276f ', resolve);
+ });
+ app.terminal.focus();
+ });
+
+ await page.keyboard.type('keep this draft');
+ await expect.poll(() => page.evaluate(() => app._localEchoOverlay?.state.promptPosition?.row)).toBe(0);
+
+ await page.evaluate(() => {
+ app.batchTerminalWrite('\x1b[2J\x1b[Hagent output\r\ncontinues here\r\n\u276f ');
+ });
+
+ await expect.poll(() => page.evaluate(() => app._localEchoOverlay?.state.promptPosition?.row)).toBe(2);
+ const state = await page.evaluate(() => ({
pendingText: app._localEchoOverlay?.pendingText,
sentInputs: window.__sentInputs,
}));
- expect(beforeEnter.activeClass).toContain('xterm-helper-textarea');
- expect(beforeEnter.cjkDisplay).toBe('none');
- expect(beforeEnter.pendingText).toBe('find bug');
- expect(beforeEnter.sentInputs).toEqual([]);
+ expect(state.pendingText).toBe('keep this draft');
+ expect(state.sentInputs).toEqual([]);
+ });
+
+ it('submits multiline local echo as one bracketed paste frame', async () => {
+ await page.evaluate(() => {
+ window.__sentInputs = [];
+ app.activeSessionId = 'mobile-multiline-paste-test';
+ app.sessions.set('mobile-multiline-paste-test', {
+ id: 'mobile-multiline-paste-test',
+ mode: 'claude',
+ status: 'running',
+ });
+ app.hideWelcome();
+ app._sendInputAsync = (_sessionId: string, input: string) => {
+ window.__sentInputs.push(input);
+ };
+ const settings = app.loadAppSettingsFromStorage();
+ settings.cjkInputEnabled = false;
+ settings.localEchoEnabled = true;
+ app.saveAppSettingsToStorage(settings);
+ app._updateCjkInputState();
+ app._updateLocalEchoState();
+ app._localEchoOverlay.clear();
+ const pasteEvent = new Event('paste', { bubbles: true, cancelable: true });
+ Object.defineProperty(pasteEvent, 'clipboardData', {
+ value: {
+ getData: (type: string) => (type === 'text/plain' ? 'first paragraph\n\nsecond paragraph' : ''),
+ },
+ });
+ app.terminal.textarea.dispatchEvent(pasteEvent);
+ app.terminal.input('\r');
+ });
+
+ await expect
+ .poll(() => page.evaluate(() => window.__sentInputs))
+ .toEqual(['\x1b[200~first paragraph\r\rsecond paragraph\x1b[201~', '\r']);
+ });
+
+ it('captures Android xterm input-only paste before newline segmentation', async () => {
+ const pastedText = 'References\nfirst line after references\n\nfinal paragraph';
+ const captured = await page.evaluate((text) => {
+ window.__sentInputs = [];
+ app.activeSessionId = 'mobile-xterm-input-paste-test';
+ app.sessions.set('mobile-xterm-input-paste-test', {
+ id: 'mobile-xterm-input-paste-test',
+ mode: 'claude',
+ status: 'running',
+ });
+ app.hideWelcome();
+ app._sendInputAsync = (_sessionId: string, input: string) => {
+ window.__sentInputs.push(input);
+ };
+ const settings = app.loadAppSettingsFromStorage();
+ settings.cjkInputEnabled = false;
+ settings.localEchoEnabled = true;
+ app.saveAppSettingsToStorage(settings);
+ app._updateCjkInputState();
+ app._updateLocalEchoState();
+ app._localEchoOverlay.clear();
+
+ const textarea = app.terminal.textarea;
+ textarea.value = text;
+ textarea.dispatchEvent(
+ new InputEvent('input', {
+ bubbles: true,
+ inputType: 'insertFromPaste',
+ // Some Android builds expose only the first segment here. The
+ // helper textarea still contains the full inserted value.
+ data: 'References',
+ })
+ );
+ const pendingText = app._localEchoOverlay.pendingText;
+ const helperValue = textarea.value;
+ app.terminal.input('\r');
+ return { pendingText, helperValue };
+ }, pastedText);
+
+ expect(captured).toEqual({
+ pendingText: pastedText,
+ helperValue: '',
+ });
+ await expect
+ .poll(() => page.evaluate(() => window.__sentInputs))
+ .toEqual(['\x1b[200~References\rfirst line after references\r\rfinal paragraph\x1b[201~', '\r']);
+ });
+
+ it('confirms Android paste on a line break without duplicating word-space input', async () => {
+ const captured = await page.evaluate(() => {
+ window.__sentInputs = [];
+ app.activeSessionId = 'mobile-xterm-segmented-paste-test';
+ app.sessions.set('mobile-xterm-segmented-paste-test', {
+ id: 'mobile-xterm-segmented-paste-test',
+ mode: 'claude',
+ status: 'running',
+ });
+ app.hideWelcome();
+ app._sendInputAsync = (_sessionId: string, input: string) => {
+ window.__sentInputs.push(input);
+ };
+ const settings = app.loadAppSettingsFromStorage();
+ settings.cjkInputEnabled = false;
+ settings.localEchoEnabled = true;
+ app.saveAppSettingsToStorage(settings);
+ app._updateCjkInputState();
+ app._updateLocalEchoState();
+ app._localEchoOverlay.clear();
+
+ const textarea = app.terminal.textarea;
+ const dispatchMutation = (inputType: string, data: string | null = null) => {
+ const accepted = textarea.dispatchEvent(
+ new InputEvent('beforeinput', {
+ bubbles: true,
+ cancelable: true,
+ inputType,
+ data,
+ })
+ );
+ if (accepted && inputType === 'insertText' && data) {
+ app.terminal.input(data);
+ }
+ return accepted;
+ };
+
+ const wordInputAccepted = dispatchMutation('insertText', 'writing');
+ const spaceInputAccepted = dispatchMutation('insertText', ' ');
+ const typedText = app._localEchoOverlay.pendingText;
+ app._localEchoOverlay.clear();
+
+ dispatchMutation('insertText', 'References');
+ dispatchMutation('insertLineBreak');
+ dispatchMutation('insertText', 'first line after references');
+ dispatchMutation('insertLineBreak');
+ dispatchMutation('insertLineBreak');
+ dispatchMutation('insertText', 'final paragraph');
+
+ const pendingText = app._localEchoOverlay.pendingText;
+ const helperValue = textarea.value;
+ app.terminal.input('\r');
+ return {
+ wordInputAccepted,
+ spaceInputAccepted,
+ typedText,
+ pendingText,
+ helperValue,
+ };
+ });
+
+ expect(captured).toEqual({
+ wordInputAccepted: true,
+ spaceInputAccepted: true,
+ typedText: 'writing ',
+ pendingText: 'References\nfirst line after references\n\nfinal paragraph',
+ helperValue: '',
+ });
+ await expect
+ .poll(() => page.evaluate(() => window.__sentInputs))
+ .toEqual(['\x1b[200~References\rfirst line after references\r\rfinal paragraph\x1b[201~', '\r']);
+ });
+
+ it('routes the mobile paste dialog through the multiline paste boundary', async () => {
+ await page.evaluate(`(() => {
+ window.__pasteCalls = [];
+ app.activeSessionId = 'mobile-paste-dialog-test';
+ app.sendPastedText = (text, options) => {
+ window.__pasteCalls.push({ text, options });
+ };
+ KeyboardAccessoryBar.pasteFromClipboard();
+ })()`);
+
+ await page.locator('.paste-textarea').fill('first paragraph\n\nsecond paragraph');
+ await page.locator('.paste-send').click();
+
+ await expect
+ .poll(() => page.evaluate(() => window.__pasteCalls))
+ .toEqual([
+ {
+ text: 'first paragraph\n\nsecond paragraph',
+ options: { submit: true },
+ },
+ ]);
+ expect(await page.locator('.paste-overlay').count()).toBe(0);
+ });
+
+ it('keeps multiline paste on the newline-preserving fallback transport', async () => {
+ const calls = await page.evaluate(() => {
+ const sent = [];
+ app.activeSessionId = 'mobile-paste-fallback-test';
+ app.sessions.set('mobile-paste-fallback-test', {
+ id: 'mobile-paste-fallback-test',
+ mode: 'claude',
+ status: 'running',
+ });
+ app._sendInputAsync = (sessionId: string, input: string, options?: { useMux?: boolean }) => {
+ sent.push({ sessionId, input, options });
+ };
+ app.sendPastedText('first\n\nReferences\nmore after references', { submit: true });
+ return sent;
+ });
+
+ expect(calls).toEqual([
+ {
+ sessionId: 'mobile-paste-fallback-test',
+ input: '\x1b[200~first\r\rReferences\rmore after references\x1b[201~',
+ options: { useMux: false },
+ },
+ {
+ sessionId: 'mobile-paste-fallback-test',
+ input: '\r',
+ options: { useMux: false },
+ },
+ ]);
+ });
+
+ it('keeps back-to-back local echo submissions in text-then-Enter order', async () => {
+ await page.evaluate(() => {
+ window.__sentInputs = [];
+ app.activeSessionId = 'mobile-rapid-submit-test';
+ app.sessions.set('mobile-rapid-submit-test', {
+ id: 'mobile-rapid-submit-test',
+ mode: 'codex',
+ status: 'running',
+ });
+ app.hideWelcome();
+ app._sendInputAsync = (_sessionId: string, input: string) => {
+ window.__sentInputs.push(input);
+ };
+ const settings = app.loadAppSettingsFromStorage();
+ settings.cjkInputEnabled = false;
+ settings.localEchoEnabled = true;
+ app.saveAppSettingsToStorage(settings);
+ app._updateCjkInputState();
+ app._updateLocalEchoState();
+ app.terminal.focus();
+ });
+ await page.keyboard.type('first');
+ await page.keyboard.press('Enter');
+ await page.keyboard.type('second');
await page.keyboard.press('Enter');
- await page.waitForFunction(() => window.__sentInputs?.join('') === 'find bug\r');
- const afterEnter = await page.evaluate(() => ({
+ await expect.poll(() => page.evaluate(() => window.__sentInputs)).toEqual(['first', '\r', 'second', '\r']);
+ });
+
+ it('flushes local echo before mobile-control Enter and accepts the next draft', async () => {
+ const state = await page.evaluate(async () => {
+ window.__sentInputs = [];
+ app.activeSessionId = 'mobile-control-enter-test';
+ app.sessions.set('mobile-control-enter-test', {
+ id: 'mobile-control-enter-test',
+ mode: 'codex',
+ status: 'running',
+ });
+ app.hideWelcome();
+ app._sendInputAsync = (_sessionId: string, input: string) => {
+ window.__sentInputs.push(input);
+ };
+ app.sendResize = async () => false;
+ const settings = app.loadAppSettingsFromStorage();
+ settings.cjkInputEnabled = false;
+ settings.localEchoEnabled = true;
+ app.saveAppSettingsToStorage(settings);
+ app._updateCjkInputState();
+ app._updateLocalEchoState();
+ app.terminal.reset();
+ await new Promise((resolve) => {
+ app.terminal.write('\x1b[2J\x1b[H\u276f ', resolve);
+ });
+ app._localEchoOverlay.clear();
+
+ app.terminal._core.coreService.triggerDataEvent('stringA ', true);
+ app._localEchoOverlay.setCompositionText('stringB');
+ app.terminal.blur();
+ app.sendTerminalKey('\r');
+ app.terminal._core.coreService.triggerDataEvent('stringC', true);
+
+ return {
+ sentInputs: window.__sentInputs,
+ pendingText: app._localEchoOverlay.pendingText,
+ compositionText: app._localEchoOverlay.compositionText,
+ visible: app._localEchoOverlay.state.visible,
+ terminalFocused: document.activeElement === app.terminal.textarea,
+ };
+ });
+
+ expect(state).toEqual({
+ sentInputs: ['stringA stringB', '\r'],
+ pendingText: 'stringC',
+ compositionText: '',
+ visible: true,
+ terminalFocused: false,
+ });
+ });
+
+ it('holds the first phone draft until the initial prompt frame is loaded', async () => {
+ await page.evaluate(() => {
+ window.__sentInputs = [];
+ app.activeSessionId = 'mobile-initial-draft-test';
+ app.sessions.set('mobile-initial-draft-test', {
+ id: 'mobile-initial-draft-test',
+ mode: 'codex',
+ status: 'running',
+ });
+ app.hideWelcome();
+ app._sendInputAsync = (_sessionId: string, input: string) => {
+ window.__sentInputs.push(input);
+ };
+ const settings = app.loadAppSettingsFromStorage();
+ settings.cjkInputEnabled = false;
+ settings.localEchoEnabled = true;
+ app.saveAppSettingsToStorage(settings);
+ app._updateCjkInputState();
+ app._updateLocalEchoState();
+ app.terminal.reset();
+ app._beginBufferLoad('mobile-initial-draft-load');
+ app.terminal.focus();
+ });
+
+ await page.keyboard.type('first draft');
+
+ const loadingState = await page.evaluate(() => ({
pendingText: app._localEchoOverlay?.pendingText,
+ overlayState: app._localEchoOverlay?.state,
sentInputs: window.__sentInputs,
}));
- expect(afterEnter.pendingText).toBe('');
- expect(afterEnter.sentInputs.join('')).toBe('find bug\r');
+ expect(loadingState.pendingText).toBe('first draft');
+ expect(loadingState.overlayState?.visible).toBe(false);
+ expect(loadingState.overlayState?.promptPosition).toBeNull();
+ expect(loadingState.sentInputs).toEqual([]);
+
+ await page.evaluate(async () => {
+ await new Promise((resolve) => {
+ app.terminal.write('\x1b[2J\x1b[Hagent output\r\nready\r\n\u203a ', resolve);
+ });
+ app._finishBufferLoad('mobile-initial-draft-load');
+ });
+
+ await expect.poll(() => page.evaluate(() => app._localEchoOverlay?.state.promptPosition?.row)).toBe(2);
+ const readyState = await page.evaluate(() => ({
+ pendingText: app._localEchoOverlay?.pendingText,
+ visible: app._localEchoOverlay?.state.visible,
+ sentInputs: window.__sentInputs,
+ }));
+ expect(readyState.pendingText).toBe('first draft');
+ expect(readyState.visible).toBe(true);
+ expect(readyState.sentInputs).toEqual([]);
});
it('shows terminal local echo at the cursor when no prompt marker is visible', async () => {
@@ -758,7 +2131,10 @@ describe('Virtual Keyboard', () => {
app._updateCjkInputState();
app._updateLocalEchoState();
app.terminal.reset();
- await new Promise((resolve) => app.terminal.write('working without prompt marker', resolve));
+ window.__cursorFallbackRow = Math.max(0, app.terminal.rows - 2);
+ await new Promise((resolve) => {
+ app.terminal.write(`\x1b[${window.__cursorFallbackRow + 1};1Hworking without prompt marker`, resolve);
+ });
app.terminal.focus();
});
@@ -768,12 +2144,13 @@ describe('Virtual Keyboard', () => {
cjkDisplay: getComputedStyle(document.getElementById('cjkInput') as HTMLElement).display,
pendingText: app._localEchoOverlay?.pendingText,
overlayState: app._localEchoOverlay?.state,
+ expectedRow: window.__cursorFallbackRow,
}));
expect(state.cjkDisplay).toBe('none');
expect(state.pendingText).toBe('abc');
expect(state.overlayState?.visible).toBe(true);
- expect(state.overlayState?.promptPosition).not.toBeNull();
+ expect(state.overlayState?.promptPosition?.row).toBe(state.expectedRow);
});
});
@@ -827,6 +2204,36 @@ describe('Virtual Keyboard', () => {
expect(state.exists).toBe(true);
expect(state.keyboardVisible).toBe(false);
expect(state.hasViewportHandler).toBe(false);
+
+ const inputState = await page.evaluate(() => {
+ app.activeSessionId = 'desktop-input-event-test';
+ app.sessions.set('desktop-input-event-test', {
+ id: 'desktop-input-event-test',
+ mode: 'claude',
+ status: 'running',
+ });
+ const settings = app.loadAppSettingsFromStorage();
+ settings.localEchoEnabled = true;
+ app.saveAppSettingsToStorage(settings);
+ app._updateLocalEchoState();
+ app._localEchoOverlay.clear();
+ const accepted = app.terminal.textarea.dispatchEvent(
+ new InputEvent('beforeinput', {
+ bubbles: true,
+ cancelable: true,
+ inputType: 'insertText',
+ data: 'autocomplete',
+ })
+ );
+ return {
+ accepted,
+ pendingText: app._localEchoOverlay.pendingText,
+ };
+ });
+ expect(inputState).toEqual({
+ accepted: true,
+ pendingText: '',
+ });
} finally {
await context.close();
}
From 9bc6dcaff5d5d3f739f710ce7e19bd67636e23cf Mon Sep 17 00:00:00 2001
From: lior
Date: Wed, 29 Jul 2026 09:15:08 +0300
Subject: [PATCH 7/8] fix(input): acknowledge only accepted terminal writes
---
src/session.ts | 69 +++++++++++-----
src/web/routes/session-routes.ts | 55 ++++++++-----
src/web/routes/ws-routes.ts | 21 +++--
src/web/schemas.ts | 7 +-
src/web/ws-connection-registry.ts | 2 +-
test/mocks/mock-session.ts | 36 +++++++--
test/reliable-input-dedup.test.ts | 19 ++++-
test/routes/session-routes.test.ts | 122 +++++++++++++++++++++++++++++
test/routes/ws-routes.test.ts | 45 +++++++++++
9 files changed, 319 insertions(+), 57 deletions(-)
diff --git a/src/session.ts b/src/session.ts
index 98e05940..85dd7113 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -98,6 +98,7 @@ export type { BackgroundTask } from './task-tracker.js';
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
export type ResizeViewportType = 'mobile' | 'tablet' | 'desktop';
+export type InputDeliveryReservation = 'applied' | 'pending' | 'reserved';
/** Line buffer flush interval (100ms) - forces processing of partial lines */
const LINE_BUFFER_FLUSH_INTERVAL = 100;
@@ -2489,6 +2490,7 @@ export class Session extends EventEmitter {
* Remember to include `\r` (carriage return) to simulate pressing Enter.
*
* @param data - The input data to send (text, escape sequences, etc.)
+ * @returns true when a live PTY accepted the input
*
* @example
* ```typescript
@@ -2497,11 +2499,11 @@ export class Session extends EventEmitter {
* session.write('ls -la\r'); // Command with Enter
* ```
*/
- write(data: string): void {
+ write(data: string): boolean {
+ if (!this.ptyProcess) return false;
this._trackCodexSubmit(data);
- if (this.ptyProcess) {
- this.ptyProcess.write(data);
- }
+ this.ptyProcess.write(data);
+ return true;
}
// ── Codex thread tracking ─────────────────────────────────────────────
@@ -2528,25 +2530,43 @@ export class Session extends EventEmitter {
* eviction drops the least-recently-active client).
*/
private _appliedInputSeq = new Map();
+ private _pendingInputSeq = new Map();
private static readonly MAX_INPUT_DEDUP_CLIENTS = 256;
/**
- * Decide whether an input frame should be applied to the PTY or skipped as a
- * duplicate redelivery. Returns true exactly once per (clientId, seq): the
- * first time a seq strictly greater than the client's last-applied is seen.
- * A redelivery of an already-applied seq (the client never got our ACK and
- * resent) returns false. Callers should ACK regardless — a duplicate is, from
- * the client's view, "delivered" — and only `write()` the PTY when this is
- * true. Relies on the client delivering one client's frames in seq order over
- * a single ordered stream, so `seq <= last` ⇒ already applied.
+ * Return whether a reliable input frame was already applied.
*
- * Without this, the client's at-least-once redelivery (needed because a
- * half-open socket silently drops frames with no error) would type a prompt
- * twice whenever an ACK is lost after the write landed.
+ * Client frames are ordered, so any `seq <= last` is a duplicate redelivery.
*/
- shouldApplyInput(clientId: string, seq: number): boolean {
+ hasAppliedInput(clientId: string, seq: number): boolean {
const last = this._appliedInputSeq.get(clientId);
- if (last !== undefined && seq <= last) return false;
+ return last !== undefined && seq <= last;
+ }
+
+ /**
+ * Reserve a reliable input stream while its writer is in flight.
+ *
+ * HTTP mux delivery is asynchronous and can overlap a reconnecting WebSocket.
+ * A pending reservation prevents the second transport from writing or ACKing
+ * the same frame before the first transport confirms that it was accepted.
+ */
+ reserveInputDelivery(clientId: string, seq: number): InputDeliveryReservation {
+ if (this.hasAppliedInput(clientId, seq)) return 'applied';
+ if (this._pendingInputSeq.has(clientId)) return 'pending';
+ this._pendingInputSeq.set(clientId, seq);
+ return 'reserved';
+ }
+
+ /** Commit an accepted reservation, or release a failed one for retry. */
+ completeInputDelivery(clientId: string, seq: number, accepted: boolean): void {
+ if (this._pendingInputSeq.get(clientId) !== seq) return;
+ this._pendingInputSeq.delete(clientId);
+ if (accepted) this.markInputApplied(clientId, seq);
+ }
+
+ markInputApplied(clientId: string, seq: number): void {
+ const last = this._appliedInputSeq.get(clientId);
+ if (last !== undefined && seq <= last) return;
// Re-insert to move this client to the MRU end for fair eviction.
if (last !== undefined) this._appliedInputSeq.delete(clientId);
this._appliedInputSeq.set(clientId, seq);
@@ -2554,6 +2574,15 @@ export class Session extends EventEmitter {
const oldest = this._appliedInputSeq.keys().next().value;
if (oldest !== undefined) this._appliedInputSeq.delete(oldest);
}
+ }
+
+ /**
+ * Compatibility helper for synchronous callers and pure dedup tests.
+ * Routes that can fail or await a writer must use reserve/complete instead.
+ */
+ shouldApplyInput(clientId: string, seq: number): boolean {
+ if (this.reserveInputDelivery(clientId, seq) !== 'reserved') return false;
+ this.completeInputDelivery(clientId, seq, true);
return true;
}
@@ -2574,12 +2603,14 @@ export class Session extends EventEmitter {
* ```
*/
async writeViaMux(data: string): Promise {
- this._trackCodexSubmit(data);
if (this._mux && this._muxSession) {
- return this._mux.sendInput(this.id, data);
+ const sent = await this._mux.sendInput(this.id, data);
+ if (sent) this._trackCodexSubmit(data);
+ return sent;
}
// Fallback to PTY write
if (this.ptyProcess) {
+ this._trackCodexSubmit(data);
this.ptyProcess.write(data);
return true;
}
diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts
index 20636f8a..0ce8b98e 100644
--- a/src/web/routes/session-routes.ts
+++ b/src/web/routes/session-routes.ts
@@ -853,29 +853,44 @@ export function registerSessionRoutes(
// Reliable delivery (POST fallback when the WebSocket is down): a 2xx IS the
// client's ACK, so a tagged duplicate redelivery must still return 200 but
- // skip the write. Untagged requests (curl/legacy) always apply.
- if (typeof clientId === 'string' && typeof seq === 'number' && !session.shouldApplyInput(clientId, seq)) {
+ // skip the write. Untagged requests (curl/legacy) bypass deduplication.
+ const taggedInput = typeof clientId === 'string' && typeof seq === 'number';
+ const reservation = taggedInput ? session.reserveInputDelivery(clientId, seq) : 'reserved';
+ if (reservation === 'applied') {
return {};
}
+ if (reservation === 'pending') {
+ return createErrorResponse(ApiErrorCode.CONFLICT, 'Session input delivery is already in progress; retry shortly');
+ }
- // Write input to PTY. Direct write is synchronous; writeViaMux
- // (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
- if (useMux) {
- // Fire-and-forget: don't block HTTP response on tmux child process.
- // Fallback to direct write on failure.
- session
- .writeViaMux(inputStr)
- .then((ok) => {
- if (!ok) {
- console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
- session.write(inputStr);
- }
- })
- .catch(() => {
- session.write(inputStr);
- });
- } else {
- session.write(inputStr);
+ // A 2xx response is the durable queue's ACK, so both write paths must finish
+ // before returning. Otherwise the next seq can overtake an in-flight mux
+ // write, and a process exit can lose input the client already discarded.
+ let accepted = false;
+ try {
+ if (useMux) {
+ try {
+ accepted = await session.writeViaMux(inputStr);
+ } catch {
+ accepted = false;
+ }
+ if (!accepted) {
+ console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
+ accepted = session.write(inputStr);
+ }
+ } else {
+ accepted = session.write(inputStr);
+ }
+ } finally {
+ if (taggedInput) {
+ session.completeInputDelivery(clientId, seq, accepted);
+ }
+ }
+ if (!accepted) {
+ return createErrorResponse(
+ ApiErrorCode.CONFLICT,
+ 'Session input is not attached yet; retry after the terminal is ready'
+ );
}
return {};
});
diff --git a/src/web/routes/ws-routes.ts b/src/web/routes/ws-routes.ts
index 1b59d6eb..8e60d27a 100644
--- a/src/web/routes/ws-routes.ts
+++ b/src/web/routes/ws-routes.ts
@@ -174,19 +174,26 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
if (msg.t === 'i' && typeof msg.d === 'string') {
if (msg.d.length > MAX_INPUT_LENGTH) return;
// Reliable delivery: when the frame carries a clientId + seq, apply it
- // exactly once (skip a duplicate redelivery) but ACK it regardless so
- // the client can drop it from its durable queue. Frames without seq
- // (legacy/other tools) are applied as-is — no behavior change.
+ // exactly once. Applied duplicates are ACKed; frames that overlap an
+ // in-flight HTTP delivery remain unacknowledged until the client retries.
+ // Frames without seq (legacy/other tools) are applied as-is.
const cid = typeof msg.cid === 'string' ? msg.cid : null;
const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null;
- const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true;
- if (apply) {
+ const reservation = cid && seq !== null ? session.reserveInputDelivery(cid, seq) : 'reserved';
+ let accepted = reservation === 'applied';
+ if (reservation === 'reserved') {
// Typed input from a claim-holding desktop keeps the claim "hot"
// and re-asserts the desktop layout after a mobile override.
if (holdsDesktopClaim) session.noteDesktopActivity();
- session.write(msg.d);
+ try {
+ accepted = session.write(msg.d);
+ } finally {
+ if (cid && seq !== null) {
+ session.completeInputDelivery(cid, seq, accepted);
+ }
+ }
}
- if (seq !== null && socket.readyState === 1) {
+ if (accepted && seq !== null && socket.readyState === 1) {
socket.send(`{"t":"ia","seq":${seq}}`);
}
} else if (
diff --git a/src/web/schemas.ts b/src/web/schemas.ts
index a2034184..c58b9ae7 100644
--- a/src/web/schemas.ts
+++ b/src/web/schemas.ts
@@ -859,9 +859,10 @@ export const SessionInputWithLimitSchema = z.object({
// client tags each input with a stable clientId + a monotonic per-session seq
// and redelivers anything it hasn't seen ACKed (e.g. a frame silently dropped
// by a half-open WebSocket on a flaky link). The server applies each (clientId,
- // seq) at-most-once via Session.shouldApplyInput so a redelivery can't type the
- // prompt twice. `.optional()` (not `.nullish()`) — the client omits them when
- // unset rather than sending null. See docs/reliable-input-delivery.md.
+ // seq) at-most-once through Session's reservation/commit bookkeeping so a
+ // redelivery can't type the prompt twice. `.optional()` (not `.nullish()`) —
+ // the client omits them when unset rather than sending null. See
+ // docs/reliable-input-delivery.md.
seq: z.number().int().nonnegative().optional(),
clientId: z.string().max(128).optional(),
});
diff --git a/src/web/ws-connection-registry.ts b/src/web/ws-connection-registry.ts
index f73d229c..ad2e4eeb 100644
--- a/src/web/ws-connection-registry.ts
+++ b/src/web/ws-connection-registry.ts
@@ -20,7 +20,7 @@
* `cid` that already holds a socket is a SUPERSEDE — the registry evicts the
* stale socket and reuses its slot, which makes a reconnect reclaim rather
* than double-count (fixes #1 and #2). The cid is opaque here; input-frame
- * dedup uses the bare clientId separately (`session.shouldApplyInput`).
+ * dedup uses the bare clientId separately (Session's input-delivery bookkeeping).
*
* Backward-compat: an upgrade with NO `cid` (legacy clients, other tools) is
* admitted anonymously — it counts toward the limit but never evicts another
diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts
index bda2077b..bbf94d50 100644
--- a/test/mocks/mock-session.ts
+++ b/test/mocks/mock-session.ts
@@ -29,8 +29,9 @@ export class MockSession extends EventEmitter {
}
/** Direct PTY write (used by session.write()) */
- write(data: string): void {
+ write(data: string): boolean {
this.writeBuffer.push(data);
+ return true;
}
/** Write via mux (used by respawn controller) */
@@ -39,13 +40,38 @@ export class MockSession extends EventEmitter {
return true;
}
- /** Exactly-once input dedup — mirrors Session.shouldApplyInput so route tests
+ /** Exactly-once input dedup — mirrors Session's delivery API so route tests
* exercising the reliable-delivery path behave like production. */
private _appliedInputSeq = new Map();
- shouldApplyInput(clientId: string, seq: number): boolean {
+ private _pendingInputSeq = new Map();
+ hasAppliedInput(clientId: string, seq: number): boolean {
+ const last = this._appliedInputSeq.get(clientId);
+ return last !== undefined && seq <= last;
+ }
+
+ reserveInputDelivery(clientId: string, seq: number): 'applied' | 'pending' | 'reserved' {
+ if (this.hasAppliedInput(clientId, seq)) return 'applied';
+ if (this._pendingInputSeq.has(clientId)) return 'pending';
+ this._pendingInputSeq.set(clientId, seq);
+ return 'reserved';
+ }
+
+ completeInputDelivery(clientId: string, seq: number, accepted: boolean): void {
+ if (this._pendingInputSeq.get(clientId) !== seq) return;
+ this._pendingInputSeq.delete(clientId);
+ if (accepted) this.markInputApplied(clientId, seq);
+ }
+
+ markInputApplied(clientId: string, seq: number): void {
const last = this._appliedInputSeq.get(clientId);
- if (last !== undefined && seq <= last) return false;
- this._appliedInputSeq.set(clientId, seq);
+ if (last === undefined || seq > last) {
+ this._appliedInputSeq.set(clientId, seq);
+ }
+ }
+
+ shouldApplyInput(clientId: string, seq: number): boolean {
+ if (this.reserveInputDelivery(clientId, seq) !== 'reserved') return false;
+ this.completeInputDelivery(clientId, seq, true);
return true;
}
diff --git a/test/reliable-input-dedup.test.ts b/test/reliable-input-dedup.test.ts
index d77bef2d..f639b86b 100644
--- a/test/reliable-input-dedup.test.ts
+++ b/test/reliable-input-dedup.test.ts
@@ -1,10 +1,10 @@
/**
- * @fileoverview Exactly-once input delivery — Session.shouldApplyInput dedup.
+ * @fileoverview Exactly-once input delivery — Session delivery bookkeeping.
*
* Guards the server half of the reliable-input-delivery feature: the web client
* tags each input frame with a stable clientId + a monotonic per-session seq and
* redelivers anything it hasn't seen ACKed (a half-open socket silently drops
- * frames on a flaky link). shouldApplyInput must apply each (clientId, seq)
+ * frames on a flaky link). The Session delivery API must apply each (clientId, seq)
* exactly once so a redelivery can never type the prompt twice — while still
* applying untagged input (curl/legacy) unconditionally at the call sites.
*
@@ -70,4 +70,19 @@ describe('Session.shouldApplyInput (exactly-once input dedup)', () => {
expect(s.shouldApplyInput('recent', 1)).toBe(false);
expect(s.shouldApplyInput('recent', 2)).toBe(true);
});
+
+ it('keeps an in-flight frame retryable until its writer accepts it', () => {
+ const s = makeSession();
+
+ expect(s.reserveInputDelivery('client', 1)).toBe('reserved');
+ expect(s.reserveInputDelivery('client', 1)).toBe('pending');
+ expect(s.reserveInputDelivery('client', 2)).toBe('pending');
+
+ s.completeInputDelivery('client', 1, false);
+ expect(s.reserveInputDelivery('client', 1)).toBe('reserved');
+ s.completeInputDelivery('client', 1, true);
+
+ expect(s.reserveInputDelivery('client', 1)).toBe('applied');
+ expect(s.reserveInputDelivery('client', 2)).toBe('reserved');
+ });
});
diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts
index 259ed71d..9df87fe2 100644
--- a/test/routes/session-routes.test.ts
+++ b/test/routes/session-routes.test.ts
@@ -485,6 +485,128 @@ describe('session-routes', () => {
expect(body.success).toBe(true);
});
+ it('waits for mux delivery before returning the reliable-input ACK', async () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any;
+ let releaseMux!: (accepted: boolean) => void;
+ const writeViaMux = vi.spyOn(session, 'writeViaMux').mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ releaseMux = resolve;
+ })
+ );
+ let settled = false;
+
+ const responsePromise = harness.app
+ .inject({
+ method: 'POST',
+ url: `/api/sessions/${harness.ctx._sessionId}/input`,
+ payload: {
+ input: 'prompt',
+ useMux: true,
+ seq: 1,
+ clientId: 'mux-wait-client',
+ },
+ })
+ .then((response) => {
+ settled = true;
+ return response;
+ });
+
+ await vi.waitFor(() => {
+ expect(writeViaMux).toHaveBeenCalledOnce();
+ });
+ expect(settled).toBe(false);
+
+ releaseMux(true);
+ const response = await responsePromise;
+ expect(response.statusCode).toBe(200);
+ });
+
+ it('keeps concurrent retries behind an in-flight mux delivery', async () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any;
+ let releaseMux!: (accepted: boolean) => void;
+ const writeViaMux = vi.spyOn(session, 'writeViaMux').mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ releaseMux = resolve;
+ })
+ );
+ const payload = {
+ input: 'prompt',
+ useMux: true,
+ seq: 1,
+ clientId: 'overlap-client',
+ };
+ const first = harness.app.inject({
+ method: 'POST',
+ url: `/api/sessions/${harness.ctx._sessionId}/input`,
+ payload,
+ });
+ await vi.waitFor(() => {
+ expect(writeViaMux).toHaveBeenCalledOnce();
+ });
+
+ const overlap = await harness.app.inject({
+ method: 'POST',
+ url: `/api/sessions/${harness.ctx._sessionId}/input`,
+ payload,
+ });
+ expect(overlap.statusCode).toBe(409);
+ expect(writeViaMux).toHaveBeenCalledOnce();
+
+ releaseMux(true);
+ expect((await first).statusCode).toBe(200);
+
+ const duplicate = await harness.app.inject({
+ method: 'POST',
+ url: `/api/sessions/${harness.ctx._sessionId}/input`,
+ payload,
+ });
+ expect(duplicate.statusCode).toBe(200);
+ expect(writeViaMux).toHaveBeenCalledOnce();
+ });
+
+ it('keeps a tagged frame retryable until a PTY accepts it', async () => {
+ const url = `/api/sessions/${harness.ctx._sessionId}/input`;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any;
+ session.writeBuffer.length = 0;
+ const write = vi
+ .spyOn(session, 'write')
+ .mockReturnValueOnce(false)
+ .mockImplementation((data: string) => {
+ session.writeBuffer.push(data);
+ return true;
+ });
+ const payload = {
+ input: 'restored draft',
+ seq: 1,
+ clientId: 'attach-race-client',
+ };
+
+ const unavailable = await harness.app.inject({
+ method: 'POST',
+ url,
+ payload,
+ });
+
+ expect(unavailable.statusCode).toBe(409);
+ expect(session.hasAppliedInput(payload.clientId, payload.seq)).toBe(false);
+
+ const retry = await harness.app.inject({
+ method: 'POST',
+ url,
+ payload,
+ });
+
+ expect(retry.statusCode).toBe(200);
+ expect(write).toHaveBeenCalledTimes(2);
+ expect(session.writeBuffer).toEqual(['restored draft']);
+ expect(session.hasAppliedInput(payload.clientId, payload.seq)).toBe(true);
+ });
+
it('returns error for unknown session', async () => {
const res = await harness.app.inject({
method: 'POST',
diff --git a/test/routes/ws-routes.test.ts b/test/routes/ws-routes.test.ts
index 5b722036..8294410b 100644
--- a/test/routes/ws-routes.test.ts
+++ b/test/routes/ws-routes.test.ts
@@ -208,6 +208,51 @@ describe('ws-routes', () => {
}
});
+ it('does not ACK a tagged frame until the PTY accepts it', async () => {
+ const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
+ const received: Array<{ t: string; seq?: number }> = [];
+ const onMessage = (raw: WebSocket.RawData) => {
+ received.push(JSON.parse(String(raw)));
+ };
+ ws.on('message', onMessage);
+ try {
+ const session = ctx._session;
+ const write = vi
+ .spyOn(session, 'write')
+ .mockReturnValueOnce(false)
+ .mockImplementation((data: string) => {
+ session.writeBuffer.push(data);
+ return true;
+ });
+ const frame = {
+ t: 'i',
+ d: 'restored draft',
+ cid: 'attach-race-client',
+ seq: 1,
+ };
+
+ ws.send(JSON.stringify(frame));
+ await vi.waitFor(() => {
+ expect(write).toHaveBeenCalledTimes(1);
+ });
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ expect(received).toEqual([]);
+ expect(session.hasAppliedInput(frame.cid, frame.seq)).toBe(false);
+
+ ws.send(JSON.stringify(frame));
+ await vi.waitFor(() => {
+ expect(received).toContainEqual({ t: 'ia', seq: 1 });
+ });
+
+ expect(write).toHaveBeenCalledTimes(2);
+ expect(session.writeBuffer).toEqual(['restored draft']);
+ expect(session.hasAppliedInput(frame.cid, frame.seq)).toBe(true);
+ } finally {
+ ws.off('message', onMessage);
+ ws.close();
+ }
+ });
+
it('ignores input exceeding MAX_INPUT_LENGTH', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
From 9cccfef0f4650c79d14a94309b969c98dced97cf Mon Sep 17 00:00:00 2001
From: lior
Date: Wed, 29 Jul 2026 09:15:33 +0300
Subject: [PATCH 8/8] docs(input): document persistent delivery invariants
---
.changeset/persistent-terminal-input.md | 7 +
CLAUDE.md | 4 +-
docs/architecture-invariants.md | 6 +-
docs/local-echo-overlay-plan.md | 228 ++++++++++++++----------
docs/reliable-input-delivery.md | 132 ++++++++++++--
5 files changed, 259 insertions(+), 118 deletions(-)
create mode 100644 .changeset/persistent-terminal-input.md
diff --git a/.changeset/persistent-terminal-input.md b/.changeset/persistent-terminal-input.md
new file mode 100644
index 00000000..429f4254
--- /dev/null
+++ b/.changeset/persistent-terminal-input.md
@@ -0,0 +1,7 @@
+---
+'aicodeman': patch
+'xterm-zerolag-input': patch
+---
+
+Persist editable terminal drafts and centralize browser input ownership across
+typing, IME composition, paste, mobile controls, and session transitions.
diff --git a/CLAUDE.md b/CLAUDE.md
index 9abf9837..56bf0e3f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -229,7 +229,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) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `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) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `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).
+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-input-state.js`(5.55) → `terminal-input-controller.js`(5.58) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `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) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `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).
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
@@ -247,7 +247,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 430px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged.
-⚠️ **`sendEnterKey()` MUST go through `terminal._core.coreService.triggerDataEvent('\r', true)`** — not `sendInput()`, and never a raw POST to `/api/sessions/:id/input`. `localEchoEnabled` defaults to `MobileDetection.isTouchDevice()`, so on every phone the characters you type are buffered in the `LocalEchoOverlay` and have **never reached the PTY**; the `onData` Enter branch in terminal-ui.js is what flushes `pendingText` first and only then sends `\r` (after an 80ms delay so text lands first). Sending a bare `\r` submits an empty line and strands the typed text on screen, so the button looks dead. Replaying the keypress reuses the overlay flush, the flushed-offset cleanup and the ordering instead of reimplementing them. `KeyboardAccessory.sendKey()` is for escape sequences (arrows/Esc) and is the WRONG template to copy for input.
+⚠️ **`sendEnterKey()` MUST go through `TerminalInputController.sendControl('\r')`** — not `sendInput()`, xterm's private `triggerDataEvent()`, or a raw POST to `/api/sessions/:id/input`. `localEchoEnabled` defaults to `MobileDetection.isTouchDevice()`, so on a phone the visible draft may still be owned by `LocalEchoOverlay` and has not reached the PTY. The controller atomically clears the durable draft, delivers its text first, and orders Enter after it. Bypassing the controller can submit an empty line, strand the draft, or race another producer. `KeyboardAccessory.sendKey()` is for escape sequences (arrows/Esc), while all interactive browser input belongs at the controller facade described in `docs/architecture-invariants.md`.
⚠️ **Skin overrides outrank plain class rules.** `styles.css` nests its skin block inside `html:not([data-skin="og"]) { … }`, so a bare `.btn-toolbar` rule in there resolves to specificity **(0,2,1)** and beats a `.btn-toolbar.btn-x` rule **(0,2,0)** in `mobile.css` regardless of load order. Toolbar-button colors set from mobile.css therefore need `!important` — that is why mobile.css leans on it so heavily. Symptom: only your `!important` properties land and everything else silently renders in generic toolbar grey.
diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md
index 6f954c77..42fdbe4e 100644
--- a/docs/architecture-invariants.md
+++ b/docs/architecture-invariants.md
@@ -1,6 +1,6 @@
# Architecture invariants
-Implementation detail extracted from `CLAUDE.md` so that file stays small enough to load into every session cheaply. Nothing here was rewritten: these are the original paragraphs, verbatim, including the version history and PR references that explain *why* each rule exists.
+Implementation detail extracted from `CLAUDE.md` so that file stays small enough to load into every session cheaply. Nothing here was rewritten: these are the original paragraphs, verbatim, including the version history and PR references that explain _why_ each rule exists.
`CLAUDE.md` keeps the short form of each rule plus a pointer to the section here. Read the pointer first; come here when you need the mechanism, the file names, or the history behind a constraint.
@@ -38,7 +38,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
### Input delivery and WS resilience
-**Input**: `session.writeViaMux()` for programmatic/curl input — tmux `send-keys -l` (literal) + `send-keys Enter`. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`).
+**Input**: `session.writeViaMux()` for programmatic/curl input — tmux `send-keys -l` (literal) + `send-keys Enter`. Single-line only (fire-and-once). Every interactive **browser** producer (xterm, Android IME/helper textarea, CJK/voice, paste, accessory controls) first enters `TerminalInputController` (`src/web/public/terminal-input-controller.js`), which solely owns transient composition/mutation/dedup state, local-echo edits, Tab/PTY ownership, interaction-prompt controls, and action ordering. Do not add a direct producer-to-transport path. The controller feeds two deliberately separate durable states through injected ports. `TerminalInputStateStore` (`src/web/public/terminal-input-state.js`) solely owns editable per-session drafts under `codeman:sessionDrafts:draft:` (and migrates the legacy aggregate `codeman:sessionDrafts` record): pending/flushed/CJK text survives session and Home switches, browser minimization or tab discard, phone sleep, and reload without submission. Its explicit `handoff()` output lets switching flush pending text into that session's PTY while retaining flushed metadata for correct mobile Backspace. Submitted frames use the **exactly-once** layer: each carries a stable `clientId` + monotonic per-session `seq`, persisted in `codeman:pendingInput` until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect cannot lose or double-deliver a prompt. ACK means the live PTY/mux accepted the frame: restored-session attach returns 409/no WS ACK and keeps the client record retryable. Never merge draft storage with delivery records. **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`).
### Auto-resume on usage limit
@@ -166,7 +166,7 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in
**Light skins carry three extra obligations.** `color-scheme: light` on the `html[data-skin]` block, or the UA renders native `