feat: add xterm-zerolag-input standalone library

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>
This commit is contained in:
arkon
2026-02-22 10:14:37 +01:00
co-authored by Claude Opus 4.6
parent 4aa38c1bbb
commit d14ac9de65
16 changed files with 2178 additions and 0 deletions
@@ -0,0 +1,121 @@
/**
* Mock terminal factory for unit tests.
*
* Creates a minimal Terminal-like object that satisfies the addon's
* requirements without needing a real xterm.js instance or DOM renderer.
*/
interface MockLine {
translateToString(_trimRight?: boolean): string;
}
interface MockBufferOptions {
lines: string[];
viewportY?: number;
baseY?: number;
cursorX?: number;
cursorY?: number;
}
interface MockTerminalOptions {
buffer?: MockBufferOptions;
cols?: number;
rows?: number;
fontFamily?: string;
fontSize?: number;
fontWeight?: string | number;
theme?: {
background?: string;
foreground?: string;
cursor?: string;
};
cellWidth?: number;
cellHeight?: number;
}
export function createMockTerminal(opts: MockTerminalOptions = {}) {
const bufOpts = opts.buffer ?? { lines: ['$ '] };
const lines = bufOpts.lines;
const viewportY = bufOpts.viewportY ?? 0;
const baseY = bufOpts.baseY ?? viewportY;
const cols = opts.cols ?? 80;
const rows = opts.rows ?? Math.max(lines.length, 24);
const cellW = opts.cellWidth ?? 8.4;
const cellH = opts.cellHeight ?? 17;
const mockLines: MockLine[] = lines.map((text) => ({
translateToString: () => text,
}));
// Create minimal DOM structure
const element = document.createElement('div');
element.className = 'terminal xterm';
const viewport = document.createElement('div');
viewport.className = 'xterm-viewport';
const screen = document.createElement('div');
screen.className = 'xterm-screen';
screen.style.position = 'relative';
const xtermRows = document.createElement('div');
xtermRows.className = 'xterm-rows';
element.appendChild(viewport);
element.appendChild(screen);
screen.appendChild(xtermRows);
// Append to document so getComputedStyle works
document.body.appendChild(element);
const terminal = {
element,
cols,
rows,
options: {
fontFamily: opts.fontFamily ?? 'monospace',
fontSize: opts.fontSize ?? 14,
fontWeight: opts.fontWeight ?? 'normal',
theme: opts.theme ?? {},
},
buffer: {
active: {
viewportY,
baseY,
cursorX: bufOpts.cursorX ?? 0,
cursorY: bufOpts.cursorY ?? 0,
getLine: (absRow: number): MockLine | undefined => {
return mockLines[absRow - viewportY];
},
},
},
_core: {
_renderService: {
dimensions: {
css: {
cell: { width: cellW, height: cellH },
},
},
},
},
// Simulate loadAddon
loadAddon(addon: { activate: (t: unknown) => void }) {
addon.activate(this);
},
};
return {
terminal,
/** Update buffer lines for subsequent calls */
setLines(newLines: string[]) {
mockLines.length = 0;
for (const text of newLines) {
mockLines.push({ translateToString: () => text });
}
},
/** Clean up DOM */
cleanup() {
element.remove();
},
};
}
@@ -0,0 +1,169 @@
import { describe, it, expect } from 'vitest';
import { renderOverlay } from '../src/overlay-renderer.js';
import type { RenderParams, FontStyle } from '../src/types.js';
const FONT: FontStyle = {
fontFamily: 'monospace',
fontSize: '14px',
fontWeight: 'normal',
color: '#eeeeee',
backgroundColor: '#0d0d0d',
letterSpacing: '',
};
function makeParams(overrides: Partial<RenderParams> = {}): RenderParams {
return {
lines: ['hello'],
startCol: 2,
totalCols: 80,
cellW: 8.4,
cellH: 17,
promptRow: 10,
font: FONT,
showCursor: true,
cursorColor: '#e0e0e0',
...overrides,
};
}
describe('renderOverlay', () => {
it('positions container at prompt row', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ promptRow: 5 }));
expect(container.style.top).toBe((5 * 17) + 'px');
expect(container.style.left).toBe('0px');
});
it('creates per-character spans in a line div', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['abc'] }));
// Line div + cursor span
expect(container.children.length).toBe(2);
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(3); // a, b, c
const spanA = lineDiv.children[0] as HTMLSpanElement;
expect(spanA.textContent).toBe('a');
expect(spanA.style.left).toBe('0px');
const spanB = lineDiv.children[1] as HTMLSpanElement;
expect(spanB.textContent).toBe('b');
expect(spanB.style.left).toBe('8.4px');
const spanC = lineDiv.children[2] as HTMLSpanElement;
expect(spanC.textContent).toBe('c');
expect(spanC.style.left).toBe('16.8px');
});
it('sets span width to cellW', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['x'], cellW: 9.5 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.width).toBe('9.5px');
});
it('applies font styles to spans', () => {
const font: FontStyle = {
fontFamily: 'Fira Code',
fontSize: '16px',
fontWeight: 'bold',
color: '#ff0000',
backgroundColor: '#000000',
letterSpacing: '0.5px',
};
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['A'], font }));
const lineDiv = container.children[0] as HTMLDivElement;
// jsdom normalizes hex to rgb()
expect(lineDiv.style.backgroundColor).toBe('rgb(0, 0, 0)');
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.fontFamily).toBe('Fira Code');
expect(span.style.fontSize).toBe('16px');
expect(span.style.fontWeight).toBe('bold');
expect(span.style.color).toBe('rgb(255, 0, 0)');
expect(span.style.letterSpacing).toBe('0.5px');
});
it('offsets first line by startCol', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['hi'], startCol: 5, cellW: 10 }));
const lineDiv = container.children[0] as HTMLDivElement;
// First line left = startCol * cellW
expect(lineDiv.style.left).toBe('50px');
});
it('renders cursor at end of text', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({
lines: ['ab'],
startCol: 3,
cellW: 10,
cellH: 20,
showCursor: true,
cursorColor: '#ff00ff',
}));
// Last child is cursor (after line div)
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
// cursorCol = startCol(3) + text.length(2) = 5
expect(cursor.style.left).toBe('50px');
expect(cursor.style.width).toBe('10px');
expect(cursor.style.height).toBe('20px');
// jsdom normalizes hex to rgb()
expect(cursor.style.backgroundColor).toBe('rgb(255, 0, 255)');
});
it('does not render cursor when showCursor is false', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['ab'], showCursor: false }));
// Only line div, no cursor
expect(container.children.length).toBe(1);
});
it('renders multi-line text', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({
lines: ['first', 'second'],
startCol: 5,
cellW: 10,
cellH: 20,
}));
// 2 line divs + cursor
expect(container.children.length).toBe(3);
const line1 = container.children[0] as HTMLDivElement;
expect(line1.style.left).toBe('50px'); // startCol * cellW
expect(line1.style.top).toBe('0px');
expect(line1.children.length).toBe(5); // 'first'
const line2 = container.children[1] as HTMLDivElement;
expect(line2.style.left).toBe('0px'); // wrapped lines start at col 0
expect(line2.style.top).toBe('20px'); // second row
expect(line2.children.length).toBe(6); // 'second'
});
it('clears previous content on re-render', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['abc'] }));
expect(container.children.length).toBe(2); // line + cursor
renderOverlay(container, makeParams({ lines: ['xy'] }));
expect(container.children.length).toBe(2); // line + cursor (rebuilt)
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(2); // x, y
});
it('shows container (display not none)', () => {
const container = document.createElement('div');
container.style.display = 'none';
renderOverlay(container, makeParams());
expect(container.style.display).toBe('');
});
});
@@ -0,0 +1,150 @@
import { describe, it, expect } from 'vitest';
import { createMockTerminal } from './helpers.js';
import { findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js';
import type { XtermTerminal, PromptFinder } from '../src/types.js';
function term(lines: string[]) {
return createMockTerminal({ buffer: { lines } });
}
describe('findPrompt', () => {
describe('character strategy', () => {
it('finds $ prompt at column 0', () => {
const { terminal, cleanup } = term(['output line', '$ ls -la']);
const finder: PromptFinder = { type: 'character', char: '$' };
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
expect(pos).toEqual({ row: 1, col: 0 });
cleanup();
});
it('finds > prompt', () => {
const { terminal, cleanup } = term(['> hello']);
const finder: PromptFinder = { type: 'character', char: '>' };
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
expect(pos).toEqual({ row: 0, col: 0 });
cleanup();
});
it('finds prompt with prefix (user@host)', () => {
const { terminal, cleanup } = term(['user@host:~$ command']);
const finder: PromptFinder = { type: 'character', char: '$' };
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
expect(pos).toEqual({ row: 0, col: 11 });
cleanup();
});
it('scans bottom-up and returns lowest match', () => {
const { terminal, cleanup } = term([
'$ old prompt',
'output',
'$ current prompt',
]);
const finder: PromptFinder = { type: 'character', char: '$' };
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
expect(pos).toEqual({ row: 2, col: 0 });
cleanup();
});
it('returns null when no prompt found', () => {
const { terminal, cleanup } = term(['no prompt here', 'or here']);
const finder: PromptFinder = { type: 'character', char: '$' };
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
expect(pos).toBeNull();
cleanup();
});
it('finds Unicode prompt character', () => {
const { terminal, cleanup } = term(['\u276f hello']);
const finder: PromptFinder = { type: 'character', char: '\u276f' };
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
expect(pos).toEqual({ row: 0, col: 0 });
cleanup();
});
});
describe('regex strategy', () => {
it('finds regex prompt', () => {
const { terminal, cleanup } = term(['user@host:~/dir$ ls']);
const finder: PromptFinder = { type: 'regex', pattern: /\$/ };
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
expect(pos).not.toBeNull();
expect(pos!.col).toBe(15);
cleanup();
});
it('matches complex PS1 patterns', () => {
const { terminal, cleanup } = term(['(venv) user % cmd']);
const finder: PromptFinder = { type: 'regex', pattern: /%/ };
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
expect(pos).not.toBeNull();
expect(pos!.col).toBe(12);
cleanup();
});
it('returns null on no match', () => {
const { terminal, cleanup } = term(['just output']);
const finder: PromptFinder = { type: 'regex', pattern: /\$\s*$/ };
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
expect(pos).toBeNull();
cleanup();
});
});
describe('custom strategy', () => {
it('uses custom finder function', () => {
const { terminal, cleanup } = term(['anything']);
const finder: PromptFinder = {
type: 'custom',
find: () => ({ row: 5, col: 10 }),
};
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
expect(pos).toEqual({ row: 5, col: 10 });
cleanup();
});
it('handles null from custom finder', () => {
const { terminal, cleanup } = term(['anything']);
const finder: PromptFinder = {
type: 'custom',
find: () => null,
};
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
expect(pos).toBeNull();
cleanup();
});
});
});
describe('readTextAfterPrompt', () => {
it('reads text after prompt with offset', () => {
const { terminal, cleanup } = term(['$ hello world']);
const prompt = { row: 0, col: 0 };
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
expect(text).toBe('hello world');
cleanup();
});
it('returns empty string for empty prompt line', () => {
const { terminal, cleanup } = term(['$ ']);
const prompt = { row: 0, col: 0 };
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
expect(text).toBe('');
cleanup();
});
it('trims trailing whitespace', () => {
const { terminal, cleanup } = term(['$ hello ']);
const prompt = { row: 0, col: 0 };
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
expect(text).toBe('hello');
cleanup();
});
it('handles offset for complex prompts', () => {
const { terminal, cleanup } = term(['user@host:~$ ls -la']);
const prompt = { row: 0, col: 11 };
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
expect(text).toBe('ls -la');
cleanup();
});
});
@@ -0,0 +1,297 @@
import { describe, it, expect, afterEach } from 'vitest';
import { createMockTerminal } from './helpers.js';
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
import type { Terminal } from '../src/types.js';
function setup(lines: string[] = ['$ '], promptChar = '$') {
const mock = createMockTerminal({ buffer: { lines } });
const addon = new ZerolagInputAddon({
prompt: { type: 'character', char: promptChar, offset: 2 },
});
mock.terminal.loadAddon(addon);
return { addon, mock };
}
let cleanups: (() => void)[] = [];
afterEach(() => {
for (const fn of cleanups) fn();
cleanups = [];
});
function tracked(lines?: string[], promptChar?: string) {
const result = setup(lines, promptChar);
cleanups.push(() => {
result.addon.dispose();
result.mock.cleanup();
});
return result;
}
describe('ZerolagInputAddon', () => {
describe('lifecycle', () => {
it('creates overlay element in .xterm-screen', () => {
const { addon, mock } = tracked();
const screen = mock.terminal.element.querySelector('.xterm-screen');
expect(screen!.children.length).toBeGreaterThan(0);
const overlay = screen!.lastElementChild as HTMLDivElement;
expect(overlay.style.zIndex).toBe('7');
expect(overlay.style.display).toBe('none');
addon.dispose();
});
it('dispose removes overlay from DOM', () => {
const { addon, mock } = tracked();
const screen = mock.terminal.element.querySelector('.xterm-screen')!;
const before = screen.children.length;
addon.dispose();
expect(screen.children.length).toBe(before - 1);
});
});
describe('addChar / pendingText', () => {
it('adds characters to pendingText', () => {
const { addon } = tracked();
addon.addChar('a');
addon.addChar('b');
addon.addChar('c');
expect(addon.pendingText).toBe('abc');
});
it('hasPending is true when text exists', () => {
const { addon } = tracked();
expect(addon.hasPending).toBe(false);
addon.addChar('x');
expect(addon.hasPending).toBe(true);
});
});
describe('appendText', () => {
it('appends multiple characters (paste)', () => {
const { addon } = tracked();
addon.addChar('h');
addon.appendText('ello');
expect(addon.pendingText).toBe('hello');
});
it('ignores empty string', () => {
const { addon } = tracked();
addon.appendText('');
expect(addon.pendingText).toBe('');
expect(addon.hasPending).toBe(false);
});
});
describe('removeChar', () => {
it('removes last character from pendingText', () => {
const { addon } = tracked();
addon.addChar('a');
addon.addChar('b');
const removed = addon.removeChar();
expect(removed).toBe(true);
expect(addon.pendingText).toBe('a');
});
it('returns false when nothing to remove', () => {
const { addon } = tracked();
expect(addon.removeChar()).toBe(false);
});
it('decrements flushed when pending is empty', () => {
const { addon } = tracked();
addon.setFlushed(3, 'abc');
expect(addon.removeChar()).toBe(true);
expect(addon.getFlushed().count).toBe(2);
expect(addon.getFlushed().text).toBe('ab');
});
it('removes pending before flushed', () => {
const { addon } = tracked();
addon.setFlushed(2, 'ab');
addon.addChar('c');
expect(addon.removeChar()).toBe(true);
expect(addon.pendingText).toBe('');
expect(addon.getFlushed().count).toBe(2); // flushed unchanged
});
it('hides overlay when both pending and flushed become empty', () => {
const { addon } = tracked();
addon.addChar('x');
addon.removeChar();
expect(addon.hasPending).toBe(false);
});
});
describe('clear', () => {
it('resets all state', () => {
const { addon } = tracked();
addon.setFlushed(3, 'abc');
addon.addChar('d');
addon.clear();
expect(addon.pendingText).toBe('');
expect(addon.getFlushed().count).toBe(0);
expect(addon.getFlushed().text).toBe('');
expect(addon.hasPending).toBe(false);
});
});
describe('flushed text', () => {
it('setFlushed stores count and text', () => {
const { addon } = tracked();
addon.setFlushed(5, 'hello');
expect(addon.getFlushed()).toEqual({ count: 5, text: 'hello' });
expect(addon.hasPending).toBe(true);
});
it('clearFlushed resets flushed state', () => {
const { addon } = tracked();
addon.setFlushed(3, 'abc');
addon.clearFlushed();
expect(addon.getFlushed()).toEqual({ count: 0, text: '' });
});
it('clearFlushed preserves pending text', () => {
const { addon } = tracked();
addon.setFlushed(3, 'abc');
addon.addChar('d');
addon.clearFlushed();
expect(addon.pendingText).toBe('d');
expect(addon.hasPending).toBe(true);
});
});
describe('state snapshot', () => {
it('returns current state', () => {
const { addon } = tracked();
addon.setFlushed(2, 'hi');
addon.addChar('!');
const state = addon.state;
expect(state.pendingText).toBe('!');
expect(state.flushedLength).toBe(2);
expect(state.flushedText).toBe('hi');
});
it('state is read-only copy', () => {
const { addon } = tracked();
addon.addChar('a');
const s1 = addon.state;
addon.addChar('b');
const s2 = addon.state;
expect(s1.pendingText).toBe('a');
expect(s2.pendingText).toBe('ab');
});
});
describe('prompt detection', () => {
it('findPrompt returns position for character prompt', () => {
const { addon } = tracked(['$ hello world']);
const pos = addon.findPrompt();
expect(pos).toEqual({ row: 0, col: 0 });
});
it('findPrompt returns null when no prompt', () => {
const { addon } = tracked(['no prompt here']);
const pos = addon.findPrompt();
expect(pos).toBeNull();
});
it('readPromptText reads text after prompt', () => {
const { addon } = tracked(['$ hello world']);
const text = addon.readPromptText();
expect(text).toBe('hello world');
});
it('readPromptText returns null when no prompt', () => {
const { addon } = tracked(['no prompt']);
const text = addon.readPromptText();
expect(text).toBeNull();
});
});
describe('buffer detection', () => {
it('detectBufferText picks up existing text after prompt', () => {
const { addon } = tracked(['$ existing text']);
const text = addon.detectBufferText();
expect(text).toBe('existing text');
expect(addon.getFlushed().count).toBe(13);
expect(addon.getFlushed().text).toBe('existing text');
});
it('detectBufferText returns null for empty prompt', () => {
const { addon } = tracked(['$ ']);
const text = addon.detectBufferText();
expect(text).toBeNull();
});
it('detectBufferText is guarded (only runs once)', () => {
const { addon } = tracked(['$ text']);
addon.detectBufferText();
addon.clearFlushed(); // clear what was detected
// Should not detect again (guard is set)
const text = addon.detectBufferText();
expect(text).toBeNull();
});
it('resetBufferDetection allows re-detection', () => {
const { addon } = tracked(['$ text']);
addon.detectBufferText();
addon.clearFlushed();
addon.resetBufferDetection();
const text = addon.detectBufferText();
expect(text).toBe('text');
});
it('clear resets buffer detection guard', () => {
const { addon } = tracked(['$ text']);
addon.detectBufferText();
addon.clear();
// After clear, detection should work again
const text = addon.detectBufferText();
expect(text).toBe('text');
});
});
describe('custom prompt configurations', () => {
it('works with > prompt character', () => {
const { addon } = tracked(['> hello'], '>');
const text = addon.readPromptText();
expect(text).toBe('hello');
});
it('works with Unicode prompt', () => {
const mock = createMockTerminal({ buffer: { lines: ['\u276f hello'] } });
const addon = new ZerolagInputAddon({
prompt: { type: 'character', char: '\u276f', offset: 2 },
});
mock.terminal.loadAddon(addon);
cleanups.push(() => { addon.dispose(); mock.cleanup(); });
const text = addon.readPromptText();
expect(text).toBe('hello');
});
});
describe('rerender / refreshFont', () => {
it('rerender does not crash when no text', () => {
const { addon } = tracked();
expect(() => addon.rerender()).not.toThrow();
});
it('refreshFont does not crash', () => {
const { addon } = tracked();
expect(() => addon.refreshFont()).not.toThrow();
});
it('rerender re-renders when hasPending', () => {
const { addon } = tracked();
addon.addChar('x');
expect(() => addon.rerender()).not.toThrow();
expect(addon.hasPending).toBe(true);
});
});
});