mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 16:09:43 +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,36 @@
|
||||
import type { XtermTerminal, CellDimensions } from './types.js';
|
||||
|
||||
/**
|
||||
* Get cell dimensions from the terminal, handling xterm.js v5 (private API)
|
||||
* and v7+ (public API).
|
||||
*
|
||||
* Returns `null` if the terminal is not yet rendered or dimensions are
|
||||
* unavailable.
|
||||
*/
|
||||
export function getCellDimensions(terminal: XtermTerminal): CellDimensions | null {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const t = terminal as any;
|
||||
|
||||
// Try v7+ public API first
|
||||
if (t.dimensions?.css?.cell) {
|
||||
return {
|
||||
width: t.dimensions.css.cell.width,
|
||||
height: t.dimensions.css.cell.height,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to v5 private API
|
||||
try {
|
||||
const dims = t._core?._renderService?.dimensions;
|
||||
if (dims?.css?.cell) {
|
||||
return {
|
||||
width: dims.css.cell.width,
|
||||
height: dims.css.cell.height,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Private API may throw in some environments
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { ZerolagInputAddon } from './zerolag-input-addon.js';
|
||||
export type {
|
||||
XtermTerminal,
|
||||
XtermAddon,
|
||||
ZerolagInputOptions,
|
||||
ZerolagInputState,
|
||||
PromptFinder,
|
||||
PromptPosition,
|
||||
CellDimensions,
|
||||
FontStyle,
|
||||
} from './types.js';
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { RenderParams, FontStyle } from './types.js';
|
||||
|
||||
/**
|
||||
* Render the overlay content into the container element.
|
||||
*
|
||||
* Creates per-character `<span>` elements positioned on an exact grid
|
||||
* matching xterm.js's canvas renderer. This avoids sub-pixel drift that
|
||||
* occurs with normal DOM text flow.
|
||||
*/
|
||||
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
|
||||
const { lines, startCol, totalCols, cellW, cellH, promptRow, font, showCursor, cursorColor } = params;
|
||||
|
||||
// Position container at prompt row
|
||||
container.style.left = '0px';
|
||||
container.style.top = (promptRow * cellH) + 'px';
|
||||
|
||||
// Clear and rebuild (typically 1-3 line divs, negligible cost)
|
||||
container.innerHTML = '';
|
||||
const fullWidthPx = totalCols * cellW;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const leftPx = i === 0 ? startCol * cellW : 0;
|
||||
const widthPx = i === 0 ? (fullWidthPx - leftPx) : fullWidthPx;
|
||||
const topPx = i * cellH;
|
||||
const lineEl = makeLine(lines[i], leftPx, topPx, widthPx, cellH, cellW, font);
|
||||
container.appendChild(lineEl);
|
||||
}
|
||||
|
||||
// Block cursor at end of last line
|
||||
if (showCursor) {
|
||||
const lastLine = lines[lines.length - 1];
|
||||
const lastLineLeft = lines.length === 1 ? startCol : 0;
|
||||
const cursorCol = lastLineLeft + lastLine.length;
|
||||
if (cursorCol < totalCols) {
|
||||
const cursor = document.createElement('span');
|
||||
cursor.style.cssText = 'position:absolute;display:inline-block';
|
||||
cursor.style.left = (cursorCol * cellW) + 'px';
|
||||
cursor.style.top = ((lines.length - 1) * cellH) + 'px';
|
||||
cursor.style.width = cellW + 'px';
|
||||
cursor.style.height = cellH + 'px';
|
||||
cursor.style.backgroundColor = cursorColor;
|
||||
container.appendChild(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
container.style.display = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a styled line `<div>` with per-character grid positioning.
|
||||
*
|
||||
* Each character gets its own `<span>` placed at `i * cellW` pixels.
|
||||
* This matches xterm's canvas renderer where each glyph occupies exactly
|
||||
* one cell width, regardless of the actual glyph metrics.
|
||||
*/
|
||||
function makeLine(
|
||||
text: string,
|
||||
leftPx: number,
|
||||
topPx: number,
|
||||
widthPx: number,
|
||||
cellH: number,
|
||||
cellW: number,
|
||||
font: FontStyle,
|
||||
): HTMLDivElement {
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'position:absolute;pointer-events:none';
|
||||
el.style.backgroundColor = font.backgroundColor;
|
||||
el.style.left = leftPx + 'px';
|
||||
el.style.top = topPx + 'px';
|
||||
el.style.width = widthPx + 'px';
|
||||
el.style.height = (cellH + 1) + 'px';
|
||||
el.style.lineHeight = cellH + 'px';
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const span = document.createElement('span');
|
||||
// Match xterm.js canvas text rendering:
|
||||
// - antialiased smoothing (canvas uses grayscale, not LCD subpixel)
|
||||
// - geometricPrecision for consistent glyph sizing
|
||||
// - no ligatures (canvas renders each glyph independently)
|
||||
span.style.cssText =
|
||||
'position:absolute;display:inline-block;text-align:center;pointer-events:none;' +
|
||||
'-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;' +
|
||||
"text-rendering:geometricPrecision;font-feature-settings:'liga' 0,'calt' 0";
|
||||
span.style.left = (i * cellW) + 'px';
|
||||
span.style.width = cellW + 'px';
|
||||
span.style.fontFamily = font.fontFamily;
|
||||
span.style.fontSize = font.fontSize;
|
||||
span.style.fontWeight = font.fontWeight;
|
||||
span.style.color = font.color;
|
||||
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
|
||||
span.textContent = text[i];
|
||||
el.appendChild(span);
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { XtermTerminal, PromptFinder, PromptPosition } from './types.js';
|
||||
|
||||
/**
|
||||
* Find the prompt in the terminal buffer using the configured strategy.
|
||||
* Scans bottom-up through the viewport to find the most recent prompt.
|
||||
*
|
||||
* @returns The prompt position (viewport-relative), or `null` if not found.
|
||||
*/
|
||||
export function findPrompt(
|
||||
terminal: XtermTerminal,
|
||||
finder: PromptFinder,
|
||||
): PromptPosition | null {
|
||||
try {
|
||||
const buffer = terminal.buffer.active;
|
||||
const viewportTop = buffer.viewportY;
|
||||
|
||||
switch (finder.type) {
|
||||
case 'character': {
|
||||
for (let row = terminal.rows - 1; row >= 0; row--) {
|
||||
const line = buffer.getLine(viewportTop + row);
|
||||
if (!line) continue;
|
||||
const text = line.translateToString(true);
|
||||
const idx = text.lastIndexOf(finder.char);
|
||||
if (idx >= 0) return { row, col: idx };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'regex': {
|
||||
for (let row = terminal.rows - 1; row >= 0; row--) {
|
||||
const line = buffer.getLine(viewportTop + row);
|
||||
if (!line) continue;
|
||||
const text = line.translateToString(true);
|
||||
const match = text.match(finder.pattern);
|
||||
if (match) {
|
||||
const col = match.index ?? 0;
|
||||
return { row, col };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'custom':
|
||||
return finder.find(terminal);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read text after the prompt position on the same line.
|
||||
*
|
||||
* @param terminal - The xterm.js terminal instance
|
||||
* @param prompt - The prompt position in the viewport
|
||||
* @param offset - Characters to skip after the prompt marker (e.g., 2 for "> ")
|
||||
* @returns The text after the prompt, trimmed. Empty string if nothing found.
|
||||
*/
|
||||
export function readTextAfterPrompt(
|
||||
terminal: XtermTerminal,
|
||||
prompt: PromptPosition,
|
||||
offset: number,
|
||||
): string {
|
||||
try {
|
||||
const buffer = terminal.buffer.active;
|
||||
const absRow = buffer.viewportY + prompt.row;
|
||||
const line = buffer.getLine(absRow);
|
||||
if (!line) return '';
|
||||
const lineText = line.translateToString(true);
|
||||
return lineText.slice(prompt.col + offset).trimEnd();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Minimal terminal interface required by the addon.
|
||||
*
|
||||
* Compatible with both `xterm` (pre-5.4) and `@xterm/xterm` (5.4+).
|
||||
* Consumers pass their real Terminal instance — we only use these properties.
|
||||
*/
|
||||
export interface XtermTerminal {
|
||||
readonly element: HTMLElement | undefined;
|
||||
readonly cols: number;
|
||||
readonly rows: number;
|
||||
readonly options: {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
fontWeight?: string | number;
|
||||
theme?: {
|
||||
background?: string;
|
||||
foreground?: string;
|
||||
cursor?: string;
|
||||
};
|
||||
};
|
||||
readonly buffer: {
|
||||
readonly active: {
|
||||
readonly viewportY: number;
|
||||
readonly baseY: number;
|
||||
getLine(y: number): {
|
||||
translateToString(trimRight?: boolean): string;
|
||||
} | undefined;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal addon interface matching xterm.js ITerminalAddon.
|
||||
*
|
||||
* The consumer calls `terminal.loadAddon(addon)` which invokes `activate()`.
|
||||
*/
|
||||
export interface XtermAddon {
|
||||
activate(terminal: XtermTerminal): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Position of the prompt in the terminal viewport.
|
||||
*/
|
||||
export interface PromptPosition {
|
||||
/** Viewport-relative row (0 = top of viewport) */
|
||||
row: number;
|
||||
/** Column of the prompt marker character */
|
||||
col: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt detection strategy.
|
||||
*
|
||||
* The overlay needs to know where user input starts on the terminal line.
|
||||
* Three strategies are supported:
|
||||
*
|
||||
* - `character`: Scan bottom-up for a specific character (e.g., `$`, `>`, `❯`)
|
||||
* - `regex`: Scan each line with a regex pattern
|
||||
* - `custom`: Full escape hatch — provide your own finder function
|
||||
*/
|
||||
export type PromptFinder =
|
||||
| { type: 'character'; char: string; offset?: number }
|
||||
| { type: 'regex'; pattern: RegExp; offset?: number }
|
||||
| { type: 'custom'; find: (terminal: XtermTerminal) => PromptPosition | null; offset?: number };
|
||||
|
||||
/**
|
||||
* Configuration options for ZerolagInputAddon.
|
||||
*/
|
||||
export interface ZerolagInputOptions {
|
||||
/**
|
||||
* How to find the prompt in the terminal buffer.
|
||||
*
|
||||
* The `offset` controls how many characters after the prompt marker
|
||||
* the user input begins (e.g., `"> "` = offset 2).
|
||||
*
|
||||
* @default { type: 'character', char: '>', offset: 2 }
|
||||
*/
|
||||
prompt?: PromptFinder;
|
||||
|
||||
/**
|
||||
* Z-index for the overlay element.
|
||||
* @default 7
|
||||
*/
|
||||
zIndex?: number;
|
||||
|
||||
/**
|
||||
* Background color for the overlay.
|
||||
* Set to `'transparent'` to disable the opaque background.
|
||||
* @default Read from terminal.options.theme.background
|
||||
*/
|
||||
backgroundColor?: string;
|
||||
|
||||
/**
|
||||
* Foreground color for overlay text.
|
||||
* @default Read from terminal.options.theme.foreground
|
||||
*/
|
||||
foregroundColor?: string;
|
||||
|
||||
/**
|
||||
* Whether to show a block cursor at the end of the overlay text.
|
||||
* @default true
|
||||
*/
|
||||
showCursor?: boolean;
|
||||
|
||||
/**
|
||||
* Cursor color (block cursor at end of text).
|
||||
* @default Read from terminal.options.theme.cursor
|
||||
*/
|
||||
cursorColor?: string;
|
||||
|
||||
/**
|
||||
* Scroll debounce time in ms for re-rendering when user scrolls
|
||||
* back to the bottom of the terminal.
|
||||
* @default 50
|
||||
*/
|
||||
scrollDebounceMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only state snapshot of the overlay.
|
||||
*/
|
||||
export interface ZerolagInputState {
|
||||
/** Characters typed but not yet acknowledged by the server */
|
||||
pendingText: string;
|
||||
/** Number of characters flushed to PTY but echo not yet received */
|
||||
flushedLength: number;
|
||||
/** Text content of the flushed portion */
|
||||
flushedText: string;
|
||||
/** Whether the overlay is currently visible */
|
||||
visible: boolean;
|
||||
/** Last detected prompt position, if any */
|
||||
promptPosition: PromptPosition | null;
|
||||
}
|
||||
|
||||
/** Cell dimensions in CSS pixels. */
|
||||
export interface CellDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Parameters for the overlay renderer. */
|
||||
export interface RenderParams {
|
||||
lines: string[];
|
||||
startCol: number;
|
||||
totalCols: number;
|
||||
cellW: number;
|
||||
cellH: number;
|
||||
promptRow: number;
|
||||
font: FontStyle;
|
||||
showCursor: boolean;
|
||||
cursorColor: string;
|
||||
}
|
||||
|
||||
/** Cached font style properties for overlay rendering. */
|
||||
export interface FontStyle {
|
||||
fontFamily: string;
|
||||
fontSize: string;
|
||||
fontWeight: string;
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
letterSpacing: string;
|
||||
}
|
||||
@@ -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