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 `
@@ -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: '' });
});
});
});
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/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/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/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/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 @@