Instant keystroke feedback overlay for [xterm.js](https://xtermjs.org/) — eliminates perceived input latency over high-RTT connections.
## The Problem
When using xterm.js over a remote connection (SSH web clients, cloud IDEs, mobile terminals), every keystroke takes a full round-trip to the server before appearing on screen. At 100-500ms RTT, typing feels sluggish and unresponsive.
## The Solution
`xterm-zerolag-input` renders typed characters **immediately** as a pixel-perfect DOM overlay positioned on the terminal's character grid. The overlay covers the terminal canvas at the prompt location, showing characters instantly while the server echo travels back. Once the server responds, the overlay is cleared and the real terminal output takes over.
**No changes to your backend needed.** The addon is purely client-side.
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:
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.
| `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'
| `'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.
| `setFlushed(count, text, render?)` | Mark characters as sent-but-unacknowledged. Pass `render=false` when restoring during a tab switch before the new buffer has loaded (prevents stale prompt column locking). Default: `true`. |
| `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. |
| `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. |
| `undoDetection()` | Undo the last `detectBufferText()` — clears flushed state and re-enables detection. Use when tab completion detection found text matching the pre-tab baseline (no real completion happened) and needs to retry. |
| `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. |
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');
}elseif(data==='\x7f'){
zerolag.removeChar();// always 'pending' since nothing is buffered
ws.send(data);
}elseif(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
functionswitchToSession(newSessionId: string){
// 1. Save current overlay state
constpending=zerolag.pendingText;
const{count,text}=zerolag.getFlushed();
if(pending){
sendToPty(currentSessionId,pending);// flush unsent text to PTY
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.
- **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.
- **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.