Typing a prompt long enough to wrap ran the text off the bottom of the screen: the
tail — the part being typed, where the cursor is — sat behind the on-screen
keyboard, so the user was typing blind. Two independent causes.
**The overlay had no bottom bound.** On touch devices keystrokes are buffered in
the local-echo overlay and do not reach the PTY until Enter, so the CLI never
learns the prompt is long and nothing scrolls or reflows to make room. Meanwhile
the renderer lays its wrapped lines out straight DOWNWARD from the prompt row
(`top = promptRow * cellH`, each line at `i * cellH`) with nothing clamping it to
the visible rows — and with the keyboard up there are only a handful of those.
The block now grows UPWARD once it would pass the last visible row: it is lifted
so its final line lands ON that row. Every line div is opaque, so it covers
transcript above rather than vanishing under the keyboard below — the same thing a
real terminal does when a composer expands. A prompt taller than the whole
viewport keeps its TAIL, for the same reason the fix exists: the end is what the
user is looking at. `startCol` indents only the line that starts at the prompt
marker, so it is dropped along with that line when only the tail fits, and the
cursor follows the last VISIBLE line.
`rows` joins the render key: the layout depends on it, so a keyboard opening —
which changes rows without changing the text — must not be skipped as a redundant
render.
**`_shrinkPaddingToFit()` was reclaiming the bars' own space.** On phones the
toolbar and accessory bar are `position: fixed`, so they occupy no layout space
and `main`'s padding-bottom is the ONLY thing reserving room for them. Shrinking
it by the full sub-row slack pulled the terminal's bottom edge down underneath
them, and the row the following re-fit gained was painted behind them — clipping
the last line of a long prompt. The shrink now has a floor: the MEASURED height of
the currently-visible fixed bars, so genuine over-reservation of the hard-coded
84px is still reclaimed while a device that needs those pixels keeps them. The
floor is `Math.min(currentPadding, measured)`, so it can only ever prevent a
shrink, never cause a grow that would resize the terminal as a side effect.
Overlay behaviour lives in `packages/xterm-zerolag-input/` (single-source; the
vendor bundles are generated), so the fix is in the package with the row count
passed in as an optional `totalRows` — absent, the layout is exactly as before.
Tests: 7 cases in the package's `overlay-renderer.test.ts` (upward lift, tail
retention, indent drop, cursor on the last visible line, and the unclamped
fallbacks) and 7 in a new `test/mobile-keyboard-bottom-padding.test.ts` (reclaim,
floor, partial reclaim, no-grow, hidden bars, CJK strip, whole-row slack). 5 and 4
of them respectively fail without the fix. Package suite 238 pass, including the
codex byte-identity and replay tests.
Verified on Android + Chrome against a live instance: a ~460-character prompt
wrapping ~12 rows stays on screen while typing and arrives at the PTY intact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With a real codex login now available, record the one shape the fake-key
lab could never produce: a genuine model reply streaming above the pinned
composer, pushing lines into history (baseY grows) while keystrokes land
mid-stream. The recorder gains an opt-in CODEX_RECORD_REAL=1 scenario
using the user's own ~/.codex (fixture secret-scanned for key/JWT
material before writing; scanned clean). The replay test pins: baseY > 0,
mid-stream predictions painted, exact convergence to the typed text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Independent post-build review found three gaps, all one family: input that
changes the composer without a prediction leaves the DISPLAYED cursor stale
for one RTT, and anchoring a new run on it painted ghosts one cell off
(blank-neutral, so they lived out the full TTL: "tehh" on
backspace-then-retype, exactly on the links the feature targets).
Fix: the addon now HOLDS new predictions after any such edit (backspace with
nothing outstanding = deleting echoed text, clearPredictions, and now also
IME/plain-paste 'text' commits, which the hook clears like 'clear') until
the next PARSED write releases the hold. The inline predictChar reconcile
deliberately does not count: only the emitter pass or the public
reconcile() is the display-caught-up contract. Worst case is exactly one
unpredicted keystroke, whose own echo releases the hold. Also patched the
one bypass path the PR had missed: _handleCjkInput now clears predictions
like insertTerminalText and the other bypass sends.
Package suite 230, vm gating 85, E2E 10/10 all green after the change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mosh-style write-through prediction: the consumer sends every keystroke
unchanged; the addon paints predicted glyphs and reconciles against the
parsed buffer. Confirm = cell match + cursor advance (placeholder-safe,
repaint-safe); two-pass mismatch cascade with neutral blanks (measured:
codex clears its placeholder on first echo); TTL bound; baseY-based line
reads; scroll/resize/off-row clears. Zero edits to zerolag-input-addon.ts.
Tests: 30 addon-law specs + renderer geometry (fake performance clock for
TTL/grace), 6 replay suites running the real algorithm through a real
@xterm/headless parser fed by the recorded codex fixtures, and a
500-iteration seeded fuzz with per-op span/record + grid invariants.
227 total, the pre-existing 175 untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Recorder (scripts/dev/record-codex-frames.mjs) captures real codex 0.147
TUI output through the production pipeline (tmux status-off + the codex-mode
full strip from session.ts) into JSONL fixtures with keystroke injection
points; analyzer replays them through @xterm/headless for the measurements
in docs/predictive-echo-plan.md. Composer signature /^> /-style (U+203A),
modal and wrapped rows correctly rejected, echo is unstyled default-fg,
tmux delivers echo as minimal in-place deltas.
Package: types.ts gains optional cursorX/cursorY, getCell, onWriteParsed,
onResize (all additive); prediction-renderer.ts renders per-glyph spans
keyed by prediction seq; @xterm/headless@^6.0.0 devDep for replay tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers all 7 test plan items: Chinese overlap prevention, double-width
spacing, Japanese/Korean input, cursor positioning, line wrapping at
column boundaries, ASCII regression, and teammate terminal panels.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add charCellWidth/stringCellWidth helpers for Unicode-aware width detection,
fix makeLine to use for...of iteration with visual column positioning, and
fix line splitting in _render to use visual column widths instead of string
length. CJK/fullwidth characters now correctly occupy 2 cell widths.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pitfall 3 (REAL BUG): setFlushed() called _render() unconditionally.
During tab switch, rendering against a stale buffer found the old
session's prompt and locked the column position via the flushed-text
column lock. After the new buffer loaded, rerender() kept the stale
column. Fix: add render parameter (default true), pass false during
tab-switch restore to defer rendering until buffer is loaded.
Pitfall 2: Tab completion "undo" required calling clearFlushed() +
resetBufferDetection() separately — easy to get wrong. Add
undoDetection() convenience method that atomically clears flushed
state and re-enables detection.
Pitfall 1: Backspace Map sync is app-specific (can't be fixed in lib),
but removeChar() return value + getFlushed() make the pattern clean.
Updated tab-switch test to demonstrate the correct pattern.
Tests: 78 total (+4 new)
- setFlushed render=false prevents stale column lock
- setFlushed render=false keeps overlay hidden
- undoDetection clears flushed + re-enables detection
- undoDetection preserves pending text
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Second deep audit comparing all 43 integration points in Claudeman's
app.js against the library API found one critical gap:
- Add suppressBufferDetection() — needed when switching to sessions
with UI framework text (Ink) after the prompt that would be falsely
detected. Claudeman sets _bufferDetectDone=true externally; this
method provides the public equivalent.
Tests: 74 total (+13 new)
- Tab-switch save/restore pattern (flushed state roundtrip)
- suppressBufferDetection blocks explicit, implicit (addChar), and
cascade (removeChar) detection paths
- clear() resets suppression
- Methods safe before activate() and after dispose()
- addChar implicit buffer detection on first keystroke
- refreshFont with flushed-only text
README: rewritten from 207 to 459 lines with:
- removeChar cascade explanation with return value table
- Flushed text concept explained
- Integration patterns: buffered, char-at-a-time, tab switching,
tab completion, Ink/TUI frameworks, SSE reconnect, resize, font
- Architecture diagram (keypress → DOM overlay → PTY echo flow)
- Prompt column locking, text wrapping, render cache, scroll awareness
- All known limitations documented
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Regex global flag safety — strip `g` flag before matching to prevent
lastIndex mutation and missing .index on match results
2. state.visible false before activate — null overlay no longer reports
visible: true
3. removeChar() returns 'pending' | 'flushed' | false instead of boolean
so consumers can distinguish whether to send backspace to PTY
4. removeChar() implements buffer detection cascade (step 3) — detects
existing prompt text when both pending and flushed are empty
5. RenderKey includes text content, not just length — prevents stale
renders when setFlushed() called with same count but different text
6. Remove dead `import type { Terminal }` from test file
7. Remove internal `FontStyle` from public exports
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract Claudeman's local echo overlay into a reusable xterm.js addon
at packages/xterm-zerolag-input/. Provides instant keystroke feedback
via a DOM overlay, eliminating perceived input latency over high-RTT
connections (SSH, mobile, cloud IDEs).
- Zero dependencies, compatible with xterm v5.x and @xterm/xterm v5.4+
- Configurable prompt detection (character, regex, or custom function)
- Flushed text tracking for tab-switch / deferred echo scenarios
- Per-character grid-aligned rendering matching xterm's canvas output
- 56 tests passing (prompt finder, overlay renderer, full addon lifecycle)
- Dual CJS/ESM build with full TypeScript declarations
Claudeman source is unchanged — migration to consume this lib is a
separate follow-up.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>