fix: address 7 issues found in xterm-zerolag-input audit

1. Regex global flag safety — strip `g` flag before matching to prevent
   lastIndex mutation and missing .index on match results
2. state.visible false before activate — null overlay no longer reports
   visible: true
3. removeChar() returns 'pending' | 'flushed' | false instead of boolean
   so consumers can distinguish whether to send backspace to PTY
4. removeChar() implements buffer detection cascade (step 3) — detects
   existing prompt text when both pending and flushed are empty
5. RenderKey includes text content, not just length — prevents stale
   renders when setFlushed() called with same count but different text
6. Remove dead `import type { Terminal }` from test file
7. Remove internal `FontStyle` from public exports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-22 10:24:03 +01:00
co-authored by Claude Opus 4.6
parent d14ac9de65
commit 53132e3a38
6 changed files with 94 additions and 24 deletions
@@ -7,5 +7,4 @@ export type {
PromptFinder,
PromptPosition,
CellDimensions,
FontStyle,
} from './types.js';
@@ -27,11 +27,17 @@ export function findPrompt(
}
case 'regex': {
// Create a fresh non-global regex to avoid lastIndex mutation
// and ensure .match() returns a single result with .index
const pattern = finder.pattern;
const safePattern = pattern.global
? new RegExp(pattern.source, pattern.flags.replace('g', ''))
: pattern;
for (let row = terminal.rows - 1; row >= 0; row--) {
const line = buffer.getLine(viewportTop + row);
if (!line) continue;
const text = line.translateToString(true);
const match = text.match(finder.pattern);
const match = text.match(safePattern);
if (match) {
const col = match.index ?? 0;
return { row, col };
@@ -48,7 +48,8 @@ const DEFAULT_CURSOR = '#e0e0e0';
* zerolag.clear();
* ws.send(text + '\r');
* } else if (data === '\x7f') {
* if (zerolag.removeChar()) ws.send(data);
* const source = zerolag.removeChar();
* if (source === 'flushed') ws.send(data); // only send if text was already in PTY
* } else if (data.length === 1 && data.charCodeAt(0) >= 32) {
* zerolag.addChar(data);
* }
@@ -194,14 +195,19 @@ export class ZerolagInputAddon implements XtermAddon {
* Remove the last character from the overlay.
*
* Cascade order:
* 1. Remove from `pendingText` if non-empty
* 2. Decrement `flushedOffset` if pending is empty but flushed exists
* 3. Try `detectBufferText()` if both are empty
* 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 `true` if a character was removed, `false` if nothing to remove.
* When `false`, the consumer should NOT send backspace to the PTY.
* @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(): boolean {
removeChar(): 'pending' | 'flushed' | false {
if (this._pendingText.length > 0) {
this._pendingText = this._pendingText.slice(0, -1);
if (this._pendingText.length > 0 || this._flushedOffset > 0) {
@@ -209,7 +215,7 @@ export class ZerolagInputAddon implements XtermAddon {
} else {
this._hide();
}
return true;
return 'pending';
}
if (this._flushedOffset > 0) {
@@ -220,7 +226,21 @@ export class ZerolagInputAddon implements XtermAddon {
} else {
this._hide();
}
return true;
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;
@@ -369,7 +389,7 @@ export class ZerolagInputAddon implements XtermAddon {
pendingText: this._pendingText,
flushedLength: this._flushedOffset,
flushedText: this._flushedText,
visible: this._overlay?.style.display !== 'none',
visible: this._overlay !== null && this._overlay.style.display !== 'none',
promptPosition: this._lastPromptPos ? { ...this._lastPromptPos } : null,
};
}
@@ -500,8 +520,9 @@ export class ZerolagInputAddon implements XtermAddon {
}
}
// Skip redundant re-renders
const renderKey = `${displayText.length}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._flushedOffset}`;
// 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;