mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
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:
@@ -0,0 +1,544 @@
|
||||
import type {
|
||||
XtermTerminal,
|
||||
XtermAddon,
|
||||
ZerolagInputOptions,
|
||||
ZerolagInputState,
|
||||
PromptPosition,
|
||||
PromptFinder,
|
||||
FontStyle,
|
||||
} from './types.js';
|
||||
import { getCellDimensions } from './cell-dimensions.js';
|
||||
import { findPrompt, readTextAfterPrompt } from './prompt-finder.js';
|
||||
import { renderOverlay } from './overlay-renderer.js';
|
||||
|
||||
const DEFAULT_PROMPT: PromptFinder = { type: 'character', char: '>', offset: 2 };
|
||||
const DEFAULT_Z_INDEX = 7;
|
||||
const DEFAULT_SCROLL_DEBOUNCE_MS = 50;
|
||||
const DEFAULT_BG = '#0d0d0d';
|
||||
const DEFAULT_FG = '#eeeeee';
|
||||
const DEFAULT_CURSOR = '#e0e0e0';
|
||||
|
||||
/**
|
||||
* xterm.js addon that provides instant keystroke feedback via a DOM overlay.
|
||||
*
|
||||
* Eliminates perceived input latency over high-RTT connections (SSH, remote
|
||||
* terminals, mobile) by rendering typed characters immediately as a DOM
|
||||
* overlay, without waiting for the PTY round-trip.
|
||||
*
|
||||
* The addon does NOT hook `terminal.onData` — the consumer wires their
|
||||
* own input handler and calls `addChar()`, `removeChar()`, `clear()`, etc.
|
||||
*
|
||||
* Compatible with both `xterm` (pre-5.4) and `@xterm/xterm` (5.4+).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Terminal } from '@xterm/xterm';
|
||||
* import { ZerolagInputAddon } from 'xterm-zerolag-input';
|
||||
*
|
||||
* const terminal = new Terminal();
|
||||
* const zerolag = new ZerolagInputAddon({
|
||||
* prompt: { type: 'character', char: '$', offset: 2 },
|
||||
* });
|
||||
* terminal.open(document.getElementById('terminal')!);
|
||||
* terminal.loadAddon(zerolag);
|
||||
*
|
||||
* terminal.onData((data) => {
|
||||
* if (data === '\r') {
|
||||
* const text = zerolag.pendingText;
|
||||
* zerolag.clear();
|
||||
* ws.send(text + '\r');
|
||||
* } else if (data === '\x7f') {
|
||||
* if (zerolag.removeChar()) ws.send(data);
|
||||
* } else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
* zerolag.addChar(data);
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class ZerolagInputAddon implements XtermAddon {
|
||||
private _terminal: XtermTerminal | null = null;
|
||||
private _overlay: HTMLDivElement | null = null;
|
||||
private _options: Required<
|
||||
Pick<ZerolagInputOptions, 'zIndex' | 'showCursor' | 'scrollDebounceMs'>
|
||||
> & ZerolagInputOptions;
|
||||
|
||||
// Text state
|
||||
private _pendingText = '';
|
||||
private _flushedOffset = 0;
|
||||
private _flushedText = '';
|
||||
private _bufferDetectDone = false;
|
||||
|
||||
// Render cache
|
||||
private _lastRenderKey = '';
|
||||
private _lastPromptPos: PromptPosition | null = null;
|
||||
|
||||
// Font cache
|
||||
private _font: FontStyle = {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'normal',
|
||||
color: DEFAULT_FG,
|
||||
backgroundColor: DEFAULT_BG,
|
||||
letterSpacing: '',
|
||||
};
|
||||
|
||||
// Scroll handling
|
||||
private _scrollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private _scrollHandler: (() => void) | null = null;
|
||||
private _scrollViewport: Element | null = null;
|
||||
|
||||
constructor(options?: ZerolagInputOptions) {
|
||||
this._options = {
|
||||
prompt: options?.prompt ?? DEFAULT_PROMPT,
|
||||
zIndex: options?.zIndex ?? DEFAULT_Z_INDEX,
|
||||
showCursor: options?.showCursor ?? true,
|
||||
scrollDebounceMs: options?.scrollDebounceMs ?? DEFAULT_SCROLL_DEBOUNCE_MS,
|
||||
backgroundColor: options?.backgroundColor,
|
||||
foregroundColor: options?.foregroundColor,
|
||||
cursorColor: options?.cursorColor,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Called by `terminal.loadAddon()`. Do not call directly.
|
||||
*/
|
||||
activate(terminal: XtermTerminal): void {
|
||||
this._terminal = terminal;
|
||||
|
||||
// Create overlay container
|
||||
this._overlay = document.createElement('div');
|
||||
this._overlay.style.cssText =
|
||||
`position:absolute;z-index:${this._options.zIndex};pointer-events:none;display:none`;
|
||||
|
||||
// Insert into xterm DOM
|
||||
const screen = terminal.element?.querySelector('.xterm-screen');
|
||||
if (screen) {
|
||||
screen.appendChild(this._overlay);
|
||||
}
|
||||
|
||||
// Cache font properties
|
||||
this._cacheFont();
|
||||
|
||||
// Scroll detection: hide overlay when scrolled away from bottom
|
||||
this._scrollHandler = () => {
|
||||
try {
|
||||
const buf = this._terminal!.buffer.active;
|
||||
if (buf.viewportY !== buf.baseY) {
|
||||
this._overlay!.style.display = 'none';
|
||||
if (this._scrollTimer) {
|
||||
clearTimeout(this._scrollTimer);
|
||||
this._scrollTimer = null;
|
||||
}
|
||||
} else if (this._pendingText || this._flushedOffset > 0) {
|
||||
if (this._scrollTimer) clearTimeout(this._scrollTimer);
|
||||
this._scrollTimer = setTimeout(() => {
|
||||
this._scrollTimer = null;
|
||||
this._lastRenderKey = '';
|
||||
this._render();
|
||||
}, this._options.scrollDebounceMs);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const viewport = terminal.element?.querySelector('.xterm-viewport');
|
||||
if (viewport) {
|
||||
viewport.addEventListener('scroll', this._scrollHandler, { passive: true });
|
||||
this._scrollViewport = viewport;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the overlay, clean up listeners.
|
||||
*/
|
||||
dispose(): void {
|
||||
this.clear();
|
||||
if (this._scrollTimer) {
|
||||
clearTimeout(this._scrollTimer);
|
||||
this._scrollTimer = null;
|
||||
}
|
||||
if (this._scrollViewport && this._scrollHandler) {
|
||||
this._scrollViewport.removeEventListener('scroll', this._scrollHandler);
|
||||
}
|
||||
this._overlay?.remove();
|
||||
this._overlay = null;
|
||||
this._scrollViewport = null;
|
||||
this._scrollHandler = null;
|
||||
this._terminal = null;
|
||||
}
|
||||
|
||||
// ─── Input methods ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add a single printable character to the overlay.
|
||||
* Call this when the user types a character (charCode >= 32, length === 1).
|
||||
*/
|
||||
addChar(char: string): void {
|
||||
if (!this._pendingText && !this._flushedOffset) this._detectBufferText();
|
||||
this._pendingText += char;
|
||||
this._render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Append multiple characters at once (e.g., paste).
|
||||
*/
|
||||
appendText(text: string): void {
|
||||
if (!text) return;
|
||||
if (!this._pendingText && !this._flushedOffset) this._detectBufferText();
|
||||
this._pendingText += text;
|
||||
this._render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the last character from the overlay.
|
||||
*
|
||||
* Cascade order:
|
||||
* 1. Remove from `pendingText` if non-empty
|
||||
* 2. Decrement `flushedOffset` if pending is empty but flushed exists
|
||||
* 3. Try `detectBufferText()` if both are empty
|
||||
*
|
||||
* @returns `true` if a character was removed, `false` if nothing to remove.
|
||||
* When `false`, the consumer should NOT send backspace to the PTY.
|
||||
*/
|
||||
removeChar(): boolean {
|
||||
if (this._pendingText.length > 0) {
|
||||
this._pendingText = this._pendingText.slice(0, -1);
|
||||
if (this._pendingText.length > 0 || this._flushedOffset > 0) {
|
||||
this._render();
|
||||
} else {
|
||||
this._hide();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this._flushedOffset > 0) {
|
||||
this._flushedOffset--;
|
||||
this._flushedText = this._flushedText.slice(0, -1);
|
||||
if (this._flushedOffset > 0) {
|
||||
this._render();
|
||||
} else {
|
||||
this._hide();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all overlay state (pending + flushed). Hides the overlay.
|
||||
* Call on Enter, Ctrl+C, or any action that submits/cancels input.
|
||||
*/
|
||||
clear(): void {
|
||||
this._pendingText = '';
|
||||
this._flushedOffset = 0;
|
||||
this._flushedText = '';
|
||||
this._bufferDetectDone = false;
|
||||
this._lastRenderKey = '';
|
||||
this._lastPromptPos = null;
|
||||
this._hide();
|
||||
}
|
||||
|
||||
// ─── Flushed text tracking ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mark characters as "flushed" — sent to PTY but echo not yet received.
|
||||
*
|
||||
* The overlay renders flushed text (from the stored string) with an opaque
|
||||
* background to cover the terminal's canvas text, preventing a visible
|
||||
* font mismatch between canvas and DOM rendering.
|
||||
*
|
||||
* @param count - Number of characters flushed
|
||||
* @param text - The actual flushed text (avoids reading stale terminal buffer)
|
||||
*/
|
||||
setFlushed(count: number, text: string): void {
|
||||
this._flushedOffset = count;
|
||||
this._flushedText = text;
|
||||
this._render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current flushed state.
|
||||
*/
|
||||
getFlushed(): { count: number; text: string } {
|
||||
return { count: this._flushedOffset, text: this._flushedText };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear flushed state. Call when server echo has arrived and the terminal
|
||||
* buffer now contains the flushed text.
|
||||
*/
|
||||
clearFlushed(): void {
|
||||
this._flushedOffset = 0;
|
||||
this._flushedText = '';
|
||||
if (this._pendingText) {
|
||||
this._render();
|
||||
} else {
|
||||
this._hide();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Rendering control ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Force a re-render of the overlay at the current prompt position.
|
||||
* Call after terminal resets, buffer reloads, or full-screen redraws
|
||||
* that move the prompt.
|
||||
*/
|
||||
rerender(): void {
|
||||
if (this._pendingText || this._flushedOffset > 0) {
|
||||
this._lastRenderKey = '';
|
||||
this._render();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read font properties from the terminal and re-render.
|
||||
* Call after font size changes, theme changes, etc.
|
||||
*/
|
||||
refreshFont(): void {
|
||||
this._cacheFont();
|
||||
this._lastRenderKey = '';
|
||||
if (this._pendingText || this._flushedOffset > 0) this._render();
|
||||
}
|
||||
|
||||
// ─── Buffer detection ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan the terminal buffer for text after the prompt marker.
|
||||
* If found, sets it as flushed text in the overlay.
|
||||
*
|
||||
* Use case: Tab completion filled text on the prompt that the overlay
|
||||
* doesn't know about. Call this to sync overlay state with the buffer.
|
||||
*
|
||||
* @returns The detected text, or `null` if no prompt or no text found.
|
||||
*/
|
||||
detectBufferText(): string | null {
|
||||
return this._detectBufferText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the buffer detection guard. After `clear()`, detection is
|
||||
* automatically re-enabled. Call this manually if you need to force
|
||||
* re-detection (e.g., after a tab completion response arrives).
|
||||
*/
|
||||
resetBufferDetection(): void {
|
||||
this._bufferDetectDone = false;
|
||||
}
|
||||
|
||||
// ─── Prompt utilities ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find the prompt in the terminal buffer using the configured strategy.
|
||||
* @returns The position or `null` if not found.
|
||||
*/
|
||||
findPrompt(): PromptPosition | null {
|
||||
if (!this._terminal) return null;
|
||||
return findPrompt(this._terminal, this._options.prompt ?? DEFAULT_PROMPT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read text after the prompt marker on the prompt line.
|
||||
* Convenience method for consumers that need to snapshot prompt content.
|
||||
*/
|
||||
readPromptText(): string | null {
|
||||
if (!this._terminal) return null;
|
||||
const prompt = this.findPrompt();
|
||||
if (!prompt) return null;
|
||||
const offset = this._getPromptOffset();
|
||||
const text = readTextAfterPrompt(this._terminal, prompt, offset);
|
||||
return text || null;
|
||||
}
|
||||
|
||||
// ─── Public state ─────────────────────────────────────────────────
|
||||
|
||||
/** Current pending (unacknowledged) text. */
|
||||
get pendingText(): string {
|
||||
return this._pendingText;
|
||||
}
|
||||
|
||||
/** Whether there is any overlay content (pending or flushed). */
|
||||
get hasPending(): boolean {
|
||||
return this._pendingText.length > 0 || this._flushedOffset > 0;
|
||||
}
|
||||
|
||||
/** Read-only state snapshot. */
|
||||
get state(): ZerolagInputState {
|
||||
return {
|
||||
pendingText: this._pendingText,
|
||||
flushedLength: this._flushedOffset,
|
||||
flushedText: this._flushedText,
|
||||
visible: this._overlay?.style.display !== 'none',
|
||||
promptPosition: this._lastPromptPos ? { ...this._lastPromptPos } : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Private methods ──────────────────────────────────────────────
|
||||
|
||||
private _getPromptOffset(): number {
|
||||
const prompt = this._options.prompt ?? DEFAULT_PROMPT;
|
||||
return prompt.offset ?? 2;
|
||||
}
|
||||
|
||||
private _detectBufferText(): string | null {
|
||||
if (this._bufferDetectDone) return null;
|
||||
if (!this._terminal) return null;
|
||||
|
||||
try {
|
||||
const prompt = this.findPrompt();
|
||||
if (!prompt) return null;
|
||||
|
||||
const offset = this._getPromptOffset();
|
||||
const afterPrompt = readTextAfterPrompt(this._terminal, prompt, offset);
|
||||
|
||||
if (afterPrompt.length > 0) {
|
||||
this._flushedOffset = afterPrompt.length;
|
||||
this._flushedText = afterPrompt;
|
||||
this._lastPromptPos = prompt;
|
||||
this._bufferDetectDone = true;
|
||||
return afterPrompt;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private _cacheFont(): void {
|
||||
if (!this._terminal) return;
|
||||
|
||||
const t = this._terminal;
|
||||
this._font.fontFamily = t.options.fontFamily || 'monospace';
|
||||
this._font.fontSize = (t.options.fontSize || 14) + 'px';
|
||||
this._font.fontWeight = String(t.options.fontWeight || 'normal');
|
||||
this._font.backgroundColor =
|
||||
this._options.backgroundColor ??
|
||||
t.options.theme?.background ??
|
||||
DEFAULT_BG;
|
||||
this._font.color =
|
||||
this._options.foregroundColor ??
|
||||
t.options.theme?.foreground ??
|
||||
DEFAULT_FG;
|
||||
this._font.letterSpacing = '';
|
||||
|
||||
// Prefer computed styles from rendered rows (matches actual rendering)
|
||||
const rows = t.element?.querySelector('.xterm-rows');
|
||||
if (rows) {
|
||||
const cs = getComputedStyle(rows);
|
||||
this._font.letterSpacing = cs.letterSpacing;
|
||||
if (!this._options.foregroundColor && cs.color) {
|
||||
this._font.color = cs.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _hide(): void {
|
||||
if (!this._overlay) return;
|
||||
this._lastRenderKey = '';
|
||||
this._lastPromptPos = null;
|
||||
this._overlay.innerHTML = '';
|
||||
this._overlay.style.display = 'none';
|
||||
}
|
||||
|
||||
private _render(): void {
|
||||
if (!this._terminal || !this._overlay) return;
|
||||
if (!this._pendingText && !(this._flushedOffset > 0)) {
|
||||
this._overlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const buf = this._terminal.buffer.active;
|
||||
|
||||
// Hide overlay when scrolled up — prompt is at bottom, not in viewport
|
||||
if (buf.viewportY !== buf.baseY) {
|
||||
this._overlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-scan for prompt on every render (full-screen redraws can move it)
|
||||
const prompt = this.findPrompt();
|
||||
if (prompt) {
|
||||
// When flushed text exists, lock column to prevent jitter from
|
||||
// redraws that temporarily shift the prompt marker. Allow row changes.
|
||||
if (this._lastPromptPos && this._flushedOffset > 0) {
|
||||
this._lastPromptPos = { row: prompt.row, col: this._lastPromptPos.col };
|
||||
} else {
|
||||
this._lastPromptPos = prompt;
|
||||
}
|
||||
} else if (!this._lastPromptPos) {
|
||||
this._overlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const activePrompt = this._lastPromptPos!;
|
||||
|
||||
const dims = getCellDimensions(this._terminal);
|
||||
if (!dims) {
|
||||
this._overlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const { width: cellW, height: cellH } = dims;
|
||||
const totalCols = this._terminal.cols;
|
||||
const offset = this._getPromptOffset();
|
||||
const startCol = activePrompt.col + offset;
|
||||
|
||||
// Build display text: flushed chars + pending chars
|
||||
let displayText = this._pendingText;
|
||||
if (this._flushedOffset > 0) {
|
||||
if (this._flushedText && this._flushedText.length === this._flushedOffset) {
|
||||
displayText = this._flushedText + this._pendingText;
|
||||
} else {
|
||||
// Fallback: read flushed chars from terminal buffer
|
||||
const absRow = buf.viewportY + activePrompt.row;
|
||||
const line = buf.getLine(absRow);
|
||||
if (line) {
|
||||
const lineText = line.translateToString(true);
|
||||
const flushedChars = lineText.slice(startCol, startCol + this._flushedOffset);
|
||||
displayText = flushedChars + this._pendingText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip redundant re-renders
|
||||
const renderKey = `${displayText.length}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._flushedOffset}`;
|
||||
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
|
||||
this._lastRenderKey = renderKey;
|
||||
|
||||
// Split text into visual lines matching terminal character-wrap
|
||||
const firstLineCols = Math.max(1, totalCols - startCol);
|
||||
const lines: string[] = [];
|
||||
let remaining = displayText;
|
||||
lines.push(remaining.slice(0, firstLineCols));
|
||||
remaining = remaining.slice(firstLineCols);
|
||||
while (remaining.length > 0) {
|
||||
lines.push(remaining.slice(0, totalCols));
|
||||
remaining = remaining.slice(totalCols);
|
||||
}
|
||||
|
||||
const cursorColor =
|
||||
this._options.cursorColor ??
|
||||
this._terminal.options.theme?.cursor ??
|
||||
DEFAULT_CURSOR;
|
||||
|
||||
renderOverlay(this._overlay, {
|
||||
lines,
|
||||
startCol,
|
||||
totalCols,
|
||||
cellW,
|
||||
cellH,
|
||||
promptRow: activePrompt.row,
|
||||
font: this._font,
|
||||
showCursor: this._options.showCursor,
|
||||
cursorColor,
|
||||
});
|
||||
} catch {
|
||||
// Hide on render error but preserve pendingText —
|
||||
// next rerender() will retry when terminal is ready.
|
||||
if (this._overlay) {
|
||||
this._overlay.innerHTML = '';
|
||||
this._overlay.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user