From ce188e62790591d3e491c623181e88486ae5442b Mon Sep 17 00:00:00 2001 From: arkon Date: Sun, 22 Feb 2026 10:44:41 +0100 Subject: [PATCH] fix: add suppressBufferDetection, comprehensive docs and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second deep audit comparing all 43 integration points in Claudeman's app.js against the library API found one critical gap: - Add suppressBufferDetection() — needed when switching to sessions with UI framework text (Ink) after the prompt that would be falsely detected. Claudeman sets _bufferDetectDone=true externally; this method provides the public equivalent. Tests: 74 total (+13 new) - Tab-switch save/restore pattern (flushed state roundtrip) - suppressBufferDetection blocks explicit, implicit (addChar), and cascade (removeChar) detection paths - clear() resets suppression - Methods safe before activate() and after dispose() - addChar implicit buffer detection on first keystroke - refreshFont with flushed-only text README: rewritten from 207 to 459 lines with: - removeChar cascade explanation with return value table - Flushed text concept explained - Integration patterns: buffered, char-at-a-time, tab switching, tab completion, Ink/TUI frameworks, SSE reconnect, resize, font - Architecture diagram (keypress → DOM overlay → PTY echo flow) - Prompt column locking, text wrapping, render cache, scroll awareness - All known limitations documented Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/xterm-zerolag-input/README.md | 305 +++++++++++++++--- .../src/zerolag-input-addon.ts | 14 + .../test/zerolag-input-addon.test.ts | 163 ++++++++++ 3 files changed, 432 insertions(+), 50 deletions(-) diff --git a/packages/xterm-zerolag-input/README.md b/packages/xterm-zerolag-input/README.md index a8c582a3..04bc45db 100644 --- a/packages/xterm-zerolag-input/README.md +++ b/packages/xterm-zerolag-input/README.md @@ -18,6 +18,8 @@ When using xterm.js over a remote connection (SSH web clients, cloud IDEs, mobil npm install xterm-zerolag-input ``` +Zero runtime dependencies. Compatible with both `xterm` (pre-5.4) and `@xterm/xterm` (5.4+). + ## Quick Start ```typescript @@ -44,12 +46,10 @@ terminal.onData((data) => { if (source === 'flushed') ws.send(data); // only backspace text already in PTY } else if (data.length === 1 && data.charCodeAt(0) >= 32) { zerolag.addChar(data); - // Don't send to server yet — wait for Enter - // Or send immediately if your app uses char-at-a-time mode } }); -// 3. Re-render after terminal output (optional, for frameworks like Ink) +// 3. Re-render after terminal output (optional, for full-screen TUI frameworks) terminal.onWriteParsed(() => { if (zerolag.hasPending) zerolag.rerender(); }); @@ -57,10 +57,11 @@ terminal.onWriteParsed(() => { ## Prompt Detection -The addon needs to know where user input starts on the terminal line. Three strategies are supported: +The addon needs to know where user input starts on the terminal line. It scans the terminal buffer bottom-up looking for the prompt. Three strategies are supported: ### Character (default) -Scans bottom-up for a single character: + +Scans bottom-up for a single character. Uses `lastIndexOf` to find the rightmost occurrence on each line. ```typescript // Bash: user@host:~$ @@ -76,21 +77,23 @@ Scans bottom-up for a single character: { type: 'character', char: '>', offset: 2 } ``` -The `offset` is how many characters after the marker the input begins (e.g., `"$ "` = 2). +The `offset` is how many characters after the marker the user input begins (e.g., `"$ "` = 2, `">"` with no space = 1). ### Regex -For complex prompts: + +For complex prompts. The global (`g`) flag is safely stripped to prevent `lastIndex` mutation across renders. ```typescript -// Match end-of-prompt patterns +// Match dollar sign at end of prompt { type: 'regex', pattern: /\$\s*$/, offset: 2 } -// Match specific prompt format +// Match specific virtualenv prompt { type: 'regex', pattern: /\(venv\)\s+\w+\s+%/, offset: 2 } ``` ### Custom -Full control: + +Full control — provide your own function: ```typescript { @@ -98,64 +101,93 @@ Full control: offset: 0, find: (terminal) => { // Your logic here — return { row, col } or null + // row is viewport-relative (0 = top of viewport) return { row: terminal.rows - 1, col: 0 }; }, } ``` -## API +## API Reference ### `ZerolagInputAddon` Implements xterm.js `ITerminalAddon`. Load via `terminal.loadAddon(addon)`. +The addon does **not** hook `terminal.onData()` — you wire your own input handler and call the methods below. This gives you full control over which keystrokes are echoed vs forwarded. + #### Input Methods | Method | Description | |--------|-------------| -| `addChar(char)` | Add a single printable character to the overlay | -| `appendText(text)` | Append multiple characters (e.g., paste) | -| `removeChar(): 'pending' \| 'flushed' \| false` | Remove last char. Returns source (`'pending'` = unsent, `'flushed'` = send backspace to PTY) or `false` | -| `clear()` | Clear all state and hide overlay | +| `addChar(char)` | Add a single printable character to the overlay. Call for `charCode >= 32, length === 1`. On first keystroke after empty state, auto-detects existing buffer text as flushed. | +| `appendText(text)` | Append multiple characters at once (paste). Same auto-detection as `addChar`. | +| `removeChar()` | Remove last character. See [removeChar Cascade](#removechar-cascade) below. | +| `clear()` | Clear all state (pending + flushed), hide overlay. Call on Enter, Ctrl+C, Escape, or any action that submits/cancels input. Resets buffer detection guard. | + +#### removeChar Cascade + +`removeChar()` returns `'pending' | 'flushed' | false` indicating the source of the removed character: + +``` +Step 1: pendingText non-empty → pop last char → return 'pending' +Step 2: flushed text exists → decrement flushed → return 'flushed' +Step 3: both empty → detect buffer → return 'flushed' (if found) +Step 4: nothing found → → return false +``` + +**How to use the return value:** + +| Return | Meaning | Action | +|--------|---------|--------| +| `'pending'` | Removed a character that was never sent to PTY | Do nothing — no backspace needed | +| `'flushed'` | Removed a character that was already sent to PTY | Send `\x7f` (backspace) to PTY | +| `false` | Nothing to remove | Do nothing | + +Step 3 handles tab completion and arrow-key edits: if the user tabs to complete a command and immediately hits backspace, the overlay detects the completed text from the terminal buffer and removes from it. #### Flushed Text Tracking -For scenarios where text has been sent to the PTY but the echo hasn't arrived yet (e.g., tab switching between sessions): +"Flushed" text is text that has been sent to the PTY but whose echo hasn't arrived in the terminal buffer yet. This happens during: +- **Tab switching**: Pending overlay text is flushed to PTY before switching, then restored as flushed on switch-back +- **Tab completion**: Shell fills text on the prompt; overlay syncs via `detectBufferText()` | Method | Description | |--------|-------------| -| `setFlushed(count, text)` | Mark characters as sent-but-unacknowledged | -| `getFlushed()` | Get `{ count, text }` of flushed state | -| `clearFlushed()` | Clear flushed state (echo arrived) | +| `setFlushed(count, text)` | Mark characters as sent-but-unacknowledged. Triggers a render. | +| `getFlushed()` | Returns `{ count: number, text: string }`. | +| `clearFlushed()` | Clear flushed state (call when server echo has arrived). | + +#### Buffer Detection + +The overlay can scan the terminal buffer for text that already exists after the prompt but wasn't typed through the overlay (tab completion, arrow-key edits, shell history). + +| Method | Description | +|--------|-------------| +| `detectBufferText()` | Scan buffer for text after prompt. Returns the detected text string, or `null`. If found, sets it as flushed text. Guarded: only runs once per `clear()` cycle. | +| `resetBufferDetection()` | Re-enable detection (e.g., after tab completion response arrives). | +| `suppressBufferDetection()` | Prevent detection until next `clear()`. Use when switching to a session whose buffer has UI framework text (e.g., Ink status bars) after the prompt marker that would be falsely detected. | #### Rendering | Method | Description | |--------|-------------| -| `rerender()` | Force re-render at current prompt position | -| `refreshFont()` | Re-read font properties after size/theme change | - -#### Buffer Detection - -| Method | Description | -|--------|-------------| -| `detectBufferText()` | Scan buffer for text after prompt; returns detected text or `null` | -| `resetBufferDetection()` | Allow re-detection (auto-reset on `clear()`) | +| `rerender()` | Force re-render at current prompt position. Clears the render cache so the DOM is rebuilt. Call after terminal buffer reloads, full-screen redraws, or SSE reconnects. | +| `refreshFont()` | Re-read font properties (family, size, weight, letter-spacing, colors) from the terminal and re-render. Call after font size changes or theme switches. | #### Prompt Utilities | Method | Description | |--------|-------------| -| `findPrompt()` | Find prompt position using configured strategy | -| `readPromptText()` | Read text after prompt marker | +| `findPrompt()` | Find prompt position using the configured strategy. Returns `{ row, col }` (viewport-relative) or `null`. | +| `readPromptText()` | Read text after the prompt marker on the prompt line. Returns the text or `null`. | -#### State +#### State Properties | Property | Type | Description | |----------|------|-------------| -| `pendingText` | `string` | Characters typed but not acknowledged | -| `hasPending` | `boolean` | Whether overlay has any content | -| `state` | `ZerolagInputState` | Full read-only state snapshot | +| `pendingText` | `string` | Characters typed but not acknowledged. Read-only. | +| `hasPending` | `boolean` | `true` if overlay has any content (pending or flushed). | +| `state` | `ZerolagInputState` | Read-only snapshot: `{ pendingText, flushedLength, flushedText, visible, promptPosition }`. Safe to call before `activate()` (returns `visible: false`). | ### Options @@ -163,24 +195,154 @@ For scenarios where text has been sent to the PTY but the echo hasn't arrived ye interface ZerolagInputOptions { prompt?: PromptFinder; // Default: { type: 'character', char: '>', offset: 2 } zIndex?: number; // Default: 7 - backgroundColor?: string; // Default: from terminal theme - foregroundColor?: string; // Default: from terminal theme + backgroundColor?: string; // Default: from terminal theme, then '#0d0d0d' + foregroundColor?: string; // Default: from computed .xterm-rows style, then theme, then '#eeeeee' showCursor?: boolean; // Default: true - cursorColor?: string; // Default: from terminal theme + cursorColor?: string; // Default: from terminal theme cursor, then '#e0e0e0' scrollDebounceMs?: number; // Default: 50 } ``` +## Integration Patterns + +### Buffered Input (hold until Enter) + +The quick start example above uses buffered mode — characters accumulate in the overlay and are sent on Enter. This is common for remote shells where you want to batch input. + +### Char-at-a-Time (send immediately) + +For applications that need each keystroke sent immediately: + +```typescript +terminal.onData((data) => { + if (data === '\r') { + zerolag.clear(); + ws.send('\r'); + } else if (data === '\x7f') { + zerolag.removeChar(); // always 'pending' since nothing is buffered + ws.send(data); + } else if (data.length === 1 && data.charCodeAt(0) >= 32) { + zerolag.addChar(data); + ws.send(data); + // The overlay shows the char immediately; the PTY echo will arrive later + // and the overlay continues showing until the next rerender() + } +}); +``` + +### Tab Switching (multi-session) + +When your app has multiple terminal sessions in tabs: + +```typescript +function switchToSession(newSessionId: string) { + // 1. Save current overlay state + const pending = zerolag.pendingText; + const { count, text } = zerolag.getFlushed(); + if (pending) { + sendToPty(currentSessionId, pending); // flush unsent text to PTY + } + const totalCount = count + pending.length; + const totalText = text + pending; + savedFlushed.set(currentSessionId, { count: totalCount, text: totalText }); + zerolag.clear(); + + // 2. Switch terminal buffer to new session + loadSessionBuffer(newSessionId); + + // 3. Restore overlay state for new session + const saved = savedFlushed.get(newSessionId); + if (saved) { + zerolag.suppressBufferDetection(); // prevent false detection of UI text + zerolag.setFlushed(saved.count, saved.text); + } else { + zerolag.suppressBufferDetection(); // fresh session, no flushed state + } + + // 4. Re-render after buffer loads + terminal.write('', () => { + zerolag.rerender(); // finds prompt, positions overlay correctly + }); +} +``` + +### Tab Completion Detection + +After sending a Tab key to the PTY, you can detect whether the shell completed text: + +```typescript +// Before Tab: snapshot baseline +const baseline = zerolag.readPromptText(); +zerolag.clear(); +sendToPty('\t'); + +// After PTY response arrives: +zerolag.resetBufferDetection(); +const detected = zerolag.detectBufferText(); +if (detected && detected !== baseline) { + // Tab completion occurred — overlay now shows the completed text +} +``` + +### Full-Screen TUI Frameworks (Ink, Blessed) + +Frameworks like Ink redraw the entire screen on state changes, which can move the prompt. Re-render the overlay after each terminal write: + +```typescript +terminal.onWriteParsed(() => { + if (zerolag.hasPending) zerolag.rerender(); +}); +``` + +### SSE/WebSocket Reconnect + +After a connection drop and reconnect, the terminal buffer reloads. Preserve overlay text across reconnects: + +```typescript +function onReconnect() { + // Buffer reloaded — re-render overlay at new prompt position + zerolag.rerender(); +} +``` + +### Terminal Resize + +After the terminal is resized (columns/rows change), cell dimensions change: + +```typescript +fitAddon.fit(); +zerolag.rerender(); // recalculates cell dimensions and prompt position +``` + +### Font Size / Theme Changes + +```typescript +terminal.options.fontSize = 18; +zerolag.refreshFont(); // re-caches font properties, re-renders +``` + ## How It Works -1. A `
` overlay is inserted into xterm.js's `.xterm-screen` element at z-index 7 -2. Each character is rendered as an absolutely-positioned `` on the terminal's cell grid -3. Cell dimensions are read from xterm.js's render service (private API on v5, public on v7+) -4. Font properties (family, size, weight, letter-spacing) are cached from the terminal's computed styles -5. The overlay is hidden when the user scrolls away from the bottom of the terminal -6. A render cache (`renderKey`) prevents redundant DOM rebuilds at 60fps +### Architecture -### xterm.js DOM Structure +``` +User types 'a' addChar('a') DOM overlay Instant feedback + │ │ │ │ + ▼ ▼ ▼ ▼ + Keyboard ─────► ZerolagAddon ─────► at ─────► User sees 'a' + │ grid pos immediately + │ + │ (meanwhile, 200ms later...) + │ + ▼ + PTY echo 'a' ─────► xterm.js canvas ─────► Canvas shows 'a' + (overlay still on top, + cleared on next clear()) +``` + +### DOM Overlay Positioning + +The overlay is a `
` inserted into xterm.js's `.xterm-screen` element: ``` div.xterm-screen (position: relative) @@ -188,19 +350,62 @@ div.xterm-screen (position: relative) ├── div.xterm-rows (z-index: auto) ├── div.xterm-selection (z-index: 1) ├── div.xterm-decoration-container (z-index: 6-7) - └── div.zerolag-overlay (z-index: 7) ← our overlay + └── div[zerolag overlay] (z-index: 7) ← our overlay ``` +Each character is rendered as an absolutely-positioned `` on the terminal's cell grid: + +``` +left = charIndex * cellWidth (CSS pixels) +top = lineIndex * cellHeight (CSS pixels) +width = cellWidth (one cell per character) +``` + +Cell dimensions are read from xterm.js: +- **v5.x**: `terminal._core._renderService.dimensions.css.cell` (private API) +- **v7+**: `terminal.dimensions.css.cell` (public API, auto-detected) + +### Font Matching + +The overlay matches the terminal's font rendering by: +1. Caching `fontFamily`, `fontSize`, `fontWeight` from `terminal.options` +2. Reading `letterSpacing` from the computed style of `.xterm-rows` +3. Applying `-webkit-font-smoothing: antialiased` (matches canvas grayscale rendering) +4. Disabling ligatures via `font-feature-settings: 'liga' 0, 'calt' 0` +5. Using `text-rendering: geometricPrecision` for consistent glyph sizing + +### Render Cache + +A render key based on `displayText:startCol:row:col:totalCols:flushedOffset` prevents redundant DOM rebuilds. The cache is cleared by `rerender()`, `refreshFont()`, and internally on state changes that affect the display. + +### Scroll Awareness + +The overlay hides when the user scrolls away from the bottom of the terminal (where the prompt lives). A scroll listener on `.xterm-viewport` detects `viewportY !== baseY` and hides the overlay. When the user scrolls back to the bottom, a debounced re-render (50ms default) repositions the overlay. + +### Prompt Column Locking + +When flushed text exists, the prompt column is locked to prevent visual jitter from full-screen redraws that temporarily shift the prompt marker. The row is allowed to change (output scrolls the prompt down), but the column stays fixed until the flushed state is cleared. + +### Text Wrapping + +Long input that exceeds the terminal width is wrapped at column boundaries: +- Line 0: `(totalCols - startCol)` characters (starts after prompt) +- Line 1+: `totalCols` characters (starts at column 0) + +This matches xterm.js's character-level wrapping behavior. + ## Compatibility -- **xterm.js v5.x**: Uses private API `terminal._core._renderService.dimensions` for cell sizing -- **xterm.js v7+** (future): Will automatically use public `terminal.dimensions` API +- **xterm.js v5.x**: Uses private API `terminal._core._renderService.dimensions` for cell sizing. Fully supported. +- **xterm.js v7+** (future): Will automatically use public `terminal.dimensions` API when available. +- **Renderers**: Best results with the canvas/WebGL renderer. DOM renderer works but the overlay is redundant since DOM text is already positioned identically. ## Known Limitations -- **Canvas/WebGL renderer**: Minor sub-pixel font differences between DOM overlay and canvas text are possible. Best results with the DOM renderer. -- **Unicode/emoji**: Multi-byte characters (emoji, CJK) are not echoed (they have varying cell widths that are hard to predict client-side). -- **Misprediction**: If the server processes input differently than expected (e.g., password prompts that don't echo), the overlay will show characters that aren't actually there. Call `clear()` when you detect such cases. +- **Canvas/WebGL font mismatch**: Minor sub-pixel differences between DOM overlay text and canvas-rendered text are possible. The per-character absolute positioning minimizes this, but it's not pixel-identical on all platforms. +- **Unicode/emoji**: Multi-byte characters (emoji, CJK) are not reliably echoed — they occupy variable cell widths that can't be predicted client-side. The overlay renders them at single-cell width, causing misalignment. +- **Misprediction**: If the server processes input differently than expected (e.g., password prompts that suppress echo), the overlay shows characters that aren't actually displayed. Call `clear()` when you detect such cases. +- **Prompt character in output**: If the prompt character appears in command output (e.g., `$` in a log message), the overlay may position at the wrong location. Use a more specific regex or custom finder to avoid this. ## License diff --git a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts index 283eeec9..079b53e4 100644 --- a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts +++ b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts @@ -347,6 +347,20 @@ export class ZerolagInputAddon implements XtermAddon { 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 utilities ───────────────────────────────────────────── /** 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 eb63b3c1..894972f6 100644 --- a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts +++ b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts @@ -261,6 +261,45 @@ describe('ZerolagInputAddon', () => { 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('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(); @@ -325,5 +364,129 @@ describe('ZerolagInputAddon', () => { 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); + 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('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('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' + }); }); });