Rewrite the xterm-zerolag-input README (hero demo GIF, value-first
structure) and fix its drift against the source: 175 tests not 78,
CJK/emoji wide-char support documented instead of listed as a
limitation, setPrompt() documented.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Rewrite the `xterm-zerolag-input` package README as a value-first document and correct the drift that had accumulated against the source.
- Added the side-by-side phone demo GIF (`docs/images/zerolag-demo-20260728.gif`) as the hero image, referenced by absolute raw URL so it renders on npmjs.com as well as GitHub. The two-phone comparison shows 0ms local echo next to a 600ms-2.7s server echo on the same session.
- New "Why this one" comparison table, an explicit list of target use cases (SSH web clients, cloud IDEs, mobile terminals, container consoles), and a bundle-size badge (6.1 kB gzipped, measured from the ESM build).
- Corrected the test-count badge from 78 to the actual 175 tests across 5 files, in both the package README and the Published Packages section of the root README.
- Removed the stale "Unicode/emoji rendered at single-cell width" limitation. CJK, fullwidth forms and emoji have had double-width rendering and visual-column positioning since the wide-character fix; the honest remaining caveat (per-code-point width summing over-counts ZWJ grapheme clusters) replaces it.
- Documented the previously undocumented public `setPrompt()` method for switching prompt strategies at runtime, and the new "Wide characters (CJK, emoji)" integration section covering the optional `Unicode11Addon` path and the built-in range-table fallback.
- Documented `backgroundColor: 'transparent'`, corrected the `foregroundColor` default, and updated the grid-alignment math to reflect visual-column positioning rather than character index.
Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, configurable prompt detection, full state machine with 78 tests.
Instant keystroke feedback overlay for xterm.js. Eliminates perceived input latency over high-RTT connections by rendering typed characters immediately as a pixel-perfect DOM overlay. Zero dependencies, 6.1 kB gzipped, configurable prompt detection, CJK/emoji wide-character support, full state machine with 175 tests.
- Rewrite the `xterm-zerolag-input` package README as a value-first document and correct the drift that had accumulated against the source.
- Added the side-by-side phone demo GIF (`docs/images/zerolag-demo-20260728.gif`) as the hero image, referenced by absolute raw URL so it renders on npmjs.com as well as GitHub. The two-phone comparison shows 0ms local echo next to a 600ms-2.7s server echo on the same session.
- New "Why this one" comparison table, an explicit list of target use cases (SSH web clients, cloud IDEs, mobile terminals, container consoles), and a bundle-size badge (6.1 kB gzipped, measured from the ESM build).
- Corrected the test-count badge from 78 to the actual 175 tests across 5 files, in both the package README and the Published Packages section of the root README.
- Removed the stale "Unicode/emoji rendered at single-cell width" limitation. CJK, fullwidth forms and emoji have had double-width rendering and visual-column positioning since the wide-character fix; the honest remaining caveat (per-code-point width summing over-counts ZWJ grapheme clusters) replaces it.
- Documented the previously undocumented public `setPrompt()` method for switching prompt strategies at runtime, and the new "Wide characters (CJK, emoji)" integration section covering the optional `Unicode11Addon` path and the built-in range-table fallback.
- Documented `backgroundColor: 'transparent'`, corrected the `foregroundColor` default, and updated the grid-alignment math to reflect visual-column positioning rather than character index.
<img src="https://img.shields.io/badge/xterm.js-v5%20%7C%20v7+-3b82f6?style=flat-square" alt="xterm.js v5 and v7+">
</p>
</p>
<p align="center">
<img src="https://raw.githubusercontent.com/Ark0N/Codeman/master/docs/images/zerolag-demo-20260728.gif" alt="Side-by-side phones typing into the same remote session: with zerolag the text appears at 0ms, without it every keystroke waits 600ms to 2.7s for the server echo" width="900">
</p>
<p align="center">
<em>Two phones, the same remote session, the same slow link.<br>
Left: the zerolag overlay paints every keystroke at <strong>0ms</strong>. Right: stock xterm.js waits <strong>600ms to 2.7s</strong> for the server to echo it back.</em>
</p>
---
## The Problem
## The 30-second version
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. Users type blind, make mistakes they can't see, and the experience feels broken.
Over a remote connection, xterm.js shows you a character only after it has flown to the server and back. At 100-500ms RTT that reads as broken: you type ahead of the screen, you cannot see your typos, and you start pecking one key at a time to stay in sync.
## 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 seamlessly disappears and the real terminal text takes over.
`xterm-zerolag-input` paints your keystrokes **immediately**, as an absolutely-positioned DOM overlay locked to the terminal's character grid. When the server echo lands 300ms later, the overlay clears and the real terminal text takes over. The handoff is invisible.
```
Keystroke Flow:
┌─── DOM overlay (instant, 0ms)
User types 'h' ─── onData('h') ───┤
└─── Your app sends to PTY ──→ Server
@@ -32,14 +39,24 @@ User types 'h' ─── onData('h') ───┤
Server echoes 'h' ←──────────────────────────────────────────────────┘
**No changes to your backend needed.** The addon is purely client-side.
**No backend changes. No protocol. No server support.** It is a client-side addon that never touches the wire.
## Origin
## Why this one
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.
| | |
|---|---|
| **Survives full-screen TUIs** | Ink, blessed, and friends repaint the whole screen constantly. The overlay is a separate DOM layer they cannot reach, so it does not get clobbered. |
| **Pixel-matched to the canvas** | Each character is its own absolutely-positioned `<span>` at exact cell coordinates, so it does not drift out of the grid like normal DOM text flow. |
| **Wide characters included** | CJK, fullwidth forms and emoji render double-width and position by visual column, using the terminal's Unicode addon when one is loaded. |
| **Backspace that actually works** | A three-layer cascade (unsent, in-flight, already on screen) tells you exactly what to forward to the PTY, so editing works through any mix of typed, flushed and tab-completed text. |
| **You keep control of input** | The addon never hooks `onData` for you. You decide what gets echoed and what gets forwarded, which is what makes char-at-a-time, buffered, and multi-session tab switching all possible. |
| **Small and self-contained** | 6.1 kB gzipped, zero runtime dependencies, dual CJS/ESM with full type declarations. |
| **Proven under load** | Extracted from a production app, hardened over thousands of hours of real remote and mobile usage, 175 tests over every state transition. |
Built for anything that puts a terminal behind a network hop: SSH web clients, cloud IDEs, mobile terminals, Kubernetes and container consoles, remote agent dashboards, browser-based dev environments.
## Install
@@ -47,12 +64,9 @@ This library was extracted from [Codeman](https://github.com/Ark0N/Codeman), mis
npm install xterm-zerolag-input
```
- **Zero runtime dependencies**
- Compatible with both `xterm` (pre-5.4) and `@xterm/xterm` (5.4+)
- Dual CJS/ESM build with full TypeScript declarations
- Works with canvas, WebGL, and DOM renderers
Works with both `xterm` (pre-5.4) and `@xterm/xterm` (5.4+), and with the canvas, WebGL and DOM renderers.
## Quick Start
## Quick start
```typescript
import{Terminal}from'@xterm/xterm';
@@ -61,7 +75,7 @@ import { ZerolagInputAddon } from 'xterm-zerolag-input';
That is the whole integration. Everything below is for tuning it.
Most terminal UIs can't do local echo because:
## Why this is hard
1.**Buffer writes corrupt**: Frameworks like [Ink](https://github.com/vadimdemedes/ink) (React for terminals) redraw the entire screen on every state change. Writing directly to the terminal buffer gets immediately overwritten.
Most terminal UIs cannot do local echo, for three reasons:
2.**Cursor position lies**: In Ink, `buffer.cursorY` reflects internal state (near the status bar), not the visible prompt. You can't trust it.
1.**Buffer writes get corrupted.** Frameworks like [Ink](https://github.com/vadimdemedes/ink) (React for terminals) redraw the entire screen on every state change. Anything written straight into the terminal buffer is overwritten immediately.
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.
2.**Cursor position lies.** In Ink, `buffer.cursorY` reflects internal render state (often near a status bar), not the visible prompt. You cannot trust it.
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 `<span>`** at exact cell-grid coordinates
3.**Fonts do not line up.** Canvas and WebGL renderers do their own text shaping. A DOM overlay has to pixel-match that grid, and normal DOM text flow drifts as sub-pixel glyph widths accumulate.
This library answers all three:
- a **DOM overlay** on its own z-index layer, which Ink cannot touch
- **bottom-up buffer scanning** for the prompt instead of trusting the cursor
- **one absolutely-positioned `<span>` per character** at exact cell-grid coordinates
---
## Prompt Detection
## Prompt detection
The addon needs to know where user input starts. It scans the terminal buffer bottom-up for the prompt. Three strategies:
The addon needs to know where user input starts. It scans the terminal buffer bottom-up. Three strategies:
### Character (default)
@@ -118,17 +135,17 @@ The addon needs to know where user input starts. It scans the terminal buffer bo
{type:'character',char:'%',offset: 2}
// Fish / Starship: ❯
{type:'character',char:'\u276f',offset: 2}
{type:'character',char:'❯',offset: 2}
// Simple arrow: >
{type:'character',char:'>',offset: 2}
```
`offset` = characters between the prompt marker and where user input begins (e.g., `"$ "` = 2).
`offset` = characters between the prompt marker and where user input begins (`"$ "` = 2).
### Regex
For complex prompts. The `g` flag is safely stripped to prevent`lastIndex` mutation.
For complex prompts. The `g` flag is stripped safely, so there is no`lastIndex` mutation.
```typescript
{type:'regex',pattern:/\$\s*$/,offset: 2}
@@ -150,77 +167,88 @@ Full control:
}
```
### Switching prompts at runtime
If one terminal hosts several CLIs with different prompts, swap the strategy in place:
`setPrompt()` clears the cached prompt position and re-renders if anything is pending, so a mode switch cannot leave the overlay pinned to the old column.
---
## API Reference
## API reference
### `ZerolagInputAddon`
Implements xterm.js `ITerminalAddon`. The addon does **not** hook `terminal.onData()` — you wire your own input handler and call these methods. This gives you full control over which keystrokes are echoed vs forwarded.
Implements the xterm.js `ITerminalAddon` interface. It deliberately does **not** hook `terminal.onData()`: you wire your own handler and call these methods, which is what gives you control over which keystrokes are echoed and which are forwarded.
### Input
| Method | Returns | Description |
|--------|---------|-------------|
| `addChar(char)` | `void` | Add a single printable character. Auto-detects existing buffer text on first keystroke. |
| `addChar(char)` | `void` | Add a single printable character. Auto-detects existing buffer text on the first keystroke. |
| `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. |
| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove the last character. See [backspace handling](#backspace-handling). |
| `clear()` | `void` | Clear all state and hide the overlay. Call on Enter, Ctrl+C, Escape. |
### Backspace Handling
### 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 |
| `'pending'` | Unsent text (never transmitted to the PTY) | Do nothing |
| `'flushed'` | Text already sent to the PTY | Send `\x7f`to the 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.
The cascade order is pending text, then flushed text, then auto-detected buffer text (which is what makes backspace work after tab completion). Backspace "just works" across any combination of typed, in-flight and completed text.
### Flushed Text
### Flushed text
"Flushed" = sent to PTY but echo hasn't arrived yet. Happens during tab switches and tab completion.
"Flushed" means sent to the PTY but the echo has not arrived yet. This happens during tab switches and tab completion.
| Method | Description |
|--------|-------------|
| `setFlushed(count, text, render?)` | Mark text as flushed. Pass `render=false` during tab-switch restore (buffer not loaded yet). |
| `setFlushed(count, text, render?)` | Mark text as flushed. Pass `render=false` during tab-switch restore, when the buffer is not loaded yet. |
| `getFlushed()` | Returns `{ count, text }`. |
| `clearFlushed()` | Clear flushed state when server echo arrives. |
| `clearFlushed()` | Clear flushed state once the server echo arrives. |
### Buffer Detection
### Buffer detection
Scan the terminal for text that exists after the prompt but wasn't typed through the overlay.
Finds text that exists after the prompt but was never typed through the overlay.
| Method | Description |
|--------|-------------|
| `detectBufferText()` | Scan and return detected text (or `null`). Sets it as flushed. Guarded: runs once per `clear()` cycle. |
| `detectBufferText()` | Scan and return the detected text (or `null`), marking it flushed. Guarded: runs once per `clear()` cycle. |
The quick start example above. Characters accumulate in the overlay and are sent on Enter. Best for remote shells where you want to batch input.
The quick start above. Characters accumulate in the overlay and go out on Enter. Best for remote shells where you want to batch input.
### Char-at-a-Time (send immediately)
### Char-at-a-time (send immediately)
```typescript
terminal.onData((data)=>{
@@ -256,12 +284,14 @@ terminal.onData((data) => {
ws.send(data);
}elseif(data.length===1&&data.charCodeAt(0)>=32){
zerolag.addChar(data);
ws.send(data);// send immediately — overlay shows while echo travels back
ws.send(data);// overlay shows the char while the echo travels back
}
});
```
### Tab Switching (multi-session)
This is the mode that keeps shell features intact: tab completion, `Ctrl+R` history search, and readline bindings all still work, because every byte still reaches the PTY.
### Tab switching (multi-session)
```typescript
functionswitchToSession(newId: string){
@@ -280,19 +310,19 @@ function switchToSession(newId: string) {
Wide characters work out of the box: the overlay measures each character's cell width, renders double-width spans for wide ones, and positions later characters by visual column instead of character index. Line wrapping is computed in columns too, so a wrapped Japanese or Chinese line lands on the same cells the server will use.
For exact Unicode 11+ widths, load xterm's Unicode addon and the overlay will defer to it:
When flushed text exists, the prompt column is locked to prevent jitter from full-screen redraws. Row changes are allowed (output can scroll the prompt).
While flushed text exists the prompt column is locked, so a full-screen redraw cannot make the overlay jitter sideways. Row changes are still allowed, because output legitimately scrolls the prompt.
### Scroll Awareness
### Scroll awareness
Overlay hides when scrolled up (`viewportY !== baseY`). Debounced re-render when scrolling back to bottom.
The overlay hides when the viewport is scrolled up (`viewportY !== baseY`) and re-renders, debounced, when you scroll back to the bottom.
---
## Known Limitations
## Known limitations
- **Canvas/WebGL font mismatch**: Minor sub-pixel differences possible. Per-character absolute positioning minimizes this.
- **Password prompts**: Overlay shows characters that aren't echoed. Call `clear()` when you detect no-echo mode.
- **Prompt in output**: If `$` appears in command output, prompt detection may find the wrong position. Use regex or custom finder.
- **Canvas and WebGL font mismatch**: minor sub-pixel differences are still possible. Per-character absolute positioning keeps them small.
- **Grapheme clusters**: widths are summed per code point, so ZWJ emoji sequences (for example 👨👩👧) and combining marks can be over-counted. Single-code-point emoji and CJK are correct.
- **Password prompts**: the overlay will happily show characters the server is not echoing. Call `clear()` when you detect a no-echo prompt.
- **Prompt characters in output**: if your prompt marker also appears in command output, detection can latch onto the wrong line. Use a regex or a custom finder.
---
## Origin
Extracted from [**Codeman**](https://github.com/Ark0N/Codeman), mission control for AI coding agents: multi-session management, live agent visualization, autonomous respawn loops, and a mobile-first web UI for Claude Code, OpenCode, Codex and Gemini.
The local echo system was built to make phone and remote access feel instant, ran in production for thousands of hours, then survived three deep code audits before being pulled out into this standalone library. The demo above is a real Codeman session on two phones.
## License
MIT — [Codeman](https://github.com/Ark0N/Codeman) Contributors
"description":"Instant keystroke feedback overlay for xterm.js — eliminates perceived input latency over high-RTT connections",
"type":"module",
"main":"dist/index.cjs",
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.