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,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;
}
+11
View File
@@ -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 '';
}
}
+163
View File
@@ -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';
}
}
}
}