mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
feat: add xterm-zerolag-input standalone library
Extract Claudeman's local echo overlay into a reusable xterm.js addon at packages/xterm-zerolag-input/. Provides instant keystroke feedback via a DOM overlay, eliminating perceived input latency over high-RTT connections (SSH, mobile, cloud IDEs). - Zero dependencies, compatible with xterm v5.x and @xterm/xterm v5.4+ - Configurable prompt detection (character, regex, or custom function) - Flushed text tracking for tab-switch / deferred echo scenarios - Per-character grid-aligned rendering matching xterm's canvas output - 56 tests passing (prompt finder, overlay renderer, full addon lifecycle) - Dual CJS/ESM build with full TypeScript declarations Claudeman source is unchanged — migration to consume this lib is a separate follow-up. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Mock terminal factory for unit tests.
|
||||
*
|
||||
* Creates a minimal Terminal-like object that satisfies the addon's
|
||||
* requirements without needing a real xterm.js instance or DOM renderer.
|
||||
*/
|
||||
|
||||
interface MockLine {
|
||||
translateToString(_trimRight?: boolean): string;
|
||||
}
|
||||
|
||||
interface MockBufferOptions {
|
||||
lines: string[];
|
||||
viewportY?: number;
|
||||
baseY?: number;
|
||||
cursorX?: number;
|
||||
cursorY?: number;
|
||||
}
|
||||
|
||||
interface MockTerminalOptions {
|
||||
buffer?: MockBufferOptions;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
fontWeight?: string | number;
|
||||
theme?: {
|
||||
background?: string;
|
||||
foreground?: string;
|
||||
cursor?: string;
|
||||
};
|
||||
cellWidth?: number;
|
||||
cellHeight?: number;
|
||||
}
|
||||
|
||||
export function createMockTerminal(opts: MockTerminalOptions = {}) {
|
||||
const bufOpts = opts.buffer ?? { lines: ['$ '] };
|
||||
const lines = bufOpts.lines;
|
||||
const viewportY = bufOpts.viewportY ?? 0;
|
||||
const baseY = bufOpts.baseY ?? viewportY;
|
||||
const cols = opts.cols ?? 80;
|
||||
const rows = opts.rows ?? Math.max(lines.length, 24);
|
||||
const cellW = opts.cellWidth ?? 8.4;
|
||||
const cellH = opts.cellHeight ?? 17;
|
||||
|
||||
const mockLines: MockLine[] = lines.map((text) => ({
|
||||
translateToString: () => text,
|
||||
}));
|
||||
|
||||
// Create minimal DOM structure
|
||||
const element = document.createElement('div');
|
||||
element.className = 'terminal xterm';
|
||||
|
||||
const viewport = document.createElement('div');
|
||||
viewport.className = 'xterm-viewport';
|
||||
|
||||
const screen = document.createElement('div');
|
||||
screen.className = 'xterm-screen';
|
||||
screen.style.position = 'relative';
|
||||
|
||||
const xtermRows = document.createElement('div');
|
||||
xtermRows.className = 'xterm-rows';
|
||||
|
||||
element.appendChild(viewport);
|
||||
element.appendChild(screen);
|
||||
screen.appendChild(xtermRows);
|
||||
|
||||
// Append to document so getComputedStyle works
|
||||
document.body.appendChild(element);
|
||||
|
||||
const terminal = {
|
||||
element,
|
||||
cols,
|
||||
rows,
|
||||
options: {
|
||||
fontFamily: opts.fontFamily ?? 'monospace',
|
||||
fontSize: opts.fontSize ?? 14,
|
||||
fontWeight: opts.fontWeight ?? 'normal',
|
||||
theme: opts.theme ?? {},
|
||||
},
|
||||
buffer: {
|
||||
active: {
|
||||
viewportY,
|
||||
baseY,
|
||||
cursorX: bufOpts.cursorX ?? 0,
|
||||
cursorY: bufOpts.cursorY ?? 0,
|
||||
getLine: (absRow: number): MockLine | undefined => {
|
||||
return mockLines[absRow - viewportY];
|
||||
},
|
||||
},
|
||||
},
|
||||
_core: {
|
||||
_renderService: {
|
||||
dimensions: {
|
||||
css: {
|
||||
cell: { width: cellW, height: cellH },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Simulate loadAddon
|
||||
loadAddon(addon: { activate: (t: unknown) => void }) {
|
||||
addon.activate(this);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
terminal,
|
||||
/** Update buffer lines for subsequent calls */
|
||||
setLines(newLines: string[]) {
|
||||
mockLines.length = 0;
|
||||
for (const text of newLines) {
|
||||
mockLines.push({ translateToString: () => text });
|
||||
}
|
||||
},
|
||||
/** Clean up DOM */
|
||||
cleanup() {
|
||||
element.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderOverlay } 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: '',
|
||||
};
|
||||
|
||||
function makeParams(overrides: Partial<RenderParams> = {}): RenderParams {
|
||||
return {
|
||||
lines: ['hello'],
|
||||
startCol: 2,
|
||||
totalCols: 80,
|
||||
cellW: 8.4,
|
||||
cellH: 17,
|
||||
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('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);
|
||||
|
||||
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 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');
|
||||
});
|
||||
|
||||
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 }));
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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',
|
||||
}));
|
||||
|
||||
// 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('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);
|
||||
|
||||
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'
|
||||
});
|
||||
|
||||
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)
|
||||
|
||||
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('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createMockTerminal } from './helpers.js';
|
||||
import { findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js';
|
||||
import type { XtermTerminal, PromptFinder } from '../src/types.js';
|
||||
|
||||
function term(lines: string[]) {
|
||||
return createMockTerminal({ buffer: { lines } });
|
||||
}
|
||||
|
||||
describe('findPrompt', () => {
|
||||
describe('character strategy', () => {
|
||||
it('finds $ prompt at column 0', () => {
|
||||
const { terminal, cleanup } = term(['output line', '$ ls -la']);
|
||||
const finder: PromptFinder = { type: 'character', char: '$' };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toEqual({ row: 1, col: 0 });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('finds > prompt', () => {
|
||||
const { terminal, cleanup } = term(['> hello']);
|
||||
const finder: PromptFinder = { type: 'character', char: '>' };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toEqual({ row: 0, col: 0 });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('finds prompt with prefix (user@host)', () => {
|
||||
const { terminal, cleanup } = term(['user@host:~$ command']);
|
||||
const finder: PromptFinder = { type: 'character', char: '$' };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toEqual({ row: 0, col: 11 });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('scans bottom-up and returns lowest match', () => {
|
||||
const { terminal, cleanup } = term([
|
||||
'$ old prompt',
|
||||
'output',
|
||||
'$ current prompt',
|
||||
]);
|
||||
const finder: PromptFinder = { type: 'character', char: '$' };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toEqual({ row: 2, col: 0 });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('returns null when no prompt found', () => {
|
||||
const { terminal, cleanup } = term(['no prompt here', 'or here']);
|
||||
const finder: PromptFinder = { type: 'character', char: '$' };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toBeNull();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('finds Unicode prompt character', () => {
|
||||
const { terminal, cleanup } = term(['\u276f hello']);
|
||||
const finder: PromptFinder = { type: 'character', char: '\u276f' };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toEqual({ row: 0, col: 0 });
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe('regex strategy', () => {
|
||||
it('finds regex prompt', () => {
|
||||
const { terminal, cleanup } = term(['user@host:~/dir$ ls']);
|
||||
const finder: PromptFinder = { type: 'regex', pattern: /\$/ };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).not.toBeNull();
|
||||
expect(pos!.col).toBe(15);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('matches complex PS1 patterns', () => {
|
||||
const { terminal, cleanup } = term(['(venv) user % cmd']);
|
||||
const finder: PromptFinder = { type: 'regex', pattern: /%/ };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).not.toBeNull();
|
||||
expect(pos!.col).toBe(12);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('returns null on no match', () => {
|
||||
const { terminal, cleanup } = term(['just output']);
|
||||
const finder: PromptFinder = { type: 'regex', pattern: /\$\s*$/ };
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toBeNull();
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom strategy', () => {
|
||||
it('uses custom finder function', () => {
|
||||
const { terminal, cleanup } = term(['anything']);
|
||||
const finder: PromptFinder = {
|
||||
type: 'custom',
|
||||
find: () => ({ row: 5, col: 10 }),
|
||||
};
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toEqual({ row: 5, col: 10 });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('handles null from custom finder', () => {
|
||||
const { terminal, cleanup } = term(['anything']);
|
||||
const finder: PromptFinder = {
|
||||
type: 'custom',
|
||||
find: () => null,
|
||||
};
|
||||
const pos = findPrompt(terminal as unknown as XtermTerminal, finder);
|
||||
expect(pos).toBeNull();
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('readTextAfterPrompt', () => {
|
||||
it('reads text after prompt with offset', () => {
|
||||
const { terminal, cleanup } = term(['$ hello world']);
|
||||
const prompt = { row: 0, col: 0 };
|
||||
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
|
||||
expect(text).toBe('hello world');
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('returns empty string for empty prompt line', () => {
|
||||
const { terminal, cleanup } = term(['$ ']);
|
||||
const prompt = { row: 0, col: 0 };
|
||||
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
|
||||
expect(text).toBe('');
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('trims trailing whitespace', () => {
|
||||
const { terminal, cleanup } = term(['$ hello ']);
|
||||
const prompt = { row: 0, col: 0 };
|
||||
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
|
||||
expect(text).toBe('hello');
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('handles offset for complex prompts', () => {
|
||||
const { terminal, cleanup } = term(['user@host:~$ ls -la']);
|
||||
const prompt = { row: 0, col: 11 };
|
||||
const text = readTextAfterPrompt(terminal as unknown as XtermTerminal, prompt, 2);
|
||||
expect(text).toBe('ls -la');
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { createMockTerminal } from './helpers.js';
|
||||
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
|
||||
import type { Terminal } from '../src/types.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 };
|
||||
}
|
||||
|
||||
let cleanups: (() => void)[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
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;
|
||||
}
|
||||
|
||||
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('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('hasPending is true when text exists', () => {
|
||||
const { addon } = tracked();
|
||||
expect(addon.hasPending).toBe(false);
|
||||
addon.addChar('x');
|
||||
expect(addon.hasPending).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendText', () => {
|
||||
it('appends multiple characters (paste)', () => {
|
||||
const { addon } = tracked();
|
||||
addon.addChar('h');
|
||||
addon.appendText('ello');
|
||||
expect(addon.pendingText).toBe('hello');
|
||||
});
|
||||
|
||||
it('ignores empty string', () => {
|
||||
const { addon } = tracked();
|
||||
addon.appendText('');
|
||||
expect(addon.pendingText).toBe('');
|
||||
expect(addon.hasPending).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeChar', () => {
|
||||
it('removes last character from pendingText', () => {
|
||||
const { addon } = tracked();
|
||||
addon.addChar('a');
|
||||
addon.addChar('b');
|
||||
const removed = addon.removeChar();
|
||||
expect(removed).toBe(true);
|
||||
expect(addon.pendingText).toBe('a');
|
||||
});
|
||||
|
||||
it('returns false when nothing to remove', () => {
|
||||
const { addon } = tracked();
|
||||
expect(addon.removeChar()).toBe(false);
|
||||
});
|
||||
|
||||
it('decrements flushed when pending is empty', () => {
|
||||
const { addon } = tracked();
|
||||
addon.setFlushed(3, 'abc');
|
||||
expect(addon.removeChar()).toBe(true);
|
||||
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');
|
||||
expect(addon.removeChar()).toBe(true);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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('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);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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('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('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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user