diff --git a/packages/xterm-zerolag-input/src/overlay-renderer.ts b/packages/xterm-zerolag-input/src/overlay-renderer.ts index 6dffc852..72c62201 100644 --- a/packages/xterm-zerolag-input/src/overlay-renderer.ts +++ b/packages/xterm-zerolag-input/src/overlay-renderer.ts @@ -1,4 +1,50 @@ -import type { RenderParams, FontStyle } from './types.js'; +import type { RenderParams, FontStyle, XtermTerminal } from './types.js'; + +// ─── CJK / fullwidth character width detection ─────────────────────── + +/** + * Get visual cell width of a single character. + * CJK wide characters occupy 2 cells, others occupy 1. + * Prefers the terminal's Unicode addon when available. + */ +export function charCellWidth(terminal: XtermTerminal | null | undefined, ch: string): number { + if (terminal?.unicode?.getStringCellWidth) { + return terminal.unicode.getStringCellWidth(ch); + } + // Fallback: detect CJK wide characters by Unicode range + const code = ch.codePointAt(0); + if ( + code !== undefined && + code >= 0x1100 && + (code <= 0x115f || // Hangul Jamo + (code >= 0x2e80 && code <= 0x303e) || // CJK Radicals, Kangxi, Ideographic + (code >= 0x3040 && code <= 0x33bf) || // Hiragana, Katakana, Bopomofo, CJK Compat + (code >= 0x3400 && code <= 0x4dbf) || // CJK Unified Ext A + (code >= 0x4e00 && code <= 0xa4cf) || // CJK Unified, Yi + (code >= 0xa960 && code <= 0xa97c) || // Hangul Jamo Extended-A + (code >= 0xac00 && code <= 0xd7a3) || // Hangul Syllables + (code >= 0xf900 && code <= 0xfaff) || // CJK Compat Ideographs + (code >= 0xfe30 && code <= 0xfe6f) || // CJK Compat Forms + (code >= 0xff01 && code <= 0xff60) || // Fullwidth Forms + (code >= 0xffe0 && code <= 0xffe6) || // Fullwidth Signs + (code >= 0x1f000 && code <= 0x1fbff) || // Mahjong, Domino, Emoji + (code >= 0x20000 && code <= 0x2ffff) || // CJK Unified Ext B-F + (code >= 0x30000 && code <= 0x3ffff)) // CJK Unified Ext G+ + ) + return 2; + return 1; +} + +/** + * Get visual cell width of a string (sum of all character widths). + */ +export function stringCellWidth(terminal: XtermTerminal | null | undefined, str: string): number { + let w = 0; + for (const ch of str) w += charCellWidth(terminal, ch); + return w; +} + +// ─── Overlay rendering ──────────────────────────────────────────────── /** * Render the overlay content into the container element. @@ -6,100 +52,114 @@ import type { RenderParams, FontStyle } from './types.js'; * Creates per-character `` elements positioned on an exact grid * matching xterm.js's canvas renderer. This avoids sub-pixel drift that * occurs with normal DOM text flow. + * + * CJK wide characters are rendered with double-width spans. */ export function renderOverlay(container: HTMLDivElement, params: RenderParams): void { - const { lines, startCol, totalCols, cellW, cellH, charTop, charHeight, promptRow, font, showCursor, cursorColor } = params; + const { + lines, + startCol, + totalCols, + cellW, + cellH, + charTop, + charHeight, + promptRow, + font, + showCursor, + cursorColor, + terminal, + } = params; - // Position container at prompt row. - container.style.left = '0px'; - container.style.top = (promptRow * cellH) + 'px'; + // 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; + // 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, charTop, charHeight, font); - container.appendChild(lineEl); + 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, charTop, charHeight, font, terminal); + container.appendChild(lineEl); + } + + // Block cursor at end of last line (use visual width for CJK support) + if (showCursor) { + const lastLine = lines[lines.length - 1]; + const lastLineLeft = lines.length === 1 ? startCol : 0; + const cursorCol = lastLineLeft + stringCellWidth(terminal, lastLine); + 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); } + } - // 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 = ''; + container.style.display = ''; } /** * Create a styled line `
` with per-character grid positioning. * - * Each character gets its own `` 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. + * Each character gets its own `` positioned by visual column offset. + * CJK wide characters occupy 2 cell widths. */ function makeLine( - text: string, - leftPx: number, - topPx: number, - widthPx: number, - cellH: number, - cellW: number, - charTop: number, - charHeight: number, - font: FontStyle, + text: string, + leftPx: number, + topPx: number, + widthPx: number, + cellH: number, + cellW: number, + _charTop: number, + _charHeight: number, + font: FontStyle, + terminal?: XtermTerminal | null ): 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'; - // Extend background 1px past cell boundary to cover the compositing - // seam between the overlay layer (z-index:7) and the canvas layer below. - // The extra 1px lands in the next row's charTop gap (empty area before - // text rendering starts), so no canvas content is obscured. - el.style.height = (cellH + 1) + 'px'; + 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'; + // Extend background 1px past cell boundary to cover the compositing + // seam between the overlay layer (z-index:7) and the canvas layer below. + // The extra 1px lands in the next row's charTop gap (empty area before + // text rendering starts), so no canvas content is obscured. + el.style.height = cellH + 1 + 'px'; - // Spans fill the full cell height with matching lineHeight for natural - // CSS vertical centering. No transform — any sub-pixel overhang past - // the line div causes visible anti-aliasing artifacts at the boundary. - // The ≤0.5px difference from canvas ceil() rounding is imperceptible. + // CJK wide chars occupy 2 cells — position by visual column offset + let colOffset = 0; + for (const ch of text) { + const cw = charCellWidth(terminal, ch); + const span = document.createElement('span'); + // No ligatures — canvas renders each glyph independently. + span.style.cssText = + 'position:absolute;display:inline-block;text-align:center;pointer-events:none;' + + "font-feature-settings:'liga' 0,'calt' 0"; + span.style.left = colOffset * cellW + 'px'; + span.style.top = '0px'; + span.style.width = cw * cellW + 'px'; + span.style.height = cellH + 'px'; + span.style.lineHeight = cellH + '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 = ch; + el.appendChild(span); + colOffset += cw; + } - for (let i = 0; i < text.length; i++) { - const span = document.createElement('span'); - // No ligatures — canvas renders each glyph independently. - span.style.cssText = - 'position:absolute;display:inline-block;text-align:center;pointer-events:none;' + - "font-feature-settings:'liga' 0,'calt' 0"; - span.style.left = (i * cellW) + 'px'; - span.style.top = '0px'; - span.style.width = cellW + 'px'; - span.style.height = cellH + 'px'; - span.style.lineHeight = cellH + '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; + return el; } diff --git a/packages/xterm-zerolag-input/src/types.ts b/packages/xterm-zerolag-input/src/types.ts index c023a6a9..08dd0050 100644 --- a/packages/xterm-zerolag-input/src/types.ts +++ b/packages/xterm-zerolag-input/src/types.ts @@ -5,28 +5,35 @@ * 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 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; - }; + }; + readonly buffer: { + readonly active: { + readonly viewportY: number; + readonly baseY: number; + getLine(y: number): + | { + translateToString(trimRight?: boolean): string; + } + | undefined; }; + }; + /** Unicode addon (e.g. Unicode11Addon) for CJK wide character width */ + readonly unicode?: { + getStringCellWidth(str: string): number; + activeVersion?: string; + }; } /** @@ -35,18 +42,18 @@ export interface XtermTerminal { * The consumer calls `terminal.loadAddon(addon)` which invokes `activate()`. */ export interface XtermAddon { - activate(terminal: XtermTerminal): void; - dispose(): void; + 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; + /** Viewport-relative row (0 = top of viewport) */ + row: number; + /** Column of the prompt marker character */ + col: number; } /** @@ -60,112 +67,114 @@ export interface PromptPosition { * - `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 }; + | { 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; + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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; + /** 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; - /** Vertical offset (px) from cell top to where characters render. */ - charTop: number; - /** Height of the character rendering area (px). */ - charHeight: number; + width: number; + height: number; + /** Vertical offset (px) from cell top to where characters render. */ + charTop: number; + /** Height of the character rendering area (px). */ + charHeight: number; } /** Parameters for the overlay renderer. */ export interface RenderParams { - lines: string[]; - startCol: number; - totalCols: number; - cellW: number; - cellH: number; - /** Vertical offset (px) from cell top to character rendering area. */ - charTop: number; - /** Height of the character rendering area (px). */ - charHeight: number; - promptRow: number; - font: FontStyle; - showCursor: boolean; - cursorColor: string; + lines: string[]; + startCol: number; + totalCols: number; + cellW: number; + cellH: number; + /** Vertical offset (px) from cell top to character rendering area. */ + charTop: number; + /** Height of the character rendering area (px). */ + charHeight: number; + promptRow: number; + font: FontStyle; + showCursor: boolean; + cursorColor: string; + /** Terminal instance for CJK wide character width detection */ + terminal?: XtermTerminal | null; } /** Cached font style properties for overlay rendering. */ export interface FontStyle { - fontFamily: string; - fontSize: string; - fontWeight: string; - color: string; - backgroundColor: string; - letterSpacing: string; + fontFamily: string; + fontSize: string; + fontWeight: string; + color: string; + backgroundColor: string; + letterSpacing: string; } diff --git a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts index 66eae400..5e201ba4 100644 --- a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts +++ b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts @@ -1,15 +1,15 @@ import type { - XtermTerminal, - XtermAddon, - ZerolagInputOptions, - ZerolagInputState, - PromptPosition, - PromptFinder, - FontStyle, + 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'; +import { renderOverlay, charCellWidth } from './overlay-renderer.js'; const DEFAULT_PROMPT: PromptFinder = { type: 'character', char: '>', offset: 2 }; const DEFAULT_Z_INDEX = 7; @@ -57,558 +57,573 @@ const DEFAULT_CURSOR = '#e0e0e0'; * ``` */ export class ZerolagInputAddon implements XtermAddon { - private _terminal: XtermTerminal | null = null; - private _overlay: HTMLDivElement | null = null; - private _options: Required< - Pick - > & ZerolagInputOptions; + private _terminal: XtermTerminal | null = null; + private _overlay: HTMLDivElement | null = null; + private _options: Required> & + ZerolagInputOptions; - // Text state - private _pendingText = ''; - private _flushedOffset = 0; - private _flushedText = ''; - private _bufferDetectDone = false; + // Text state + private _pendingText = ''; + private _flushedOffset = 0; + private _flushedText = ''; + private _bufferDetectDone = false; - // Render cache - private _lastRenderKey = ''; - private _lastPromptPos: PromptPosition | null = null; + // 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: '', + // Font cache + private _font: FontStyle = { + fontFamily: 'monospace', + fontSize: '14px', + fontWeight: 'normal', + color: DEFAULT_FG, + backgroundColor: DEFAULT_BG, + letterSpacing: '', + }; + + // Scroll handling + private _scrollTimer: ReturnType | 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, }; + } - // Scroll handling - private _scrollTimer: ReturnType | null = null; - private _scrollHandler: (() => void) | null = null; - private _scrollViewport: Element | null = null; + // ─── Lifecycle ──────────────────────────────────────────────────── - 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, - }; + /** + * 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); } - // ─── Lifecycle ──────────────────────────────────────────────────── + // Cache font properties + this._cacheFont(); - /** - * 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) { + // 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; - } - 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 → returns `'pending'` - * 2. Decrement `flushedOffset` if pending is empty but flushed exists → returns `'flushed'` - * 3. Try `detectBufferText()` if both are empty, then decrement → returns `'flushed'` - * - * @returns The source of the removed character, or `false` if nothing to remove. - * - * - `'pending'`: A character was removed from unsent text. The consumer - * should NOT send backspace to the PTY (the text was never transmitted). - * - `'flushed'`: A character was removed from text already sent to the PTY. - * The consumer SHOULD send backspace to the PTY. - * - `false`: Nothing to remove. The consumer should NOT send backspace. - */ - removeChar(): 'pending' | 'flushed' | false { - 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 'pending'; - } - - if (this._flushedOffset > 0) { - this._flushedOffset--; - this._flushedText = this._flushedText.slice(0, -1); - if (this._flushedOffset > 0) { - this._render(); - } else { - this._hide(); - } - return 'flushed'; - } - - // Both empty — try detecting text already on the prompt line - // (handles tab completion, arrow-key edits, etc.) - this._detectBufferText(); - if (this._flushedOffset > 0) { - this._flushedOffset--; - this._flushedText = this._flushedText.slice(0, -1); - if (this._flushedOffset > 0) { - this._render(); - } else { - this._hide(); - } - return 'flushed'; - } - - 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) - * @param render - Whether to re-render immediately (default: `true`). - * Pass `false` when restoring flushed state during a tab/session switch - * before the new buffer has loaded — rendering against a stale buffer - * would lock the prompt column to the wrong position. Call `rerender()` - * explicitly after the buffer finishes loading. - */ - setFlushed(count: number, text: string, render = true): void { - this._flushedOffset = count; - this._flushedText = text; - if (render) 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) { + } + } 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 → returns `'pending'` + * 2. Decrement `flushedOffset` if pending is empty but flushed exists → returns `'flushed'` + * 3. Try `detectBufferText()` if both are empty, then decrement → returns `'flushed'` + * + * @returns The source of the removed character, or `false` if nothing to remove. + * + * - `'pending'`: A character was removed from unsent text. The consumer + * should NOT send backspace to the PTY (the text was never transmitted). + * - `'flushed'`: A character was removed from text already sent to the PTY. + * The consumer SHOULD send backspace to the PTY. + * - `false`: Nothing to remove. The consumer should NOT send backspace. + */ + removeChar(): 'pending' | 'flushed' | false { + 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 'pending'; } - /** - * 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(); + if (this._flushedOffset > 0) { + this._flushedOffset--; + this._flushedText = this._flushedText.slice(0, -1); + if (this._flushedOffset > 0) { + this._render(); + } else { + this._hide(); + } + return 'flushed'; } - // ─── 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(); + // Both empty — try detecting text already on the prompt line + // (handles tab completion, arrow-key edits, etc.) + this._detectBufferText(); + if (this._flushedOffset > 0) { + this._flushedOffset--; + this._flushedText = this._flushedText.slice(0, -1); + if (this._flushedOffset > 0) { + this._render(); + } else { + this._hide(); + } + return 'flushed'; } - /** - * 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; - } + return false; + } - /** - * Undo the last `detectBufferText()` call — clears flushed state and - * re-enables detection. - * - * Use case: Tab completion detection found text that matches the - * pre-tab baseline (no real completion happened). Call this to undo - * the detection so it can retry on the next flush cycle. - */ - undoDetection(): void { - this._flushedOffset = 0; - this._flushedText = ''; - this._bufferDetectDone = 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(); + } - /** - * Suppress buffer detection until the next `clear()` or - * `resetBufferDetection()` call. - * - * Use case: When switching to a session whose buffer contains UI - * framework text (e.g., Ink status bars) after the prompt marker, - * `detectBufferText()` would falsely pick up that text as user input. - * Call this after switching to prevent false detection until the user - * actually presses Enter (which calls `clear()` and re-enables detection). - */ - suppressBufferDetection(): void { + // ─── 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) + * @param render - Whether to re-render immediately (default: `true`). + * Pass `false` when restoring flushed state during a tab/session switch + * before the new buffer has loaded — rendering against a stale buffer + * would lock the prompt column to the wrong position. Call `rerender()` + * explicitly after the buffer finishes loading. + */ + setFlushed(count: number, text: string, render = true): void { + this._flushedOffset = count; + this._flushedText = text; + if (render) 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; + } + + /** + * Undo the last `detectBufferText()` call — clears flushed state and + * re-enables detection. + * + * Use case: Tab completion detection found text that matches the + * pre-tab baseline (no real completion happened). Call this to undo + * the detection so it can retry on the next flush cycle. + */ + undoDetection(): void { + this._flushedOffset = 0; + this._flushedText = ''; + this._bufferDetectDone = false; + } + + /** + * Suppress buffer detection until the next `clear()` or + * `resetBufferDetection()` call. + * + * Use case: When switching to a session whose buffer contains UI + * framework text (e.g., Ink status bars) after the prompt marker, + * `detectBufferText()` would falsely pick up that text as user input. + * Call this after switching to prevent false detection until the user + * actually presses Enter (which calls `clear()` and re-enables detection). + */ + suppressBufferDetection(): void { + this._bufferDetectDone = true; + } + + // ─── Prompt configuration ────────────────────────────────────────── + + /** + * Change the prompt detection strategy at runtime. + * Call this when switching between CLI modes (e.g., Claude Code vs OpenCode) + * that use different prompt characters. + */ + setPrompt(finder: PromptFinder): void { + this._options.prompt = finder; + this._lastPromptPos = null; + this._lastRenderKey = ''; + if (this._pendingText || this._flushedOffset > 0) this._render(); + } + + // ─── 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 !== null && 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 */ } - // ─── Prompt configuration ────────────────────────────────────────── + return null; + } - /** - * Change the prompt detection strategy at runtime. - * Call this when switching between CLI modes (e.g., Claude Code vs OpenCode) - * that use different prompt characters. - */ - setPrompt(finder: PromptFinder): void { - this._options.prompt = finder; - this._lastPromptPos = null; - this._lastRenderKey = ''; - if (this._pendingText || this._flushedOffset > 0) this._render(); + 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; } - // ─── Prompt utilities ───────────────────────────────────────────── + try { + const buf = this._terminal.buffer.active; - /** - * 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); - } + // Hide overlay when scrolled up — prompt is at bottom, not in viewport + if (buf.viewportY !== buf.baseY) { + this._overlay.style.display = 'none'; + return; + } - /** - * 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 !== null && 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; - } + // 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!; - private _hide(): void { - if (!this._overlay) return; - this._lastRenderKey = ''; - this._lastPromptPos = null; + const dims = getCellDimensions(this._terminal); + if (!dims) { + this._overlay.style.display = 'none'; + return; + } + + const { width: cellW, height: cellH, charTop, charHeight } = 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 — include text content to detect + // same-length changes (e.g., setFlushed with different text) + const renderKey = `${displayText}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._flushedOffset}`; + if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return; + this._lastRenderKey = renderKey; + + // Split into visual lines by column width (CJK wide chars = 2 cols) + const firstLineCols = Math.max(1, totalCols - startCol); + const chars = [...displayText]; // proper Unicode iteration + const lines: string[] = []; + let ci = 0; + // First line: remaining columns after prompt + { + let lineStr = ''; + let lineCols = 0; + while (ci < chars.length) { + const cw = charCellWidth(this._terminal, chars[ci]); + if (lineCols + cw > firstLineCols) break; + lineStr += chars[ci]; + lineCols += cw; + ci++; + } + lines.push(lineStr); + } + // Subsequent lines: full terminal width + while (ci < chars.length) { + let lineStr = ''; + let lineCols = 0; + while (ci < chars.length) { + const cw = charCellWidth(this._terminal, chars[ci]); + if (lineCols + cw > totalCols) break; + lineStr += chars[ci]; + lineCols += cw; + ci++; + } + lines.push(lineStr); + } + + const cursorColor = this._options.cursorColor ?? this._terminal.options.theme?.cursor ?? DEFAULT_CURSOR; + + renderOverlay(this._overlay, { + lines, + startCol, + totalCols, + cellW, + cellH, + charTop, + charHeight, + promptRow: activePrompt.row, + font: this._font, + showCursor: this._options.showCursor, + cursorColor, + terminal: this._terminal, + }); + } 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'; + } } - - 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, charTop, charHeight } = 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 — include text content to detect - // same-length changes (e.g., setFlushed with different text) - const renderKey = `${displayText}:${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, - charTop, - charHeight, - 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'; - } - } - } + } } diff --git a/packages/xterm-zerolag-input/test/overlay-renderer.test.ts b/packages/xterm-zerolag-input/test/overlay-renderer.test.ts index 81d12223..c87a02a7 100644 --- a/packages/xterm-zerolag-input/test/overlay-renderer.test.ts +++ b/packages/xterm-zerolag-input/test/overlay-renderer.test.ts @@ -1,300 +1,420 @@ import { describe, it, expect } from 'vitest'; -import { renderOverlay } from '../src/overlay-renderer.js'; +import { renderOverlay, charCellWidth, stringCellWidth } 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: '', + fontFamily: 'monospace', + fontSize: '14px', + fontWeight: 'normal', + color: '#eeeeee', + backgroundColor: '#0d0d0d', + letterSpacing: '', }; function makeParams(overrides: Partial = {}): RenderParams { - return { - lines: ['hello'], - startCol: 2, - totalCols: 80, - cellW: 8.4, - cellH: 17, - charTop: 2, - charHeight: 14, - promptRow: 10, - font: FONT, - showCursor: true, - cursorColor: '#e0e0e0', - ...overrides, - }; + return { + lines: ['hello'], + startCol: 2, + totalCols: 80, + cellW: 8.4, + cellH: 17, + charTop: 2, + charHeight: 14, + 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('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'] })); + 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); + // 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 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 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 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'); - }); + 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('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 })); + 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 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'); - }); + 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('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', - })); + 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)'); - }); + // 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('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, - })); + 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); + // 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 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' - }); + 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 + 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) + 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 - }); + 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(''); - }); + it('shows container (display not none)', () => { + const container = document.createElement('div'); + container.style.display = 'none'; + renderOverlay(container, makeParams()); + expect(container.style.display).toBe(''); + }); - // ─── Anti-flicker / compositing seam tests ──────────────────── + // ─── Anti-flicker / compositing seam tests ──────────────────── - it('line div height extends 1px past cellH to cover compositing seam', () => { - const container = document.createElement('div'); - renderOverlay(container, makeParams({ lines: ['abc'], cellH: 19 })); - const lineDiv = container.children[0] as HTMLDivElement; - // cellH + 1 = 20px — the extra 1px covers the compositing seam - expect(lineDiv.style.height).toBe('20px'); - }); + it('line div height extends 1px past cellH to cover compositing seam', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['abc'], cellH: 19 })); + const lineDiv = container.children[0] as HTMLDivElement; + // cellH + 1 = 20px — the extra 1px covers the compositing seam + expect(lineDiv.style.height).toBe('20px'); + }); - it('line div height is cellH+1 for various cell heights', () => { - for (const cellH of [15, 17, 19, 22]) { - const container = document.createElement('div'); - renderOverlay(container, makeParams({ lines: ['x'], cellH })); - const lineDiv = container.children[0] as HTMLDivElement; - expect(lineDiv.style.height).toBe((cellH + 1) + 'px'); - } - }); + it('line div height is cellH+1 for various cell heights', () => { + for (const cellH of [15, 17, 19, 22]) { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['x'], cellH })); + const lineDiv = container.children[0] as HTMLDivElement; + expect(lineDiv.style.height).toBe(cellH + 1 + 'px'); + } + }); - it('multi-line overlay has cellH+1 height on each line div', () => { - const container = document.createElement('div'); - renderOverlay(container, makeParams({ - lines: ['first', 'second'], - cellH: 19, - })); - const line1 = container.children[0] as HTMLDivElement; - const line2 = container.children[1] as HTMLDivElement; - expect(line1.style.height).toBe('20px'); - expect(line2.style.height).toBe('20px'); - }); + it('multi-line overlay has cellH+1 height on each line div', () => { + const container = document.createElement('div'); + renderOverlay( + container, + makeParams({ + lines: ['first', 'second'], + cellH: 19, + }) + ); + const line1 = container.children[0] as HTMLDivElement; + const line2 = container.children[1] as HTMLDivElement; + expect(line1.style.height).toBe('20px'); + expect(line2.style.height).toBe('20px'); + }); - // ─── Span vertical centering tests ──────────────────────────── + // ─── Span vertical centering tests ──────────────────────────── - it('span uses full cellH for height and lineHeight (CSS centering)', () => { - const container = document.createElement('div'); - renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 })); - const lineDiv = container.children[0] as HTMLDivElement; - const span = lineDiv.children[0] as HTMLSpanElement; - expect(span.style.height).toBe('19px'); - expect(span.style.lineHeight).toBe('19px'); - }); + it('span uses full cellH for height and lineHeight (CSS centering)', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 })); + const lineDiv = container.children[0] as HTMLDivElement; + const span = lineDiv.children[0] as HTMLSpanElement; + expect(span.style.height).toBe('19px'); + expect(span.style.lineHeight).toBe('19px'); + }); - it('span top is 0px (no vertical offset / no transform)', () => { - const container = document.createElement('div'); - renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 })); - const lineDiv = container.children[0] as HTMLDivElement; - const span = lineDiv.children[0] as HTMLSpanElement; - expect(span.style.top).toBe('0px'); - // No translateY transform — sub-pixel overhang causes artifacts - expect(span.style.transform).toBe(''); - }); + it('span top is 0px (no vertical offset / no transform)', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 })); + const lineDiv = container.children[0] as HTMLDivElement; + const span = lineDiv.children[0] as HTMLSpanElement; + expect(span.style.top).toBe('0px'); + // No translateY transform — sub-pixel overhang causes artifacts + expect(span.style.transform).toBe(''); + }); - // ─── Font rendering tests ───────────────────────────────────── + // ─── Font rendering tests ───────────────────────────────────── - it('span disables ligatures via font-feature-settings', () => { - const container = document.createElement('div'); - renderOverlay(container, makeParams({ lines: ['fi'] })); - const lineDiv = container.children[0] as HTMLDivElement; - const span = lineDiv.children[0] as HTMLSpanElement; - // Check cssText includes the ligature-disabling settings - // jsdom may normalize whitespace; check that both liga and calt are disabled - expect(span.style.cssText).toContain("font-feature-settings:"); - expect(span.style.cssText).toContain("'liga' 0"); - expect(span.style.cssText).toContain("'calt' 0"); - }); + it('span disables ligatures via font-feature-settings', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['fi'] })); + const lineDiv = container.children[0] as HTMLDivElement; + const span = lineDiv.children[0] as HTMLSpanElement; + // Check cssText includes the ligature-disabling settings + // jsdom may normalize whitespace; check that both liga and calt are disabled + expect(span.style.cssText).toContain('font-feature-settings:'); + expect(span.style.cssText).toContain("'liga' 0"); + expect(span.style.cssText).toContain("'calt' 0"); + }); - it('span has text-align: center for glyph centering', () => { - const container = document.createElement('div'); - renderOverlay(container, makeParams({ lines: ['m'] })); - const lineDiv = container.children[0] as HTMLDivElement; - const span = lineDiv.children[0] as HTMLSpanElement; - expect(span.style.textAlign).toBe('center'); - }); + it('span has text-align: center for glyph centering', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['m'] })); + const lineDiv = container.children[0] as HTMLDivElement; + const span = lineDiv.children[0] as HTMLSpanElement; + expect(span.style.textAlign).toBe('center'); + }); - it('span has pointer-events: none', () => { - const container = document.createElement('div'); - renderOverlay(container, makeParams({ lines: ['a'] })); - const lineDiv = container.children[0] as HTMLDivElement; - const span = lineDiv.children[0] as HTMLSpanElement; - expect(span.style.pointerEvents).toBe('none'); - }); + it('span has pointer-events: none', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['a'] })); + const lineDiv = container.children[0] as HTMLDivElement; + const span = lineDiv.children[0] as HTMLSpanElement; + expect(span.style.pointerEvents).toBe('none'); + }); - // ─── Multi-line cursor positioning ──────────────────────────── + // ─── Multi-line cursor positioning ──────────────────────────── - it('cursor on wrapped line uses col 0 as base', () => { - const container = document.createElement('div'); - renderOverlay(container, makeParams({ - lines: ['first', 'ab'], - startCol: 5, - cellW: 10, - cellH: 20, - showCursor: true, - })); - // Cursor at end of second line: col = 0 + 2 = 2 - const cursor = container.children[container.children.length - 1] as HTMLSpanElement; - expect(cursor.style.left).toBe('20px'); // 2 * 10 - expect(cursor.style.top).toBe('20px'); // row 1 * cellH - }); + it('cursor on wrapped line uses col 0 as base', () => { + const container = document.createElement('div'); + renderOverlay( + container, + makeParams({ + lines: ['first', 'ab'], + startCol: 5, + cellW: 10, + cellH: 20, + showCursor: true, + }) + ); + // Cursor at end of second line: col = 0 + 2 = 2 + const cursor = container.children[container.children.length - 1] as HTMLSpanElement; + expect(cursor.style.left).toBe('20px'); // 2 * 10 + expect(cursor.style.top).toBe('20px'); // row 1 * cellH + }); - // ─── charTop/charHeight passed through ──────────────────────── + // ─── charTop/charHeight passed through ──────────────────────── - it('accepts charTop and charHeight params without error', () => { - const container = document.createElement('div'); - expect(() => renderOverlay(container, makeParams({ - lines: ['test'], - charTop: 2, - charHeight: 14, - }))).not.toThrow(); - expect(container.children.length).toBeGreaterThan(0); - }); + it('accepts charTop and charHeight params without error', () => { + const container = document.createElement('div'); + expect(() => + renderOverlay( + container, + makeParams({ + lines: ['test'], + charTop: 2, + charHeight: 14, + }) + ) + ).not.toThrow(); + expect(container.children.length).toBeGreaterThan(0); + }); - // ─── Line div positioning regression ────────────────────────── + // ─── Line div positioning regression ────────────────────────── - it('line div background color matches font.backgroundColor', () => { - const container = document.createElement('div'); - const font: FontStyle = { ...FONT, backgroundColor: '#1a1a1a' }; - renderOverlay(container, makeParams({ lines: ['x'], font })); - const lineDiv = container.children[0] as HTMLDivElement; - // jsdom normalizes hex to rgb() - expect(lineDiv.style.backgroundColor).toBe('rgb(26, 26, 26)'); - }); + it('line div background color matches font.backgroundColor', () => { + const container = document.createElement('div'); + const font: FontStyle = { ...FONT, backgroundColor: '#1a1a1a' }; + renderOverlay(container, makeParams({ lines: ['x'], font })); + const lineDiv = container.children[0] as HTMLDivElement; + // jsdom normalizes hex to rgb() + expect(lineDiv.style.backgroundColor).toBe('rgb(26, 26, 26)'); + }); - it('empty line produces line div with no spans', () => { - const container = document.createElement('div'); - renderOverlay(container, makeParams({ lines: [''] })); - const lineDiv = container.children[0] as HTMLDivElement; - expect(lineDiv.children.length).toBe(0); - }); + it('empty line produces line div with no spans', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: [''] })); + const lineDiv = container.children[0] as HTMLDivElement; + expect(lineDiv.children.length).toBe(0); + }); + + // ─── CJK wide character support ─────────────────────────────── + + it('CJK characters get double-width spans', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['a你b'], cellW: 10 })); + const lineDiv = container.children[0] as HTMLDivElement; + expect(lineDiv.children.length).toBe(3); + + const spanA = lineDiv.children[0] as HTMLSpanElement; + expect(spanA.textContent).toBe('a'); + expect(spanA.style.left).toBe('0px'); + expect(spanA.style.width).toBe('10px'); // 1 cell + + const spanCJK = lineDiv.children[1] as HTMLSpanElement; + expect(spanCJK.textContent).toBe('你'); + expect(spanCJK.style.left).toBe('10px'); // col 1 + expect(spanCJK.style.width).toBe('20px'); // 2 cells + + const spanB = lineDiv.children[2] as HTMLSpanElement; + expect(spanB.textContent).toBe('b'); + expect(spanB.style.left).toBe('30px'); // col 3 + expect(spanB.style.width).toBe('10px'); // 1 cell + }); + + it('cursor position accounts for CJK width', () => { + const container = document.createElement('div'); + renderOverlay( + container, + makeParams({ + lines: ['你好'], + startCol: 2, + cellW: 10, + showCursor: true, + }) + ); + // 你(2) + 好(2) = 4 visual cols, cursor at startCol(2) + 4 = 6 + const cursor = container.children[container.children.length - 1] as HTMLSpanElement; + expect(cursor.style.left).toBe('60px'); + }); + + it('mixed ASCII and CJK characters position correctly', () => { + const container = document.createElement('div'); + renderOverlay(container, makeParams({ lines: ['hi你'], cellW: 8 })); + const lineDiv = container.children[0] as HTMLDivElement; + // h(col 0), i(col 1), 你(col 2, width 2) + const spanH = lineDiv.children[0] as HTMLSpanElement; + expect(spanH.style.left).toBe('0px'); + const spanI = lineDiv.children[1] as HTMLSpanElement; + expect(spanI.style.left).toBe('8px'); + const spanCJK = lineDiv.children[2] as HTMLSpanElement; + expect(spanCJK.style.left).toBe('16px'); + expect(spanCJK.style.width).toBe('16px'); + }); +}); + +describe('charCellWidth', () => { + it('returns 1 for ASCII characters', () => { + expect(charCellWidth(null, 'a')).toBe(1); + expect(charCellWidth(null, '!')).toBe(1); + expect(charCellWidth(null, ' ')).toBe(1); + }); + + it('returns 2 for CJK ideographs', () => { + expect(charCellWidth(null, '你')).toBe(2); + expect(charCellWidth(null, '好')).toBe(2); + expect(charCellWidth(null, '中')).toBe(2); + }); + + it('returns 2 for Japanese hiragana', () => { + expect(charCellWidth(null, 'こ')).toBe(2); + expect(charCellWidth(null, 'ん')).toBe(2); + }); + + it('returns 2 for Korean syllables', () => { + expect(charCellWidth(null, '안')).toBe(2); + expect(charCellWidth(null, '녕')).toBe(2); + }); + + it('returns 2 for fullwidth forms', () => { + expect(charCellWidth(null, '\uff01')).toBe(2); // ! + expect(charCellWidth(null, '\uff21')).toBe(2); // A + }); + + it('uses terminal unicode addon when available', () => { + const mockTerminal = { + unicode: { getStringCellWidth: (s: string) => (s === 'W' ? 2 : 1) }, + } as any; + expect(charCellWidth(mockTerminal, 'W')).toBe(2); + expect(charCellWidth(mockTerminal, 'n')).toBe(1); + }); +}); + +describe('stringCellWidth', () => { + it('sums individual character widths', () => { + expect(stringCellWidth(null, 'abc')).toBe(3); + expect(stringCellWidth(null, '你好')).toBe(4); + expect(stringCellWidth(null, 'a你b')).toBe(4); + }); + + it('returns 0 for empty string', () => { + expect(stringCellWidth(null, '')).toBe(0); + }); }); diff --git a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts index 34a4f8f3..74063761 100644 --- a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts +++ b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts @@ -3,667 +3,725 @@ import { createMockTerminal } from './helpers.js'; import { ZerolagInputAddon } from '../src/zerolag-input-addon.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 }; + 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 = []; + 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; + 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('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(); }); - 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('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); + }); + }); - it('hasPending is true when text exists', () => { - const { addon } = tracked(); - expect(addon.hasPending).toBe(false); - addon.addChar('x'); - expect(addon.hasPending).toBe(true); - }); + 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'); }); - describe('appendText', () => { - it('appends multiple characters (paste)', () => { - const { addon } = tracked(); - addon.addChar('h'); - addon.appendText('ello'); - expect(addon.pendingText).toBe('hello'); - }); + it('hasPending is true when text exists', () => { + const { addon } = tracked(); + expect(addon.hasPending).toBe(false); + addon.addChar('x'); + expect(addon.hasPending).toBe(true); + }); + }); - it('ignores empty string', () => { - const { addon } = tracked(); - addon.appendText(''); - expect(addon.pendingText).toBe(''); - expect(addon.hasPending).toBe(false); - }); + describe('appendText', () => { + it('appends multiple characters (paste)', () => { + const { addon } = tracked(); + addon.addChar('h'); + addon.appendText('ello'); + expect(addon.pendingText).toBe('hello'); }); - describe('removeChar', () => { - it('returns "pending" when removing from pendingText', () => { - const { addon } = tracked(); - addon.addChar('a'); - addon.addChar('b'); - const source = addon.removeChar(); - expect(source).toBe('pending'); - expect(addon.pendingText).toBe('a'); - }); + it('ignores empty string', () => { + const { addon } = tracked(); + addon.appendText(''); + expect(addon.pendingText).toBe(''); + expect(addon.hasPending).toBe(false); + }); + }); - it('returns false when nothing to remove', () => { - const { addon } = tracked(); - expect(addon.removeChar()).toBe(false); - }); - - it('returns "flushed" when removing from flushed text', () => { - const { addon } = tracked(); - addon.setFlushed(3, 'abc'); - const source = addon.removeChar(); - expect(source).toBe('flushed'); - 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'); - const source = addon.removeChar(); - expect(source).toBe('pending'); - 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); - }); - - it('detects buffer text and removes from it when both empty', () => { - const { addon } = tracked(['$ hello']); - // Both pending and flushed are empty, but buffer has text - const source = addon.removeChar(); - expect(source).toBe('flushed'); - // "hello" (5 chars) detected, then one removed = 4 - expect(addon.getFlushed().count).toBe(4); - expect(addon.getFlushed().text).toBe('hell'); - }); - - it('returns false on empty prompt with no buffer text', () => { - const { addon } = tracked(['$ ']); - expect(addon.removeChar()).toBe(false); - }); + describe('removeChar', () => { + it('returns "pending" when removing from pendingText', () => { + const { addon } = tracked(); + addon.addChar('a'); + addon.addChar('b'); + const source = addon.removeChar(); + expect(source).toBe('pending'); + expect(addon.pendingText).toBe('a'); }); - 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); - }); + it('returns false when nothing to remove', () => { + const { addon } = tracked(); + expect(addon.removeChar()).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('setFlushed with render=false does not render', () => { - const { addon } = tracked(); - addon.setFlushed(3, 'abc', false); - expect(addon.getFlushed()).toEqual({ count: 3, text: 'abc' }); - expect(addon.hasPending).toBe(true); - // Overlay should still be hidden (no render triggered) - expect(addon.state.visible).toBe(false); - }); - - it('setFlushed with render=false prevents stale column lock', () => { - const { addon, mock } = tracked(['$ old session text']); - // Simulate: clear overlay, then restore flushed WITHOUT render - addon.clear(); - addon.setFlushed(5, 'hello', false); - // lastPromptPos should still be null (no render happened) - expect(addon.state.promptPosition).toBeNull(); - // Now simulate buffer load and rerender - mock.setLines(['$ ']); - addon.rerender(); - // Prompt should be freshly scanned, no stale column lock - const pos = addon.state.promptPosition; - expect(pos).not.toBeNull(); - }); - - 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); - }); + it('returns "flushed" when removing from flushed text', () => { + const { addon } = tracked(); + addon.setFlushed(3, 'abc'); + const source = addon.removeChar(); + expect(source).toBe('flushed'); + expect(addon.getFlushed().count).toBe(2); + expect(addon.getFlushed().text).toBe('ab'); }); - 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'); - }); + it('removes pending before flushed', () => { + const { addon } = tracked(); + addon.setFlushed(2, 'ab'); + addon.addChar('c'); + const source = addon.removeChar(); + expect(source).toBe('pending'); + expect(addon.pendingText).toBe(''); + expect(addon.getFlushed().count).toBe(2); // flushed unchanged }); - 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(); - }); + it('hides overlay when both pending and flushed become empty', () => { + const { addon } = tracked(); + addon.addChar('x'); + addon.removeChar(); + expect(addon.hasPending).toBe(false); }); - 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('suppressBufferDetection prevents detection', () => { - const { addon } = tracked(['$ some UI text']); - addon.suppressBufferDetection(); - - // Detection should be blocked - const text = addon.detectBufferText(); - expect(text).toBeNull(); - expect(addon.getFlushed().count).toBe(0); - }); - - it('suppressBufferDetection also blocks implicit detection in addChar', () => { - const { addon } = tracked(['$ ink status bar']); - addon.suppressBufferDetection(); - - // addChar calls _detectBufferText internally on first keystroke - addon.addChar('x'); - // Should have only 'x' pending, NOT the buffer text as flushed - expect(addon.pendingText).toBe('x'); - expect(addon.getFlushed().count).toBe(0); - }); - - it('suppressBufferDetection also blocks detection in removeChar cascade', () => { - const { addon } = tracked(['$ buffer text']); - addon.suppressBufferDetection(); - - // removeChar step 3 calls _detectBufferText — should be blocked - expect(addon.removeChar()).toBe(false); - expect(addon.getFlushed().count).toBe(0); - }); - - it('undoDetection clears flushed and re-enables detection', () => { - const { addon } = tracked(['$ completed']); - // Detect buffer text (simulates tab completion detection) - addon.detectBufferText(); - expect(addon.getFlushed().count).toBe(9); // 'completed' - - // Undo because it matched baseline (no real completion) - addon.undoDetection(); - expect(addon.getFlushed().count).toBe(0); - expect(addon.getFlushed().text).toBe(''); - - // Detection should work again (guard reset) - const text = addon.detectBufferText(); - expect(text).toBe('completed'); - }); - - it('undoDetection does not hide overlay or clear pending', () => { - const { addon } = tracked(['$ buffer']); - addon.detectBufferText(); // sets flushed - addon.addChar('x'); // adds pending on top - - addon.undoDetection(); - // Flushed cleared, but pending preserved - expect(addon.pendingText).toBe('x'); - expect(addon.getFlushed().count).toBe(0); - expect(addon.hasPending).toBe(true); - }); - - it('clear resets suppression (re-enables detection)', () => { - const { addon } = tracked(['$ text']); - addon.suppressBufferDetection(); - addon.clear(); // resets _bufferDetectDone to false - - 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'); - }); + it('detects buffer text and removes from it when both empty', () => { + const { addon } = tracked(['$ hello']); + // Both pending and flushed are empty, but buffer has text + const source = addon.removeChar(); + expect(source).toBe('flushed'); + // "hello" (5 chars) detected, then one removed = 4 + expect(addon.getFlushed().count).toBe(4); + expect(addon.getFlushed().text).toBe('hell'); }); - describe('custom prompt configurations', () => { - it('works with > prompt character', () => { - const { addon } = tracked(['> hello'], '>'); - const text = addon.readPromptText(); - expect(text).toBe('hello'); - }); + it('returns false on empty prompt with no buffer text', () => { + const { addon } = tracked(['$ ']); + expect(addon.removeChar()).toBe(false); + }); + }); - 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(); }); + describe('clear', () => { + it('resets all state', () => { + const { addon } = tracked(); + addon.setFlushed(3, 'abc'); + addon.addChar('d'); + addon.clear(); - const text = addon.readPromptText(); - expect(text).toBe('hello'); - }); + 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); }); - describe('state.visible', () => { - it('is false before activate', () => { - const addon = new ZerolagInputAddon(); - expect(addon.state.visible).toBe(false); - // No cleanup needed — never activated - }); - - it('is false after dispose', () => { - const { addon, mock } = tracked(); - addon.addChar('x'); - addon.dispose(); - expect(addon.state.visible).toBe(false); - mock.cleanup(); - }); + it('setFlushed with render=false does not render', () => { + const { addon } = tracked(); + addon.setFlushed(3, 'abc', false); + expect(addon.getFlushed()).toEqual({ count: 3, text: 'abc' }); + expect(addon.hasPending).toBe(true); + // Overlay should still be hidden (no render triggered) + expect(addon.state.visible).toBe(false); }); - 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); - }); - - it('refreshFont re-renders flushed-only text', () => { - const { addon } = tracked(); - addon.setFlushed(3, 'abc'); - expect(() => addon.refreshFont()).not.toThrow(); - expect(addon.hasPending).toBe(true); - }); + it('setFlushed with render=false prevents stale column lock', () => { + const { addon, mock } = tracked(['$ old session text']); + // Simulate: clear overlay, then restore flushed WITHOUT render + addon.clear(); + addon.setFlushed(5, 'hello', false); + // lastPromptPos should still be null (no render happened) + expect(addon.state.promptPosition).toBeNull(); + // Now simulate buffer load and rerender + mock.setLines(['$ ']); + addon.rerender(); + // Prompt should be freshly scanned, no stale column lock + const pos = addon.state.promptPosition; + expect(pos).not.toBeNull(); }); - describe('tab-switch save/restore pattern', () => { - it('save pending + flushed, restore as flushed', () => { - const { addon } = tracked(['$ ']); - - // User types some text - addon.addChar('h'); - addon.addChar('i'); - expect(addon.pendingText).toBe('hi'); - - // Tab switch: save state - const pending = addon.pendingText; - const { count: flushedCount, text: flushedText } = addon.getFlushed(); - const totalCount = flushedCount + pending.length; - const totalText = flushedText + pending; - addon.clear(); - - // Simulate PTY send of pending text (app would do this) - // ... - - // Tab switch back: restore as flushed (text is now in PTY) - addon.suppressBufferDetection(); // prevent false Ink detection - addon.setFlushed(totalCount, totalText, false); // no render — buffer not loaded yet - expect(addon.getFlushed()).toEqual({ count: 2, text: 'hi' }); - expect(addon.hasPending).toBe(true); // has flushed content - - // Backspace should return 'flushed' (text is in PTY) - const source = addon.removeChar(); - expect(source).toBe('flushed'); - expect(addon.getFlushed().count).toBe(1); - }); - - it('save with existing flushed + pending', () => { - const { addon } = tracked(['$ ']); - - // Set flushed from previous restore, then user types more - addon.setFlushed(3, 'abc'); - addon.addChar('d'); - addon.addChar('e'); - - // Save state - const pending = addon.pendingText; - const { count, text } = addon.getFlushed(); - expect(pending).toBe('de'); - expect(count).toBe(3); - expect(text).toBe('abc'); - - // Combined for restore - const totalCount = count + pending.length; - const totalText = text + pending; - addon.clear(); - - // Restore - addon.setFlushed(totalCount, totalText); - expect(addon.getFlushed()).toEqual({ count: 5, text: 'abcde' }); - }); + it('clearFlushed resets flushed state', () => { + const { addon } = tracked(); + addon.setFlushed(3, 'abc'); + addon.clearFlushed(); + expect(addon.getFlushed()).toEqual({ count: 0, text: '' }); }); - describe('setPrompt', () => { - it('changes prompt detection strategy', () => { - const { addon, mock } = tracked(['$ hello'], '$'); - // Initially finds $ prompt - expect(addon.findPrompt()).toEqual({ row: 0, col: 0 }); - expect(addon.readPromptText()).toBe('hello'); + 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); + }); + }); - // Switch to > prompt — $ is no longer detected - mock.setLines(['> world']); - addon.setPrompt({ type: 'character', char: '>', offset: 2 }); - expect(addon.findPrompt()).toEqual({ row: 0, col: 0 }); - expect(addon.readPromptText()).toBe('world'); - }); + describe('state snapshot', () => { + it('returns current state', () => { + const { addon } = tracked(); + addon.setFlushed(2, 'hi'); + addon.addChar('!'); - it('returns null when new prompt character not found', () => { - const { addon } = tracked(['$ hello'], '$'); - addon.setPrompt({ type: 'character', char: '>', offset: 2 }); - // Buffer still has $ not > - expect(addon.findPrompt()).toBeNull(); - }); - - it('resets cached prompt position', () => { - const { addon } = tracked(['$ typed']); - addon.addChar('x'); - expect(addon.state.promptPosition).not.toBeNull(); - - addon.setPrompt({ type: 'character', char: '>', offset: 2 }); - // After setPrompt with no matching prompt, position resets - expect(addon.state.promptPosition).toBeNull(); - }); - - it('re-renders when text exists', () => { - const { addon, mock } = tracked(['$ '], '$'); - addon.addChar('h'); - addon.addChar('i'); - - // Switch prompt strategy — should re-render with existing text - mock.setLines(['> ']); - addon.setPrompt({ type: 'character', char: '>', offset: 2 }); - expect(addon.pendingText).toBe('hi'); - expect(addon.hasPending).toBe(true); - }); - - it('does not crash when no text to render', () => { - const { addon } = tracked(['$ ']); - expect(() => addon.setPrompt({ type: 'character', char: '>', offset: 2 })).not.toThrow(); - }); - - it('works with regex prompt strategy', () => { - const mock = createMockTerminal({ buffer: { lines: ['user@host:~$ cmd'] } }); - const addon = new ZerolagInputAddon({ - prompt: { type: 'character', char: '$', offset: 2 }, - }); - mock.terminal.loadAddon(addon); - cleanups.push(() => { addon.dispose(); mock.cleanup(); }); - - // Switch to regex - addon.setPrompt({ type: 'regex', pattern: /\$/, offset: 2 }); - expect(addon.findPrompt()).not.toBeNull(); - expect(addon.readPromptText()).toBe('cmd'); - }); + const state = addon.state; + expect(state.pendingText).toBe('!'); + expect(state.flushedLength).toBe(2); + expect(state.flushedText).toBe('hi'); }); - describe('tab switch with setPrompt (CLI switching)', () => { - it('full tab switch cycle: save state, setPrompt, restore', () => { - // Session A with $ prompt - const { addon, mock } = tracked(['$ '], '$'); - addon.addChar('h'); - addon.addChar('e'); - addon.addChar('l'); - addon.addChar('l'); - addon.addChar('o'); - expect(addon.pendingText).toBe('hello'); + 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'); + }); + }); - // Save state before tab switch - const pending = addon.pendingText; - const { count: flushedCount, text: flushedText } = addon.getFlushed(); - const totalText = flushedText + pending; - const totalCount = flushedCount + pending.length; - addon.clear(); - - // Switch to Session B with > prompt - mock.setLines(['> ']); - addon.setPrompt({ type: 'character', char: '>', offset: 2 }); - expect(addon.pendingText).toBe(''); - expect(addon.hasPending).toBe(false); - - // Switch back to Session A — restore state - mock.setLines(['$ ']); - addon.setPrompt({ type: 'character', char: '$', offset: 2 }); - addon.suppressBufferDetection(); - addon.setFlushed(totalCount, totalText, false); - - expect(addon.getFlushed()).toEqual({ count: 5, text: 'hello' }); - expect(addon.hasPending).toBe(true); - }); + 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 }); }); - describe('overlay hides when prompt not found (ghost artifact fix)', () => { - it('overlay hides when prompt scrolls away', () => { - const { addon, mock } = tracked(['$ ']); - addon.addChar('x'); - // Prompt visible, overlay should render - expect(addon.hasPending).toBe(true); - - // Simulate prompt scrolling away (no $ in buffer) - mock.setLines(['just output', 'more output']); - addon.clear(); - addon.addChar('y'); - // Prompt not found — overlay hidden despite pending text - // (the addon renders but finds no prompt, so display stays none) - const state = addon.state; - expect(state.pendingText).toBe('y'); - }); - - it('clear resets lastPromptPos to null', () => { - const { addon } = tracked(['$ ']); - addon.addChar('a'); - expect(addon.state.promptPosition).not.toBeNull(); - - addon.clear(); - expect(addon.state.promptPosition).toBeNull(); - }); + it('findPrompt returns null when no prompt', () => { + const { addon } = tracked(['no prompt here']); + const pos = addon.findPrompt(); + expect(pos).toBeNull(); }); - describe('methods before activate / after dispose', () => { - it('addChar accumulates but does not crash before activate', () => { - const addon = new ZerolagInputAddon(); - addon.addChar('a'); - addon.addChar('b'); - expect(addon.pendingText).toBe('ab'); - expect(addon.hasPending).toBe(true); - // No dispose needed — never activated, no DOM - }); - - it('removeChar works on pending text before activate', () => { - const addon = new ZerolagInputAddon(); - addon.addChar('x'); - expect(addon.removeChar()).toBe('pending'); - expect(addon.pendingText).toBe(''); - }); - - it('clear works before activate', () => { - const addon = new ZerolagInputAddon(); - addon.addChar('x'); - addon.clear(); - expect(addon.pendingText).toBe(''); - expect(addon.hasPending).toBe(false); - }); - - it('all methods safe after dispose', () => { - const { addon, mock } = tracked(); - addon.dispose(); - expect(() => addon.addChar('x')).not.toThrow(); - expect(() => addon.removeChar()).not.toThrow(); - expect(() => addon.clear()).not.toThrow(); - expect(() => addon.rerender()).not.toThrow(); - expect(() => addon.refreshFont()).not.toThrow(); - expect(addon.findPrompt()).toBeNull(); - expect(addon.readPromptText()).toBeNull(); - expect(addon.detectBufferText()).toBeNull(); - mock.cleanup(); - }); + it('readPromptText reads text after prompt', () => { + const { addon } = tracked(['$ hello world']); + const text = addon.readPromptText(); + expect(text).toBe('hello world'); }); - describe('addChar implicit buffer detection', () => { - it('first keystroke detects existing buffer text as flushed', () => { - const { addon } = tracked(['$ existing']); - // First addChar should trigger _detectBufferText - addon.addChar('!'); - expect(addon.pendingText).toBe('!'); - expect(addon.getFlushed().count).toBe(8); // 'existing' - expect(addon.getFlushed().text).toBe('existing'); - }); - - it('second keystroke does NOT re-detect', () => { - const { addon } = tracked(['$ existing']); - addon.addChar('a'); - addon.addChar('b'); - // Should NOT detect again — flushed from first char remains - expect(addon.pendingText).toBe('ab'); - expect(addon.getFlushed().count).toBe(8); // still 'existing' - }); + 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('suppressBufferDetection prevents detection', () => { + const { addon } = tracked(['$ some UI text']); + addon.suppressBufferDetection(); + + // Detection should be blocked + const text = addon.detectBufferText(); + expect(text).toBeNull(); + expect(addon.getFlushed().count).toBe(0); + }); + + it('suppressBufferDetection also blocks implicit detection in addChar', () => { + const { addon } = tracked(['$ ink status bar']); + addon.suppressBufferDetection(); + + // addChar calls _detectBufferText internally on first keystroke + addon.addChar('x'); + // Should have only 'x' pending, NOT the buffer text as flushed + expect(addon.pendingText).toBe('x'); + expect(addon.getFlushed().count).toBe(0); + }); + + it('suppressBufferDetection also blocks detection in removeChar cascade', () => { + const { addon } = tracked(['$ buffer text']); + addon.suppressBufferDetection(); + + // removeChar step 3 calls _detectBufferText — should be blocked + expect(addon.removeChar()).toBe(false); + expect(addon.getFlushed().count).toBe(0); + }); + + it('undoDetection clears flushed and re-enables detection', () => { + const { addon } = tracked(['$ completed']); + // Detect buffer text (simulates tab completion detection) + addon.detectBufferText(); + expect(addon.getFlushed().count).toBe(9); // 'completed' + + // Undo because it matched baseline (no real completion) + addon.undoDetection(); + expect(addon.getFlushed().count).toBe(0); + expect(addon.getFlushed().text).toBe(''); + + // Detection should work again (guard reset) + const text = addon.detectBufferText(); + expect(text).toBe('completed'); + }); + + it('undoDetection does not hide overlay or clear pending', () => { + const { addon } = tracked(['$ buffer']); + addon.detectBufferText(); // sets flushed + addon.addChar('x'); // adds pending on top + + addon.undoDetection(); + // Flushed cleared, but pending preserved + expect(addon.pendingText).toBe('x'); + expect(addon.getFlushed().count).toBe(0); + expect(addon.hasPending).toBe(true); + }); + + it('clear resets suppression (re-enables detection)', () => { + const { addon } = tracked(['$ text']); + addon.suppressBufferDetection(); + addon.clear(); // resets _bufferDetectDone to false + + 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('state.visible', () => { + it('is false before activate', () => { + const addon = new ZerolagInputAddon(); + expect(addon.state.visible).toBe(false); + // No cleanup needed — never activated + }); + + it('is false after dispose', () => { + const { addon, mock } = tracked(); + addon.addChar('x'); + addon.dispose(); + expect(addon.state.visible).toBe(false); + mock.cleanup(); + }); + }); + + 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); + }); + + it('refreshFont re-renders flushed-only text', () => { + const { addon } = tracked(); + addon.setFlushed(3, 'abc'); + expect(() => addon.refreshFont()).not.toThrow(); + expect(addon.hasPending).toBe(true); + }); + }); + + describe('tab-switch save/restore pattern', () => { + it('save pending + flushed, restore as flushed', () => { + const { addon } = tracked(['$ ']); + + // User types some text + addon.addChar('h'); + addon.addChar('i'); + expect(addon.pendingText).toBe('hi'); + + // Tab switch: save state + const pending = addon.pendingText; + const { count: flushedCount, text: flushedText } = addon.getFlushed(); + const totalCount = flushedCount + pending.length; + const totalText = flushedText + pending; + addon.clear(); + + // Simulate PTY send of pending text (app would do this) + // ... + + // Tab switch back: restore as flushed (text is now in PTY) + addon.suppressBufferDetection(); // prevent false Ink detection + addon.setFlushed(totalCount, totalText, false); // no render — buffer not loaded yet + expect(addon.getFlushed()).toEqual({ count: 2, text: 'hi' }); + expect(addon.hasPending).toBe(true); // has flushed content + + // Backspace should return 'flushed' (text is in PTY) + const source = addon.removeChar(); + expect(source).toBe('flushed'); + expect(addon.getFlushed().count).toBe(1); + }); + + it('save with existing flushed + pending', () => { + const { addon } = tracked(['$ ']); + + // Set flushed from previous restore, then user types more + addon.setFlushed(3, 'abc'); + addon.addChar('d'); + addon.addChar('e'); + + // Save state + const pending = addon.pendingText; + const { count, text } = addon.getFlushed(); + expect(pending).toBe('de'); + expect(count).toBe(3); + expect(text).toBe('abc'); + + // Combined for restore + const totalCount = count + pending.length; + const totalText = text + pending; + addon.clear(); + + // Restore + addon.setFlushed(totalCount, totalText); + expect(addon.getFlushed()).toEqual({ count: 5, text: 'abcde' }); + }); + }); + + describe('setPrompt', () => { + it('changes prompt detection strategy', () => { + const { addon, mock } = tracked(['$ hello'], '$'); + // Initially finds $ prompt + expect(addon.findPrompt()).toEqual({ row: 0, col: 0 }); + expect(addon.readPromptText()).toBe('hello'); + + // Switch to > prompt — $ is no longer detected + mock.setLines(['> world']); + addon.setPrompt({ type: 'character', char: '>', offset: 2 }); + expect(addon.findPrompt()).toEqual({ row: 0, col: 0 }); + expect(addon.readPromptText()).toBe('world'); + }); + + it('returns null when new prompt character not found', () => { + const { addon } = tracked(['$ hello'], '$'); + addon.setPrompt({ type: 'character', char: '>', offset: 2 }); + // Buffer still has $ not > + expect(addon.findPrompt()).toBeNull(); + }); + + it('resets cached prompt position', () => { + const { addon } = tracked(['$ typed']); + addon.addChar('x'); + expect(addon.state.promptPosition).not.toBeNull(); + + addon.setPrompt({ type: 'character', char: '>', offset: 2 }); + // After setPrompt with no matching prompt, position resets + expect(addon.state.promptPosition).toBeNull(); + }); + + it('re-renders when text exists', () => { + const { addon, mock } = tracked(['$ '], '$'); + addon.addChar('h'); + addon.addChar('i'); + + // Switch prompt strategy — should re-render with existing text + mock.setLines(['> ']); + addon.setPrompt({ type: 'character', char: '>', offset: 2 }); + expect(addon.pendingText).toBe('hi'); + expect(addon.hasPending).toBe(true); + }); + + it('does not crash when no text to render', () => { + const { addon } = tracked(['$ ']); + expect(() => addon.setPrompt({ type: 'character', char: '>', offset: 2 })).not.toThrow(); + }); + + it('works with regex prompt strategy', () => { + const mock = createMockTerminal({ buffer: { lines: ['user@host:~$ cmd'] } }); + const addon = new ZerolagInputAddon({ + prompt: { type: 'character', char: '$', offset: 2 }, + }); + mock.terminal.loadAddon(addon); + cleanups.push(() => { + addon.dispose(); + mock.cleanup(); + }); + + // Switch to regex + addon.setPrompt({ type: 'regex', pattern: /\$/, offset: 2 }); + expect(addon.findPrompt()).not.toBeNull(); + expect(addon.readPromptText()).toBe('cmd'); + }); + }); + + describe('tab switch with setPrompt (CLI switching)', () => { + it('full tab switch cycle: save state, setPrompt, restore', () => { + // Session A with $ prompt + const { addon, mock } = tracked(['$ '], '$'); + addon.addChar('h'); + addon.addChar('e'); + addon.addChar('l'); + addon.addChar('l'); + addon.addChar('o'); + expect(addon.pendingText).toBe('hello'); + + // Save state before tab switch + const pending = addon.pendingText; + const { count: flushedCount, text: flushedText } = addon.getFlushed(); + const totalText = flushedText + pending; + const totalCount = flushedCount + pending.length; + addon.clear(); + + // Switch to Session B with > prompt + mock.setLines(['> ']); + addon.setPrompt({ type: 'character', char: '>', offset: 2 }); + expect(addon.pendingText).toBe(''); + expect(addon.hasPending).toBe(false); + + // Switch back to Session A — restore state + mock.setLines(['$ ']); + addon.setPrompt({ type: 'character', char: '$', offset: 2 }); + addon.suppressBufferDetection(); + addon.setFlushed(totalCount, totalText, false); + + expect(addon.getFlushed()).toEqual({ count: 5, text: 'hello' }); + expect(addon.hasPending).toBe(true); + }); + }); + + describe('overlay hides when prompt not found (ghost artifact fix)', () => { + it('overlay hides when prompt scrolls away', () => { + const { addon, mock } = tracked(['$ ']); + addon.addChar('x'); + // Prompt visible, overlay should render + expect(addon.hasPending).toBe(true); + + // Simulate prompt scrolling away (no $ in buffer) + mock.setLines(['just output', 'more output']); + addon.clear(); + addon.addChar('y'); + // Prompt not found — overlay hidden despite pending text + // (the addon renders but finds no prompt, so display stays none) + const state = addon.state; + expect(state.pendingText).toBe('y'); + }); + + it('clear resets lastPromptPos to null', () => { + const { addon } = tracked(['$ ']); + addon.addChar('a'); + expect(addon.state.promptPosition).not.toBeNull(); + + addon.clear(); + expect(addon.state.promptPosition).toBeNull(); + }); + }); + + describe('methods before activate / after dispose', () => { + it('addChar accumulates but does not crash before activate', () => { + const addon = new ZerolagInputAddon(); + addon.addChar('a'); + addon.addChar('b'); + expect(addon.pendingText).toBe('ab'); + expect(addon.hasPending).toBe(true); + // No dispose needed — never activated, no DOM + }); + + it('removeChar works on pending text before activate', () => { + const addon = new ZerolagInputAddon(); + addon.addChar('x'); + expect(addon.removeChar()).toBe('pending'); + expect(addon.pendingText).toBe(''); + }); + + it('clear works before activate', () => { + const addon = new ZerolagInputAddon(); + addon.addChar('x'); + addon.clear(); + expect(addon.pendingText).toBe(''); + expect(addon.hasPending).toBe(false); + }); + + it('all methods safe after dispose', () => { + const { addon, mock } = tracked(); + addon.dispose(); + expect(() => addon.addChar('x')).not.toThrow(); + expect(() => addon.removeChar()).not.toThrow(); + expect(() => addon.clear()).not.toThrow(); + expect(() => addon.rerender()).not.toThrow(); + expect(() => addon.refreshFont()).not.toThrow(); + expect(addon.findPrompt()).toBeNull(); + expect(addon.readPromptText()).toBeNull(); + expect(addon.detectBufferText()).toBeNull(); + mock.cleanup(); + }); + }); + + describe('CJK wide character support', () => { + it('addChar works with CJK characters', () => { + const { addon } = tracked(); + addon.addChar('你'); + addon.addChar('好'); + expect(addon.pendingText).toBe('你好'); + }); + + it('appendText works with CJK characters', () => { + const { addon } = tracked(); + addon.appendText('こんにちは'); + expect(addon.pendingText).toBe('こんにちは'); + }); + + it('removeChar removes CJK characters correctly', () => { + const { addon } = tracked(); + addon.addChar('你'); + addon.addChar('好'); + addon.removeChar(); + expect(addon.pendingText).toBe('你'); + }); + + it('mixed ASCII and CJK renders without error', () => { + const { addon } = tracked(); + addon.addChar('h'); + addon.addChar('i'); + addon.addChar('你'); + addon.addChar('好'); + expect(addon.pendingText).toBe('hi你好'); + expect(addon.hasPending).toBe(true); + }); + + it('CJK line wrapping accounts for double-width', () => { + // With 10 cols and startCol=2, first line has 8 available cols + // Each CJK char takes 2 cols, so 4 CJK chars fill the first line + const { addon } = tracked(['$ '], '$'); + // Type 5 CJK chars — should overflow first line + for (const ch of '你好世界啊') { + addon.addChar(ch); + } + expect(addon.pendingText).toBe('你好世界啊'); + expect(addon.hasPending).toBe(true); + }); + + it('Korean text renders without error', () => { + const { addon } = tracked(); + addon.appendText('안녕하세요'); + expect(addon.pendingText).toBe('안녕하세요'); + expect(addon.hasPending).toBe(true); + }); + }); + + describe('addChar implicit buffer detection', () => { + it('first keystroke detects existing buffer text as flushed', () => { + const { addon } = tracked(['$ existing']); + // First addChar should trigger _detectBufferText + addon.addChar('!'); + expect(addon.pendingText).toBe('!'); + expect(addon.getFlushed().count).toBe(8); // 'existing' + expect(addon.getFlushed().text).toBe('existing'); + }); + + it('second keystroke does NOT re-detect', () => { + const { addon } = tracked(['$ existing']); + addon.addChar('a'); + addon.addChar('b'); + // Should NOT detect again — flushed from first char remains + expect(addon.pendingText).toBe('ab'); + expect(addon.getFlushed().count).toBe(8); // still 'existing' + }); + }); }); diff --git a/src/web/public/index.html b/src/web/public/index.html index 349884d0..0930b854 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -20,7 +20,7 @@ - + @@ -1674,14 +1674,14 @@ - - - - - - - - - + + + + + + + + +