fix: add suppressBufferDetection, comprehensive docs and tests

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>
This commit is contained in:
arkon
2026-02-22 10:44:41 +01:00
co-authored by Claude Opus 4.6
parent 53132e3a38
commit ce188e6279
3 changed files with 432 additions and 50 deletions
+255 -50
View File
@@ -18,6 +18,8 @@ When using xterm.js over a remote connection (SSH web clients, cloud IDEs, mobil
npm install xterm-zerolag-input
```
Zero runtime dependencies. Compatible with both `xterm` (pre-5.4) and `@xterm/xterm` (5.4+).
## Quick Start
```typescript
@@ -44,12 +46,10 @@ terminal.onData((data) => {
if (source === 'flushed') ws.send(data); // only backspace text already in PTY
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
zerolag.addChar(data);
// Don't send to server yet — wait for Enter
// Or send immediately if your app uses char-at-a-time mode
}
});
// 3. Re-render after terminal output (optional, for frameworks like Ink)
// 3. Re-render after terminal output (optional, for full-screen TUI frameworks)
terminal.onWriteParsed(() => {
if (zerolag.hasPending) zerolag.rerender();
});
@@ -57,10 +57,11 @@ terminal.onWriteParsed(() => {
## Prompt Detection
The addon needs to know where user input starts on the terminal line. Three strategies are supported:
The addon needs to know where user input starts on the terminal line. It scans the terminal buffer bottom-up looking for the prompt. Three strategies are supported:
### Character (default)
Scans bottom-up for a single character:
Scans bottom-up for a single character. Uses `lastIndexOf` to find the rightmost occurrence on each line.
```typescript
// Bash: user@host:~$
@@ -76,21 +77,23 @@ Scans bottom-up for a single character:
{ type: 'character', char: '>', offset: 2 }
```
The `offset` is how many characters after the marker the input begins (e.g., `"$ "` = 2).
The `offset` is how many characters after the marker the user input begins (e.g., `"$ "` = 2, `">"` with no space = 1).
### Regex
For complex prompts:
For complex prompts. The global (`g`) flag is safely stripped to prevent `lastIndex` mutation across renders.
```typescript
// Match end-of-prompt patterns
// Match dollar sign at end of prompt
{ type: 'regex', pattern: /\$\s*$/, offset: 2 }
// Match specific prompt format
// Match specific virtualenv prompt
{ type: 'regex', pattern: /\(venv\)\s+\w+\s+%/, offset: 2 }
```
### Custom
Full control:
Full control — provide your own function:
```typescript
{
@@ -98,64 +101,93 @@ Full control:
offset: 0,
find: (terminal) => {
// Your logic here — return { row, col } or null
// row is viewport-relative (0 = top of viewport)
return { row: terminal.rows - 1, col: 0 };
},
}
```
## API
## API Reference
### `ZerolagInputAddon`
Implements xterm.js `ITerminalAddon`. Load via `terminal.loadAddon(addon)`.
The addon does **not** hook `terminal.onData()` — you wire your own input handler and call the methods below. This gives you full control over which keystrokes are echoed vs forwarded.
#### Input Methods
| Method | Description |
|--------|-------------|
| `addChar(char)` | Add a single printable character to the overlay |
| `appendText(text)` | Append multiple characters (e.g., paste) |
| `removeChar(): 'pending' \| 'flushed' \| false` | Remove last char. Returns source (`'pending'` = unsent, `'flushed'` = send backspace to PTY) or `false` |
| `clear()` | Clear all state and hide overlay |
| `addChar(char)` | Add a single printable character to the overlay. Call for `charCode >= 32, length === 1`. On first keystroke after empty state, auto-detects existing buffer text as flushed. |
| `appendText(text)` | Append multiple characters at once (paste). Same auto-detection as `addChar`. |
| `removeChar()` | Remove last character. See [removeChar Cascade](#removechar-cascade) below. |
| `clear()` | Clear all state (pending + flushed), hide overlay. Call on Enter, Ctrl+C, Escape, or any action that submits/cancels input. Resets buffer detection guard. |
#### removeChar Cascade
`removeChar()` returns `'pending' | 'flushed' | false` indicating the source of the removed character:
```
Step 1: pendingText non-empty → pop last char → return 'pending'
Step 2: flushed text exists → decrement flushed → return 'flushed'
Step 3: both empty → detect buffer → return 'flushed' (if found)
Step 4: nothing found → → return false
```
**How to use the return value:**
| Return | Meaning | Action |
|--------|---------|--------|
| `'pending'` | Removed a character that was never sent to PTY | Do nothing — no backspace needed |
| `'flushed'` | Removed a character that was already sent to PTY | Send `\x7f` (backspace) to PTY |
| `false` | Nothing to remove | Do nothing |
Step 3 handles tab completion and arrow-key edits: if the user tabs to complete a command and immediately hits backspace, the overlay detects the completed text from the terminal buffer and removes from it.
#### Flushed Text Tracking
For scenarios where text has been sent to the PTY but the echo hasn't arrived yet (e.g., tab switching between sessions):
"Flushed" text is text that has been sent to the PTY but whose echo hasn't arrived in the terminal buffer yet. This happens during:
- **Tab switching**: Pending overlay text is flushed to PTY before switching, then restored as flushed on switch-back
- **Tab completion**: Shell fills text on the prompt; overlay syncs via `detectBufferText()`
| Method | Description |
|--------|-------------|
| `setFlushed(count, text)` | Mark characters as sent-but-unacknowledged |
| `getFlushed()` | Get `{ count, text }` of flushed state |
| `clearFlushed()` | Clear flushed state (echo arrived) |
| `setFlushed(count, text)` | Mark characters as sent-but-unacknowledged. Triggers a render. |
| `getFlushed()` | Returns `{ count: number, text: string }`. |
| `clearFlushed()` | Clear flushed state (call when server echo has arrived). |
#### Buffer Detection
The overlay can scan the terminal buffer for text that already exists after the prompt but wasn't typed through the overlay (tab completion, arrow-key edits, shell history).
| Method | Description |
|--------|-------------|
| `detectBufferText()` | Scan buffer for text after prompt. Returns the detected text string, or `null`. If found, sets it as flushed text. Guarded: only runs once per `clear()` cycle. |
| `resetBufferDetection()` | Re-enable detection (e.g., after tab completion response arrives). |
| `suppressBufferDetection()` | Prevent detection until next `clear()`. Use when switching to a session whose buffer has UI framework text (e.g., Ink status bars) after the prompt marker that would be falsely detected. |
#### Rendering
| Method | Description |
|--------|-------------|
| `rerender()` | Force re-render at current prompt position |
| `refreshFont()` | Re-read font properties after size/theme change |
#### Buffer Detection
| Method | Description |
|--------|-------------|
| `detectBufferText()` | Scan buffer for text after prompt; returns detected text or `null` |
| `resetBufferDetection()` | Allow re-detection (auto-reset on `clear()`) |
| `rerender()` | Force re-render at current prompt position. Clears the render cache so the DOM is rebuilt. Call after terminal buffer reloads, full-screen redraws, or SSE reconnects. |
| `refreshFont()` | Re-read font properties (family, size, weight, letter-spacing, colors) from the terminal and re-render. Call after font size changes or theme switches. |
#### Prompt Utilities
| Method | Description |
|--------|-------------|
| `findPrompt()` | Find prompt position using configured strategy |
| `readPromptText()` | Read text after prompt marker |
| `findPrompt()` | Find prompt position using the configured strategy. Returns `{ row, col }` (viewport-relative) or `null`. |
| `readPromptText()` | Read text after the prompt marker on the prompt line. Returns the text or `null`. |
#### State
#### State Properties
| Property | Type | Description |
|----------|------|-------------|
| `pendingText` | `string` | Characters typed but not acknowledged |
| `hasPending` | `boolean` | Whether overlay has any content |
| `state` | `ZerolagInputState` | Full read-only state snapshot |
| `pendingText` | `string` | Characters typed but not acknowledged. Read-only. |
| `hasPending` | `boolean` | `true` if overlay has any content (pending or flushed). |
| `state` | `ZerolagInputState` | Read-only snapshot: `{ pendingText, flushedLength, flushedText, visible, promptPosition }`. Safe to call before `activate()` (returns `visible: false`). |
### Options
@@ -163,24 +195,154 @@ For scenarios where text has been sent to the PTY but the echo hasn't arrived ye
interface ZerolagInputOptions {
prompt?: PromptFinder; // Default: { type: 'character', char: '>', offset: 2 }
zIndex?: number; // Default: 7
backgroundColor?: string; // Default: from terminal theme
foregroundColor?: string; // Default: from terminal theme
backgroundColor?: string; // Default: from terminal theme, then '#0d0d0d'
foregroundColor?: string; // Default: from computed .xterm-rows style, then theme, then '#eeeeee'
showCursor?: boolean; // Default: true
cursorColor?: string; // Default: from terminal theme
cursorColor?: string; // Default: from terminal theme cursor, then '#e0e0e0'
scrollDebounceMs?: number; // Default: 50
}
```
## Integration Patterns
### Buffered Input (hold until Enter)
The quick start example above uses buffered mode — characters accumulate in the overlay and are sent on Enter. This is common for remote shells where you want to batch input.
### Char-at-a-Time (send immediately)
For applications that need each keystroke sent immediately:
```typescript
terminal.onData((data) => {
if (data === '\r') {
zerolag.clear();
ws.send('\r');
} else if (data === '\x7f') {
zerolag.removeChar(); // always 'pending' since nothing is buffered
ws.send(data);
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
zerolag.addChar(data);
ws.send(data);
// The overlay shows the char immediately; the PTY echo will arrive later
// and the overlay continues showing until the next rerender()
}
});
```
### Tab Switching (multi-session)
When your app has multiple terminal sessions in tabs:
```typescript
function switchToSession(newSessionId: string) {
// 1. Save current overlay state
const pending = zerolag.pendingText;
const { count, text } = zerolag.getFlushed();
if (pending) {
sendToPty(currentSessionId, pending); // flush unsent text to PTY
}
const totalCount = count + pending.length;
const totalText = text + pending;
savedFlushed.set(currentSessionId, { count: totalCount, text: totalText });
zerolag.clear();
// 2. Switch terminal buffer to new session
loadSessionBuffer(newSessionId);
// 3. Restore overlay state for new session
const saved = savedFlushed.get(newSessionId);
if (saved) {
zerolag.suppressBufferDetection(); // prevent false detection of UI text
zerolag.setFlushed(saved.count, saved.text);
} else {
zerolag.suppressBufferDetection(); // fresh session, no flushed state
}
// 4. Re-render after buffer loads
terminal.write('', () => {
zerolag.rerender(); // finds prompt, positions overlay correctly
});
}
```
### Tab Completion Detection
After sending a Tab key to the PTY, you can detect whether the shell completed text:
```typescript
// Before Tab: snapshot baseline
const baseline = zerolag.readPromptText();
zerolag.clear();
sendToPty('\t');
// After PTY response arrives:
zerolag.resetBufferDetection();
const detected = zerolag.detectBufferText();
if (detected && detected !== baseline) {
// Tab completion occurred — overlay now shows the completed text
}
```
### Full-Screen TUI Frameworks (Ink, Blessed)
Frameworks like Ink redraw the entire screen on state changes, which can move the prompt. Re-render the overlay after each terminal write:
```typescript
terminal.onWriteParsed(() => {
if (zerolag.hasPending) zerolag.rerender();
});
```
### SSE/WebSocket Reconnect
After a connection drop and reconnect, the terminal buffer reloads. Preserve overlay text across reconnects:
```typescript
function onReconnect() {
// Buffer reloaded — re-render overlay at new prompt position
zerolag.rerender();
}
```
### Terminal Resize
After the terminal is resized (columns/rows change), cell dimensions change:
```typescript
fitAddon.fit();
zerolag.rerender(); // recalculates cell dimensions and prompt position
```
### Font Size / Theme Changes
```typescript
terminal.options.fontSize = 18;
zerolag.refreshFont(); // re-caches font properties, re-renders
```
## How It Works
1. A `<div>` overlay is inserted into xterm.js's `.xterm-screen` element at z-index 7
2. Each character is rendered as an absolutely-positioned `<span>` on the terminal's cell grid
3. Cell dimensions are read from xterm.js's render service (private API on v5, public on v7+)
4. Font properties (family, size, weight, letter-spacing) are cached from the terminal's computed styles
5. The overlay is hidden when the user scrolls away from the bottom of the terminal
6. A render cache (`renderKey`) prevents redundant DOM rebuilds at 60fps
### Architecture
### xterm.js DOM Structure
```
User types 'a' addChar('a') DOM overlay Instant feedback
│ │ │ │
▼ ▼ ▼ ▼
Keyboard ─────► ZerolagAddon ─────► <span> at ─────► User sees 'a'
│ grid pos immediately
│
│ (meanwhile, 200ms later...)
│
▼
PTY echo 'a' ─────► xterm.js canvas ─────► Canvas shows 'a'
(overlay still on top,
cleared on next clear())
```
### DOM Overlay Positioning
The overlay is a `<div>` inserted into xterm.js's `.xterm-screen` element:
```
div.xterm-screen (position: relative)
@@ -188,19 +350,62 @@ div.xterm-screen (position: relative)
├── div.xterm-rows (z-index: auto)
├── div.xterm-selection (z-index: 1)
├── div.xterm-decoration-container (z-index: 6-7)
└── div.zerolag-overlay (z-index: 7) ← our overlay
└── div[zerolag overlay] (z-index: 7) ← our overlay
```
Each character is rendered as an absolutely-positioned `<span>` on the terminal's cell grid:
```
left = charIndex * cellWidth (CSS pixels)
top = lineIndex * cellHeight (CSS pixels)
width = cellWidth (one cell per character)
```
Cell dimensions are read from xterm.js:
- **v5.x**: `terminal._core._renderService.dimensions.css.cell` (private API)
- **v7+**: `terminal.dimensions.css.cell` (public API, auto-detected)
### Font Matching
The overlay matches the terminal's font rendering by:
1. Caching `fontFamily`, `fontSize`, `fontWeight` from `terminal.options`
2. Reading `letterSpacing` from the computed style of `.xterm-rows`
3. Applying `-webkit-font-smoothing: antialiased` (matches canvas grayscale rendering)
4. Disabling ligatures via `font-feature-settings: 'liga' 0, 'calt' 0`
5. Using `text-rendering: geometricPrecision` for consistent glyph sizing
### Render Cache
A render key based on `displayText:startCol:row:col:totalCols:flushedOffset` prevents redundant DOM rebuilds. The cache is cleared by `rerender()`, `refreshFont()`, and internally on state changes that affect the display.
### Scroll Awareness
The overlay hides when the user scrolls away from the bottom of the terminal (where the prompt lives). A scroll listener on `.xterm-viewport` detects `viewportY !== baseY` and hides the overlay. When the user scrolls back to the bottom, a debounced re-render (50ms default) repositions the overlay.
### Prompt Column Locking
When flushed text exists, the prompt column is locked to prevent visual jitter from full-screen redraws that temporarily shift the prompt marker. The row is allowed to change (output scrolls the prompt down), but the column stays fixed until the flushed state is cleared.
### Text Wrapping
Long input that exceeds the terminal width is wrapped at column boundaries:
- Line 0: `(totalCols - startCol)` characters (starts after prompt)
- Line 1+: `totalCols` characters (starts at column 0)
This matches xterm.js's character-level wrapping behavior.
## Compatibility
- **xterm.js v5.x**: Uses private API `terminal._core._renderService.dimensions` for cell sizing
- **xterm.js v7+** (future): Will automatically use public `terminal.dimensions` API
- **xterm.js v5.x**: Uses private API `terminal._core._renderService.dimensions` for cell sizing. Fully supported.
- **xterm.js v7+** (future): Will automatically use public `terminal.dimensions` API when available.
- **Renderers**: Best results with the canvas/WebGL renderer. DOM renderer works but the overlay is redundant since DOM text is already positioned identically.
## Known Limitations
- **Canvas/WebGL renderer**: Minor sub-pixel font differences between DOM overlay and canvas text are possible. Best results with the DOM renderer.
- **Unicode/emoji**: Multi-byte characters (emoji, CJK) are not echoed (they have varying cell widths that are hard to predict client-side).
- **Misprediction**: If the server processes input differently than expected (e.g., password prompts that don't echo), the overlay will show characters that aren't actually there. Call `clear()` when you detect such cases.
- **Canvas/WebGL font mismatch**: Minor sub-pixel differences between DOM overlay text and canvas-rendered text are possible. The per-character absolute positioning minimizes this, but it's not pixel-identical on all platforms.
- **Unicode/emoji**: Multi-byte characters (emoji, CJK) are not reliably echoed — they occupy variable cell widths that can't be predicted client-side. The overlay renders them at single-cell width, causing misalignment.
- **Misprediction**: If the server processes input differently than expected (e.g., password prompts that suppress echo), the overlay shows characters that aren't actually displayed. Call `clear()` when you detect such cases.
- **Prompt character in output**: If the prompt character appears in command output (e.g., `$` in a log message), the overlay may position at the wrong location. Use a more specific regex or custom finder to avoid this.
## License