mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12: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,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Claudeman Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,207 @@
|
||||
# xterm-zerolag-input
|
||||
|
||||
Instant keystroke feedback overlay for [xterm.js](https://xtermjs.org/) — eliminates perceived input latency over high-RTT connections.
|
||||
|
||||
## The Problem
|
||||
|
||||
When using xterm.js over a remote connection (SSH web clients, cloud IDEs, mobile terminals), every keystroke takes a full round-trip to the server before appearing on screen. At 100-500ms RTT, typing feels sluggish and unresponsive.
|
||||
|
||||
## The Solution
|
||||
|
||||
`xterm-zerolag-input` renders typed characters **immediately** as a pixel-perfect DOM overlay positioned on the terminal's character grid. The overlay covers the terminal canvas at the prompt location, showing characters instantly while the server echo travels back. Once the server responds, the overlay is cleared and the real terminal output takes over.
|
||||
|
||||
**No changes to your backend needed.** The addon is purely client-side.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install xterm-zerolag-input
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { ZerolagInputAddon } from 'xterm-zerolag-input';
|
||||
|
||||
const terminal = new Terminal();
|
||||
terminal.open(document.getElementById('terminal')!);
|
||||
|
||||
// 1. Create addon with your prompt character
|
||||
const zerolag = new ZerolagInputAddon({
|
||||
prompt: { type: 'character', char: '$', offset: 2 },
|
||||
});
|
||||
terminal.loadAddon(zerolag);
|
||||
|
||||
// 2. Wire your input handler
|
||||
terminal.onData((data) => {
|
||||
if (data === '\r') {
|
||||
const text = zerolag.pendingText;
|
||||
zerolag.clear();
|
||||
ws.send(text + '\r');
|
||||
} else if (data === '\x7f') {
|
||||
const removed = zerolag.removeChar();
|
||||
if (removed) ws.send(data);
|
||||
} 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)
|
||||
terminal.onWriteParsed(() => {
|
||||
if (zerolag.hasPending) zerolag.rerender();
|
||||
});
|
||||
```
|
||||
|
||||
## Prompt Detection
|
||||
|
||||
The addon needs to know where user input starts on the terminal line. Three strategies are supported:
|
||||
|
||||
### Character (default)
|
||||
Scans bottom-up for a single character:
|
||||
|
||||
```typescript
|
||||
// Bash: user@host:~$
|
||||
{ type: 'character', char: '$', offset: 2 }
|
||||
|
||||
// Zsh: user@host ~ %
|
||||
{ type: 'character', char: '%', offset: 2 }
|
||||
|
||||
// Fish / Starship: ❯
|
||||
{ type: 'character', char: '\u276f', offset: 2 }
|
||||
|
||||
// Simple arrow: >
|
||||
{ type: 'character', char: '>', offset: 2 }
|
||||
```
|
||||
|
||||
The `offset` is how many characters after the marker the input begins (e.g., `"$ "` = 2).
|
||||
|
||||
### Regex
|
||||
For complex prompts:
|
||||
|
||||
```typescript
|
||||
// Match end-of-prompt patterns
|
||||
{ type: 'regex', pattern: /\$\s*$/, offset: 2 }
|
||||
|
||||
// Match specific prompt format
|
||||
{ type: 'regex', pattern: /\(venv\)\s+\w+\s+%/, offset: 2 }
|
||||
```
|
||||
|
||||
### Custom
|
||||
Full control:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'custom',
|
||||
offset: 0,
|
||||
find: (terminal) => {
|
||||
// Your logic here — return { row, col } or null
|
||||
return { row: terminal.rows - 1, col: 0 };
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `ZerolagInputAddon`
|
||||
|
||||
Implements xterm.js `ITerminalAddon`. Load via `terminal.loadAddon(addon)`.
|
||||
|
||||
#### Input Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `addChar(char)` | Add a single printable character to the overlay |
|
||||
| `appendText(text)` | Append multiple characters (e.g., paste) |
|
||||
| `removeChar(): boolean` | Remove last char. Returns `false` if nothing to remove |
|
||||
| `clear()` | Clear all state and hide overlay |
|
||||
|
||||
#### 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):
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `setFlushed(count, text)` | Mark characters as sent-but-unacknowledged |
|
||||
| `getFlushed()` | Get `{ count, text }` of flushed state |
|
||||
| `clearFlushed()` | Clear flushed state (echo arrived) |
|
||||
|
||||
#### 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()`) |
|
||||
|
||||
#### Prompt Utilities
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `findPrompt()` | Find prompt position using configured strategy |
|
||||
| `readPromptText()` | Read text after prompt marker |
|
||||
|
||||
#### State
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `pendingText` | `string` | Characters typed but not acknowledged |
|
||||
| `hasPending` | `boolean` | Whether overlay has any content |
|
||||
| `state` | `ZerolagInputState` | Full read-only state snapshot |
|
||||
|
||||
### Options
|
||||
|
||||
```typescript
|
||||
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
|
||||
showCursor?: boolean; // Default: true
|
||||
cursorColor?: string; // Default: from terminal theme
|
||||
scrollDebounceMs?: number; // Default: 50
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. A `<div>` overlay is inserted into xterm.js's `.xterm-screen` element at z-index 7
|
||||
2. Each character is rendered as an absolutely-positioned `<span>` 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
|
||||
|
||||
### xterm.js DOM Structure
|
||||
|
||||
```
|
||||
div.xterm-screen (position: relative)
|
||||
├── div.xterm-helpers (z-index: 5)
|
||||
├── 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
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
## 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.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,210 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>xterm-zerolag-input Demo</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
background: #1a1a2e;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
font-family: system-ui, sans-serif;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
h1 { margin-bottom: 0.5rem; }
|
||||
p { margin: 0.25rem 0 1rem; opacity: 0.7; font-size: 0.9rem; }
|
||||
#terminal-container {
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.info {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #16213e;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
max-width: 600px;
|
||||
}
|
||||
.info code {
|
||||
background: #0f3460;
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>xterm-zerolag-input</h1>
|
||||
<p>Type below — characters appear instantly via DOM overlay (simulated 500ms RTT)</p>
|
||||
<div id="terminal-container"></div>
|
||||
<div class="info">
|
||||
<strong>What's happening:</strong> Each keystroke is rendered immediately as a DOM overlay
|
||||
positioned on the exact terminal grid. The simulated server echo arrives 500ms later.
|
||||
Without this addon, you'd see a half-second delay on every keystroke.
|
||||
<br><br>
|
||||
Try typing a sentence and pressing Enter. The overlay clears and the "server"
|
||||
processes your input.
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
// In a real app you'd import from node_modules:
|
||||
// import { Terminal } from '@xterm/xterm';
|
||||
// import { ZerolagInputAddon } from 'xterm-zerolag-input';
|
||||
//
|
||||
// For this demo we use CDN + inline addon class simulation.
|
||||
|
||||
// Load xterm.js from CDN
|
||||
const xtermScript = document.createElement('script');
|
||||
xtermScript.src = 'https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js';
|
||||
document.head.appendChild(xtermScript);
|
||||
|
||||
xtermScript.onload = () => {
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: '"Fira Code", "Cascadia Code", monospace',
|
||||
theme: {
|
||||
background: '#0d1117',
|
||||
foreground: '#c9d1d9',
|
||||
cursor: '#58a6ff',
|
||||
},
|
||||
rows: 20,
|
||||
cols: 80,
|
||||
});
|
||||
|
||||
terminal.open(document.getElementById('terminal-container'));
|
||||
|
||||
// Print welcome message and prompt
|
||||
terminal.write('Welcome to the xterm-zerolag-input demo!\r\n');
|
||||
terminal.write('Simulated RTT: 500ms. Type anything and press Enter.\r\n\r\n');
|
||||
terminal.write('$ ');
|
||||
|
||||
// --- In a real app, you'd use the npm package ---
|
||||
// For this demo, we simulate the addon behavior inline
|
||||
// since we can't import the built package from a static HTML file.
|
||||
|
||||
let pendingText = '';
|
||||
let overlay = null;
|
||||
|
||||
// Create overlay element (this is what the addon does internally)
|
||||
const screen = terminal.element.querySelector('.xterm-screen');
|
||||
overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:absolute;z-index:7;pointer-events:none;display:none';
|
||||
screen.appendChild(overlay);
|
||||
|
||||
function findPromptRow() {
|
||||
const buf = terminal.buffer.active;
|
||||
for (let row = terminal.rows - 1; row >= 0; row--) {
|
||||
const line = buf.getLine(buf.viewportY + row);
|
||||
if (!line) continue;
|
||||
const text = line.translateToString(true);
|
||||
if (text.lastIndexOf('$') >= 0) {
|
||||
return { row, col: text.lastIndexOf('$') };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderOverlay() {
|
||||
if (!pendingText) {
|
||||
overlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const prompt = findPromptRow();
|
||||
if (!prompt) return;
|
||||
|
||||
const dims = terminal._core._renderService.dimensions;
|
||||
const cellW = dims.css.cell.width;
|
||||
const cellH = dims.css.cell.height;
|
||||
const startCol = prompt.col + 2; // after "$ "
|
||||
|
||||
overlay.style.left = '0px';
|
||||
overlay.style.top = (prompt.row * cellH) + 'px';
|
||||
overlay.innerHTML = '';
|
||||
|
||||
// Line div
|
||||
const lineDiv = document.createElement('div');
|
||||
lineDiv.style.cssText = 'position:absolute;pointer-events:none';
|
||||
lineDiv.style.backgroundColor = '#0d1117';
|
||||
lineDiv.style.left = (startCol * cellW) + 'px';
|
||||
lineDiv.style.top = '0px';
|
||||
lineDiv.style.width = ((terminal.cols - startCol) * cellW) + 'px';
|
||||
lineDiv.style.height = (cellH + 1) + 'px';
|
||||
lineDiv.style.lineHeight = cellH + 'px';
|
||||
|
||||
for (let i = 0; i < pendingText.length; i++) {
|
||||
const span = document.createElement('span');
|
||||
span.style.cssText = "position:absolute;display:inline-block;text-align:center;-webkit-font-smoothing:antialiased;text-rendering:geometricPrecision;font-feature-settings:'liga' 0,'calt' 0";
|
||||
span.style.left = (i * cellW) + 'px';
|
||||
span.style.width = cellW + 'px';
|
||||
span.style.fontFamily = terminal.options.fontFamily;
|
||||
span.style.fontSize = terminal.options.fontSize + 'px';
|
||||
span.style.color = '#c9d1d9';
|
||||
span.textContent = pendingText[i];
|
||||
lineDiv.appendChild(span);
|
||||
}
|
||||
overlay.appendChild(lineDiv);
|
||||
|
||||
// Cursor
|
||||
const cursor = document.createElement('span');
|
||||
cursor.style.cssText = 'position:absolute;display:inline-block';
|
||||
cursor.style.left = ((startCol + pendingText.length) * cellW) + 'px';
|
||||
cursor.style.top = '0px';
|
||||
cursor.style.width = cellW + 'px';
|
||||
cursor.style.height = cellH + 'px';
|
||||
cursor.style.backgroundColor = '#58a6ff';
|
||||
overlay.appendChild(cursor);
|
||||
|
||||
overlay.style.display = '';
|
||||
}
|
||||
|
||||
// Simulated 500ms RTT
|
||||
const SIMULATED_RTT = 500;
|
||||
|
||||
terminal.onData((data) => {
|
||||
if (data === '\r') {
|
||||
// Enter: clear overlay, send to "server"
|
||||
const text = pendingText;
|
||||
pendingText = '';
|
||||
overlay.style.display = 'none';
|
||||
overlay.innerHTML = '';
|
||||
|
||||
// Simulate server processing
|
||||
setTimeout(() => {
|
||||
terminal.write('\r\n');
|
||||
if (text.trim()) {
|
||||
terminal.write(`echo: ${text}\r\n`);
|
||||
}
|
||||
terminal.write('$ ');
|
||||
}, SIMULATED_RTT);
|
||||
} else if (data === '\x7f') {
|
||||
// Backspace
|
||||
if (pendingText.length > 0) {
|
||||
pendingText = pendingText.slice(0, -1);
|
||||
renderOverlay();
|
||||
// Also send to server (delayed)
|
||||
setTimeout(() => {
|
||||
terminal.write('\b \b');
|
||||
}, SIMULATED_RTT);
|
||||
}
|
||||
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
// Printable character — instant overlay
|
||||
pendingText += data;
|
||||
renderOverlay();
|
||||
|
||||
// Simulate server echo arriving later
|
||||
const char = data;
|
||||
setTimeout(() => {
|
||||
terminal.write(char);
|
||||
}, SIMULATED_RTT);
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "xterm-zerolag-input",
|
||||
"version": "0.1.0",
|
||||
"description": "Instant keystroke feedback overlay for xterm.js — eliminates perceived input latency over high-RTT connections",
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"xterm",
|
||||
"xterm.js",
|
||||
"terminal",
|
||||
"local-echo",
|
||||
"input-latency",
|
||||
"overlay",
|
||||
"addon"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nicobailon/claudeman",
|
||||
"directory": "packages/xterm-zerolag-input"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jsdom": "^24.1.3",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { XtermTerminal, CellDimensions } from './types.js';
|
||||
|
||||
/**
|
||||
* Get cell dimensions from the terminal, handling xterm.js v5 (private API)
|
||||
* and v7+ (public API).
|
||||
*
|
||||
* Returns `null` if the terminal is not yet rendered or dimensions are
|
||||
* unavailable.
|
||||
*/
|
||||
export function getCellDimensions(terminal: XtermTerminal): CellDimensions | null {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const t = terminal as any;
|
||||
|
||||
// Try v7+ public API first
|
||||
if (t.dimensions?.css?.cell) {
|
||||
return {
|
||||
width: t.dimensions.css.cell.width,
|
||||
height: t.dimensions.css.cell.height,
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to v5 private API
|
||||
try {
|
||||
const dims = t._core?._renderService?.dimensions;
|
||||
if (dims?.css?.cell) {
|
||||
return {
|
||||
width: dims.css.cell.width,
|
||||
height: dims.css.cell.height,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Private API may throw in some environments
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { ZerolagInputAddon } from './zerolag-input-addon.js';
|
||||
export type {
|
||||
XtermTerminal,
|
||||
XtermAddon,
|
||||
ZerolagInputOptions,
|
||||
ZerolagInputState,
|
||||
PromptFinder,
|
||||
PromptPosition,
|
||||
CellDimensions,
|
||||
FontStyle,
|
||||
} from './types.js';
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { RenderParams, FontStyle } from './types.js';
|
||||
|
||||
/**
|
||||
* Render the overlay content into the container element.
|
||||
*
|
||||
* Creates per-character `<span>` elements positioned on an exact grid
|
||||
* matching xterm.js's canvas renderer. This avoids sub-pixel drift that
|
||||
* occurs with normal DOM text flow.
|
||||
*/
|
||||
export function renderOverlay(container: HTMLDivElement, params: RenderParams): void {
|
||||
const { lines, startCol, totalCols, cellW, cellH, promptRow, font, showCursor, cursorColor } = params;
|
||||
|
||||
// Position container at prompt row
|
||||
container.style.left = '0px';
|
||||
container.style.top = (promptRow * cellH) + 'px';
|
||||
|
||||
// Clear and rebuild (typically 1-3 line divs, negligible cost)
|
||||
container.innerHTML = '';
|
||||
const fullWidthPx = totalCols * cellW;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const leftPx = i === 0 ? startCol * cellW : 0;
|
||||
const widthPx = i === 0 ? (fullWidthPx - leftPx) : fullWidthPx;
|
||||
const topPx = i * cellH;
|
||||
const lineEl = makeLine(lines[i], leftPx, topPx, widthPx, cellH, cellW, font);
|
||||
container.appendChild(lineEl);
|
||||
}
|
||||
|
||||
// Block cursor at end of last line
|
||||
if (showCursor) {
|
||||
const lastLine = lines[lines.length - 1];
|
||||
const lastLineLeft = lines.length === 1 ? startCol : 0;
|
||||
const cursorCol = lastLineLeft + lastLine.length;
|
||||
if (cursorCol < totalCols) {
|
||||
const cursor = document.createElement('span');
|
||||
cursor.style.cssText = 'position:absolute;display:inline-block';
|
||||
cursor.style.left = (cursorCol * cellW) + 'px';
|
||||
cursor.style.top = ((lines.length - 1) * cellH) + 'px';
|
||||
cursor.style.width = cellW + 'px';
|
||||
cursor.style.height = cellH + 'px';
|
||||
cursor.style.backgroundColor = cursorColor;
|
||||
container.appendChild(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
container.style.display = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a styled line `<div>` with per-character grid positioning.
|
||||
*
|
||||
* Each character gets its own `<span>` placed at `i * cellW` pixels.
|
||||
* This matches xterm's canvas renderer where each glyph occupies exactly
|
||||
* one cell width, regardless of the actual glyph metrics.
|
||||
*/
|
||||
function makeLine(
|
||||
text: string,
|
||||
leftPx: number,
|
||||
topPx: number,
|
||||
widthPx: number,
|
||||
cellH: number,
|
||||
cellW: number,
|
||||
font: FontStyle,
|
||||
): HTMLDivElement {
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'position:absolute;pointer-events:none';
|
||||
el.style.backgroundColor = font.backgroundColor;
|
||||
el.style.left = leftPx + 'px';
|
||||
el.style.top = topPx + 'px';
|
||||
el.style.width = widthPx + 'px';
|
||||
el.style.height = (cellH + 1) + 'px';
|
||||
el.style.lineHeight = cellH + 'px';
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const span = document.createElement('span');
|
||||
// Match xterm.js canvas text rendering:
|
||||
// - antialiased smoothing (canvas uses grayscale, not LCD subpixel)
|
||||
// - geometricPrecision for consistent glyph sizing
|
||||
// - no ligatures (canvas renders each glyph independently)
|
||||
span.style.cssText =
|
||||
'position:absolute;display:inline-block;text-align:center;pointer-events:none;' +
|
||||
'-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;' +
|
||||
"text-rendering:geometricPrecision;font-feature-settings:'liga' 0,'calt' 0";
|
||||
span.style.left = (i * cellW) + 'px';
|
||||
span.style.width = cellW + 'px';
|
||||
span.style.fontFamily = font.fontFamily;
|
||||
span.style.fontSize = font.fontSize;
|
||||
span.style.fontWeight = font.fontWeight;
|
||||
span.style.color = font.color;
|
||||
if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing;
|
||||
span.textContent = text[i];
|
||||
el.appendChild(span);
|
||||
}
|
||||
|
||||
return el;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { XtermTerminal, PromptFinder, PromptPosition } from './types.js';
|
||||
|
||||
/**
|
||||
* Find the prompt in the terminal buffer using the configured strategy.
|
||||
* Scans bottom-up through the viewport to find the most recent prompt.
|
||||
*
|
||||
* @returns The prompt position (viewport-relative), or `null` if not found.
|
||||
*/
|
||||
export function findPrompt(
|
||||
terminal: XtermTerminal,
|
||||
finder: PromptFinder,
|
||||
): PromptPosition | null {
|
||||
try {
|
||||
const buffer = terminal.buffer.active;
|
||||
const viewportTop = buffer.viewportY;
|
||||
|
||||
switch (finder.type) {
|
||||
case 'character': {
|
||||
for (let row = terminal.rows - 1; row >= 0; row--) {
|
||||
const line = buffer.getLine(viewportTop + row);
|
||||
if (!line) continue;
|
||||
const text = line.translateToString(true);
|
||||
const idx = text.lastIndexOf(finder.char);
|
||||
if (idx >= 0) return { row, col: idx };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'regex': {
|
||||
for (let row = terminal.rows - 1; row >= 0; row--) {
|
||||
const line = buffer.getLine(viewportTop + row);
|
||||
if (!line) continue;
|
||||
const text = line.translateToString(true);
|
||||
const match = text.match(finder.pattern);
|
||||
if (match) {
|
||||
const col = match.index ?? 0;
|
||||
return { row, col };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'custom':
|
||||
return finder.find(terminal);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read text after the prompt position on the same line.
|
||||
*
|
||||
* @param terminal - The xterm.js terminal instance
|
||||
* @param prompt - The prompt position in the viewport
|
||||
* @param offset - Characters to skip after the prompt marker (e.g., 2 for "> ")
|
||||
* @returns The text after the prompt, trimmed. Empty string if nothing found.
|
||||
*/
|
||||
export function readTextAfterPrompt(
|
||||
terminal: XtermTerminal,
|
||||
prompt: PromptPosition,
|
||||
offset: number,
|
||||
): string {
|
||||
try {
|
||||
const buffer = terminal.buffer.active;
|
||||
const absRow = buffer.viewportY + prompt.row;
|
||||
const line = buffer.getLine(absRow);
|
||||
if (!line) return '';
|
||||
const lineText = line.translateToString(true);
|
||||
return lineText.slice(prompt.col + offset).trimEnd();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Minimal terminal interface required by the addon.
|
||||
*
|
||||
* Compatible with both `xterm` (pre-5.4) and `@xterm/xterm` (5.4+).
|
||||
* Consumers pass their real Terminal instance — we only use these properties.
|
||||
*/
|
||||
export interface XtermTerminal {
|
||||
readonly element: HTMLElement | undefined;
|
||||
readonly cols: number;
|
||||
readonly rows: number;
|
||||
readonly options: {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
fontWeight?: string | number;
|
||||
theme?: {
|
||||
background?: string;
|
||||
foreground?: string;
|
||||
cursor?: string;
|
||||
};
|
||||
};
|
||||
readonly buffer: {
|
||||
readonly active: {
|
||||
readonly viewportY: number;
|
||||
readonly baseY: number;
|
||||
getLine(y: number): {
|
||||
translateToString(trimRight?: boolean): string;
|
||||
} | undefined;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal addon interface matching xterm.js ITerminalAddon.
|
||||
*
|
||||
* The consumer calls `terminal.loadAddon(addon)` which invokes `activate()`.
|
||||
*/
|
||||
export interface XtermAddon {
|
||||
activate(terminal: XtermTerminal): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Position of the prompt in the terminal viewport.
|
||||
*/
|
||||
export interface PromptPosition {
|
||||
/** Viewport-relative row (0 = top of viewport) */
|
||||
row: number;
|
||||
/** Column of the prompt marker character */
|
||||
col: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt detection strategy.
|
||||
*
|
||||
* The overlay needs to know where user input starts on the terminal line.
|
||||
* Three strategies are supported:
|
||||
*
|
||||
* - `character`: Scan bottom-up for a specific character (e.g., `$`, `>`, `❯`)
|
||||
* - `regex`: Scan each line with a regex pattern
|
||||
* - `custom`: Full escape hatch — provide your own finder function
|
||||
*/
|
||||
export type PromptFinder =
|
||||
| { type: 'character'; char: string; offset?: number }
|
||||
| { type: 'regex'; pattern: RegExp; offset?: number }
|
||||
| { type: 'custom'; find: (terminal: XtermTerminal) => PromptPosition | null; offset?: number };
|
||||
|
||||
/**
|
||||
* Configuration options for ZerolagInputAddon.
|
||||
*/
|
||||
export interface ZerolagInputOptions {
|
||||
/**
|
||||
* How to find the prompt in the terminal buffer.
|
||||
*
|
||||
* The `offset` controls how many characters after the prompt marker
|
||||
* the user input begins (e.g., `"> "` = offset 2).
|
||||
*
|
||||
* @default { type: 'character', char: '>', offset: 2 }
|
||||
*/
|
||||
prompt?: PromptFinder;
|
||||
|
||||
/**
|
||||
* Z-index for the overlay element.
|
||||
* @default 7
|
||||
*/
|
||||
zIndex?: number;
|
||||
|
||||
/**
|
||||
* Background color for the overlay.
|
||||
* Set to `'transparent'` to disable the opaque background.
|
||||
* @default Read from terminal.options.theme.background
|
||||
*/
|
||||
backgroundColor?: string;
|
||||
|
||||
/**
|
||||
* Foreground color for overlay text.
|
||||
* @default Read from terminal.options.theme.foreground
|
||||
*/
|
||||
foregroundColor?: string;
|
||||
|
||||
/**
|
||||
* Whether to show a block cursor at the end of the overlay text.
|
||||
* @default true
|
||||
*/
|
||||
showCursor?: boolean;
|
||||
|
||||
/**
|
||||
* Cursor color (block cursor at end of text).
|
||||
* @default Read from terminal.options.theme.cursor
|
||||
*/
|
||||
cursorColor?: string;
|
||||
|
||||
/**
|
||||
* Scroll debounce time in ms for re-rendering when user scrolls
|
||||
* back to the bottom of the terminal.
|
||||
* @default 50
|
||||
*/
|
||||
scrollDebounceMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only state snapshot of the overlay.
|
||||
*/
|
||||
export interface ZerolagInputState {
|
||||
/** Characters typed but not yet acknowledged by the server */
|
||||
pendingText: string;
|
||||
/** Number of characters flushed to PTY but echo not yet received */
|
||||
flushedLength: number;
|
||||
/** Text content of the flushed portion */
|
||||
flushedText: string;
|
||||
/** Whether the overlay is currently visible */
|
||||
visible: boolean;
|
||||
/** Last detected prompt position, if any */
|
||||
promptPosition: PromptPosition | null;
|
||||
}
|
||||
|
||||
/** Cell dimensions in CSS pixels. */
|
||||
export interface CellDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Parameters for the overlay renderer. */
|
||||
export interface RenderParams {
|
||||
lines: string[];
|
||||
startCol: number;
|
||||
totalCols: number;
|
||||
cellW: number;
|
||||
cellH: number;
|
||||
promptRow: number;
|
||||
font: FontStyle;
|
||||
showCursor: boolean;
|
||||
cursorColor: string;
|
||||
}
|
||||
|
||||
/** Cached font style properties for overlay rendering. */
|
||||
export interface FontStyle {
|
||||
fontFamily: string;
|
||||
fontSize: string;
|
||||
fontWeight: string;
|
||||
color: string;
|
||||
backgroundColor: string;
|
||||
letterSpacing: string;
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
import type {
|
||||
XtermTerminal,
|
||||
XtermAddon,
|
||||
ZerolagInputOptions,
|
||||
ZerolagInputState,
|
||||
PromptPosition,
|
||||
PromptFinder,
|
||||
FontStyle,
|
||||
} from './types.js';
|
||||
import { getCellDimensions } from './cell-dimensions.js';
|
||||
import { findPrompt, readTextAfterPrompt } from './prompt-finder.js';
|
||||
import { renderOverlay } from './overlay-renderer.js';
|
||||
|
||||
const DEFAULT_PROMPT: PromptFinder = { type: 'character', char: '>', offset: 2 };
|
||||
const DEFAULT_Z_INDEX = 7;
|
||||
const DEFAULT_SCROLL_DEBOUNCE_MS = 50;
|
||||
const DEFAULT_BG = '#0d0d0d';
|
||||
const DEFAULT_FG = '#eeeeee';
|
||||
const DEFAULT_CURSOR = '#e0e0e0';
|
||||
|
||||
/**
|
||||
* xterm.js addon that provides instant keystroke feedback via a DOM overlay.
|
||||
*
|
||||
* Eliminates perceived input latency over high-RTT connections (SSH, remote
|
||||
* terminals, mobile) by rendering typed characters immediately as a DOM
|
||||
* overlay, without waiting for the PTY round-trip.
|
||||
*
|
||||
* The addon does NOT hook `terminal.onData` — the consumer wires their
|
||||
* own input handler and calls `addChar()`, `removeChar()`, `clear()`, etc.
|
||||
*
|
||||
* Compatible with both `xterm` (pre-5.4) and `@xterm/xterm` (5.4+).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { Terminal } from '@xterm/xterm';
|
||||
* import { ZerolagInputAddon } from 'xterm-zerolag-input';
|
||||
*
|
||||
* const terminal = new Terminal();
|
||||
* const zerolag = new ZerolagInputAddon({
|
||||
* prompt: { type: 'character', char: '$', offset: 2 },
|
||||
* });
|
||||
* terminal.open(document.getElementById('terminal')!);
|
||||
* terminal.loadAddon(zerolag);
|
||||
*
|
||||
* terminal.onData((data) => {
|
||||
* if (data === '\r') {
|
||||
* const text = zerolag.pendingText;
|
||||
* zerolag.clear();
|
||||
* ws.send(text + '\r');
|
||||
* } else if (data === '\x7f') {
|
||||
* if (zerolag.removeChar()) ws.send(data);
|
||||
* } else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
* zerolag.addChar(data);
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export class ZerolagInputAddon implements XtermAddon {
|
||||
private _terminal: XtermTerminal | null = null;
|
||||
private _overlay: HTMLDivElement | null = null;
|
||||
private _options: Required<
|
||||
Pick<ZerolagInputOptions, 'zIndex' | 'showCursor' | 'scrollDebounceMs'>
|
||||
> & ZerolagInputOptions;
|
||||
|
||||
// Text state
|
||||
private _pendingText = '';
|
||||
private _flushedOffset = 0;
|
||||
private _flushedText = '';
|
||||
private _bufferDetectDone = false;
|
||||
|
||||
// Render cache
|
||||
private _lastRenderKey = '';
|
||||
private _lastPromptPos: PromptPosition | null = null;
|
||||
|
||||
// Font cache
|
||||
private _font: FontStyle = {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'normal',
|
||||
color: DEFAULT_FG,
|
||||
backgroundColor: DEFAULT_BG,
|
||||
letterSpacing: '',
|
||||
};
|
||||
|
||||
// Scroll handling
|
||||
private _scrollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private _scrollHandler: (() => void) | null = null;
|
||||
private _scrollViewport: Element | null = null;
|
||||
|
||||
constructor(options?: ZerolagInputOptions) {
|
||||
this._options = {
|
||||
prompt: options?.prompt ?? DEFAULT_PROMPT,
|
||||
zIndex: options?.zIndex ?? DEFAULT_Z_INDEX,
|
||||
showCursor: options?.showCursor ?? true,
|
||||
scrollDebounceMs: options?.scrollDebounceMs ?? DEFAULT_SCROLL_DEBOUNCE_MS,
|
||||
backgroundColor: options?.backgroundColor,
|
||||
foregroundColor: options?.foregroundColor,
|
||||
cursorColor: options?.cursorColor,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Called by `terminal.loadAddon()`. Do not call directly.
|
||||
*/
|
||||
activate(terminal: XtermTerminal): void {
|
||||
this._terminal = terminal;
|
||||
|
||||
// Create overlay container
|
||||
this._overlay = document.createElement('div');
|
||||
this._overlay.style.cssText =
|
||||
`position:absolute;z-index:${this._options.zIndex};pointer-events:none;display:none`;
|
||||
|
||||
// Insert into xterm DOM
|
||||
const screen = terminal.element?.querySelector('.xterm-screen');
|
||||
if (screen) {
|
||||
screen.appendChild(this._overlay);
|
||||
}
|
||||
|
||||
// Cache font properties
|
||||
this._cacheFont();
|
||||
|
||||
// Scroll detection: hide overlay when scrolled away from bottom
|
||||
this._scrollHandler = () => {
|
||||
try {
|
||||
const buf = this._terminal!.buffer.active;
|
||||
if (buf.viewportY !== buf.baseY) {
|
||||
this._overlay!.style.display = 'none';
|
||||
if (this._scrollTimer) {
|
||||
clearTimeout(this._scrollTimer);
|
||||
this._scrollTimer = null;
|
||||
}
|
||||
} else if (this._pendingText || this._flushedOffset > 0) {
|
||||
if (this._scrollTimer) clearTimeout(this._scrollTimer);
|
||||
this._scrollTimer = setTimeout(() => {
|
||||
this._scrollTimer = null;
|
||||
this._lastRenderKey = '';
|
||||
this._render();
|
||||
}, this._options.scrollDebounceMs);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const viewport = terminal.element?.querySelector('.xterm-viewport');
|
||||
if (viewport) {
|
||||
viewport.addEventListener('scroll', this._scrollHandler, { passive: true });
|
||||
this._scrollViewport = viewport;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the overlay, clean up listeners.
|
||||
*/
|
||||
dispose(): void {
|
||||
this.clear();
|
||||
if (this._scrollTimer) {
|
||||
clearTimeout(this._scrollTimer);
|
||||
this._scrollTimer = null;
|
||||
}
|
||||
if (this._scrollViewport && this._scrollHandler) {
|
||||
this._scrollViewport.removeEventListener('scroll', this._scrollHandler);
|
||||
}
|
||||
this._overlay?.remove();
|
||||
this._overlay = null;
|
||||
this._scrollViewport = null;
|
||||
this._scrollHandler = null;
|
||||
this._terminal = null;
|
||||
}
|
||||
|
||||
// ─── Input methods ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add a single printable character to the overlay.
|
||||
* Call this when the user types a character (charCode >= 32, length === 1).
|
||||
*/
|
||||
addChar(char: string): void {
|
||||
if (!this._pendingText && !this._flushedOffset) this._detectBufferText();
|
||||
this._pendingText += char;
|
||||
this._render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Append multiple characters at once (e.g., paste).
|
||||
*/
|
||||
appendText(text: string): void {
|
||||
if (!text) return;
|
||||
if (!this._pendingText && !this._flushedOffset) this._detectBufferText();
|
||||
this._pendingText += text;
|
||||
this._render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the last character from the overlay.
|
||||
*
|
||||
* Cascade order:
|
||||
* 1. Remove from `pendingText` if non-empty
|
||||
* 2. Decrement `flushedOffset` if pending is empty but flushed exists
|
||||
* 3. Try `detectBufferText()` if both are empty
|
||||
*
|
||||
* @returns `true` if a character was removed, `false` if nothing to remove.
|
||||
* When `false`, the consumer should NOT send backspace to the PTY.
|
||||
*/
|
||||
removeChar(): boolean {
|
||||
if (this._pendingText.length > 0) {
|
||||
this._pendingText = this._pendingText.slice(0, -1);
|
||||
if (this._pendingText.length > 0 || this._flushedOffset > 0) {
|
||||
this._render();
|
||||
} else {
|
||||
this._hide();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this._flushedOffset > 0) {
|
||||
this._flushedOffset--;
|
||||
this._flushedText = this._flushedText.slice(0, -1);
|
||||
if (this._flushedOffset > 0) {
|
||||
this._render();
|
||||
} else {
|
||||
this._hide();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all overlay state (pending + flushed). Hides the overlay.
|
||||
* Call on Enter, Ctrl+C, or any action that submits/cancels input.
|
||||
*/
|
||||
clear(): void {
|
||||
this._pendingText = '';
|
||||
this._flushedOffset = 0;
|
||||
this._flushedText = '';
|
||||
this._bufferDetectDone = false;
|
||||
this._lastRenderKey = '';
|
||||
this._lastPromptPos = null;
|
||||
this._hide();
|
||||
}
|
||||
|
||||
// ─── Flushed text tracking ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mark characters as "flushed" — sent to PTY but echo not yet received.
|
||||
*
|
||||
* The overlay renders flushed text (from the stored string) with an opaque
|
||||
* background to cover the terminal's canvas text, preventing a visible
|
||||
* font mismatch between canvas and DOM rendering.
|
||||
*
|
||||
* @param count - Number of characters flushed
|
||||
* @param text - The actual flushed text (avoids reading stale terminal buffer)
|
||||
*/
|
||||
setFlushed(count: number, text: string): void {
|
||||
this._flushedOffset = count;
|
||||
this._flushedText = text;
|
||||
this._render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current flushed state.
|
||||
*/
|
||||
getFlushed(): { count: number; text: string } {
|
||||
return { count: this._flushedOffset, text: this._flushedText };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear flushed state. Call when server echo has arrived and the terminal
|
||||
* buffer now contains the flushed text.
|
||||
*/
|
||||
clearFlushed(): void {
|
||||
this._flushedOffset = 0;
|
||||
this._flushedText = '';
|
||||
if (this._pendingText) {
|
||||
this._render();
|
||||
} else {
|
||||
this._hide();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Rendering control ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Force a re-render of the overlay at the current prompt position.
|
||||
* Call after terminal resets, buffer reloads, or full-screen redraws
|
||||
* that move the prompt.
|
||||
*/
|
||||
rerender(): void {
|
||||
if (this._pendingText || this._flushedOffset > 0) {
|
||||
this._lastRenderKey = '';
|
||||
this._render();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read font properties from the terminal and re-render.
|
||||
* Call after font size changes, theme changes, etc.
|
||||
*/
|
||||
refreshFont(): void {
|
||||
this._cacheFont();
|
||||
this._lastRenderKey = '';
|
||||
if (this._pendingText || this._flushedOffset > 0) this._render();
|
||||
}
|
||||
|
||||
// ─── Buffer detection ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan the terminal buffer for text after the prompt marker.
|
||||
* If found, sets it as flushed text in the overlay.
|
||||
*
|
||||
* Use case: Tab completion filled text on the prompt that the overlay
|
||||
* doesn't know about. Call this to sync overlay state with the buffer.
|
||||
*
|
||||
* @returns The detected text, or `null` if no prompt or no text found.
|
||||
*/
|
||||
detectBufferText(): string | null {
|
||||
return this._detectBufferText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the buffer detection guard. After `clear()`, detection is
|
||||
* automatically re-enabled. Call this manually if you need to force
|
||||
* re-detection (e.g., after a tab completion response arrives).
|
||||
*/
|
||||
resetBufferDetection(): void {
|
||||
this._bufferDetectDone = false;
|
||||
}
|
||||
|
||||
// ─── Prompt utilities ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find the prompt in the terminal buffer using the configured strategy.
|
||||
* @returns The position or `null` if not found.
|
||||
*/
|
||||
findPrompt(): PromptPosition | null {
|
||||
if (!this._terminal) return null;
|
||||
return findPrompt(this._terminal, this._options.prompt ?? DEFAULT_PROMPT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read text after the prompt marker on the prompt line.
|
||||
* Convenience method for consumers that need to snapshot prompt content.
|
||||
*/
|
||||
readPromptText(): string | null {
|
||||
if (!this._terminal) return null;
|
||||
const prompt = this.findPrompt();
|
||||
if (!prompt) return null;
|
||||
const offset = this._getPromptOffset();
|
||||
const text = readTextAfterPrompt(this._terminal, prompt, offset);
|
||||
return text || null;
|
||||
}
|
||||
|
||||
// ─── Public state ─────────────────────────────────────────────────
|
||||
|
||||
/** Current pending (unacknowledged) text. */
|
||||
get pendingText(): string {
|
||||
return this._pendingText;
|
||||
}
|
||||
|
||||
/** Whether there is any overlay content (pending or flushed). */
|
||||
get hasPending(): boolean {
|
||||
return this._pendingText.length > 0 || this._flushedOffset > 0;
|
||||
}
|
||||
|
||||
/** Read-only state snapshot. */
|
||||
get state(): ZerolagInputState {
|
||||
return {
|
||||
pendingText: this._pendingText,
|
||||
flushedLength: this._flushedOffset,
|
||||
flushedText: this._flushedText,
|
||||
visible: this._overlay?.style.display !== 'none',
|
||||
promptPosition: this._lastPromptPos ? { ...this._lastPromptPos } : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Private methods ──────────────────────────────────────────────
|
||||
|
||||
private _getPromptOffset(): number {
|
||||
const prompt = this._options.prompt ?? DEFAULT_PROMPT;
|
||||
return prompt.offset ?? 2;
|
||||
}
|
||||
|
||||
private _detectBufferText(): string | null {
|
||||
if (this._bufferDetectDone) return null;
|
||||
if (!this._terminal) return null;
|
||||
|
||||
try {
|
||||
const prompt = this.findPrompt();
|
||||
if (!prompt) return null;
|
||||
|
||||
const offset = this._getPromptOffset();
|
||||
const afterPrompt = readTextAfterPrompt(this._terminal, prompt, offset);
|
||||
|
||||
if (afterPrompt.length > 0) {
|
||||
this._flushedOffset = afterPrompt.length;
|
||||
this._flushedText = afterPrompt;
|
||||
this._lastPromptPos = prompt;
|
||||
this._bufferDetectDone = true;
|
||||
return afterPrompt;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private _cacheFont(): void {
|
||||
if (!this._terminal) return;
|
||||
|
||||
const t = this._terminal;
|
||||
this._font.fontFamily = t.options.fontFamily || 'monospace';
|
||||
this._font.fontSize = (t.options.fontSize || 14) + 'px';
|
||||
this._font.fontWeight = String(t.options.fontWeight || 'normal');
|
||||
this._font.backgroundColor =
|
||||
this._options.backgroundColor ??
|
||||
t.options.theme?.background ??
|
||||
DEFAULT_BG;
|
||||
this._font.color =
|
||||
this._options.foregroundColor ??
|
||||
t.options.theme?.foreground ??
|
||||
DEFAULT_FG;
|
||||
this._font.letterSpacing = '';
|
||||
|
||||
// Prefer computed styles from rendered rows (matches actual rendering)
|
||||
const rows = t.element?.querySelector('.xterm-rows');
|
||||
if (rows) {
|
||||
const cs = getComputedStyle(rows);
|
||||
this._font.letterSpacing = cs.letterSpacing;
|
||||
if (!this._options.foregroundColor && cs.color) {
|
||||
this._font.color = cs.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _hide(): void {
|
||||
if (!this._overlay) return;
|
||||
this._lastRenderKey = '';
|
||||
this._lastPromptPos = null;
|
||||
this._overlay.innerHTML = '';
|
||||
this._overlay.style.display = 'none';
|
||||
}
|
||||
|
||||
private _render(): void {
|
||||
if (!this._terminal || !this._overlay) return;
|
||||
if (!this._pendingText && !(this._flushedOffset > 0)) {
|
||||
this._overlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const buf = this._terminal.buffer.active;
|
||||
|
||||
// Hide overlay when scrolled up — prompt is at bottom, not in viewport
|
||||
if (buf.viewportY !== buf.baseY) {
|
||||
this._overlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-scan for prompt on every render (full-screen redraws can move it)
|
||||
const prompt = this.findPrompt();
|
||||
if (prompt) {
|
||||
// When flushed text exists, lock column to prevent jitter from
|
||||
// redraws that temporarily shift the prompt marker. Allow row changes.
|
||||
if (this._lastPromptPos && this._flushedOffset > 0) {
|
||||
this._lastPromptPos = { row: prompt.row, col: this._lastPromptPos.col };
|
||||
} else {
|
||||
this._lastPromptPos = prompt;
|
||||
}
|
||||
} else if (!this._lastPromptPos) {
|
||||
this._overlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const activePrompt = this._lastPromptPos!;
|
||||
|
||||
const dims = getCellDimensions(this._terminal);
|
||||
if (!dims) {
|
||||
this._overlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const { width: cellW, height: cellH } = dims;
|
||||
const totalCols = this._terminal.cols;
|
||||
const offset = this._getPromptOffset();
|
||||
const startCol = activePrompt.col + offset;
|
||||
|
||||
// Build display text: flushed chars + pending chars
|
||||
let displayText = this._pendingText;
|
||||
if (this._flushedOffset > 0) {
|
||||
if (this._flushedText && this._flushedText.length === this._flushedOffset) {
|
||||
displayText = this._flushedText + this._pendingText;
|
||||
} else {
|
||||
// Fallback: read flushed chars from terminal buffer
|
||||
const absRow = buf.viewportY + activePrompt.row;
|
||||
const line = buf.getLine(absRow);
|
||||
if (line) {
|
||||
const lineText = line.translateToString(true);
|
||||
const flushedChars = lineText.slice(startCol, startCol + this._flushedOffset);
|
||||
displayText = flushedChars + this._pendingText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip redundant re-renders
|
||||
const renderKey = `${displayText.length}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._flushedOffset}`;
|
||||
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
|
||||
this._lastRenderKey = renderKey;
|
||||
|
||||
// Split text into visual lines matching terminal character-wrap
|
||||
const firstLineCols = Math.max(1, totalCols - startCol);
|
||||
const lines: string[] = [];
|
||||
let remaining = displayText;
|
||||
lines.push(remaining.slice(0, firstLineCols));
|
||||
remaining = remaining.slice(firstLineCols);
|
||||
while (remaining.length > 0) {
|
||||
lines.push(remaining.slice(0, totalCols));
|
||||
remaining = remaining.slice(totalCols);
|
||||
}
|
||||
|
||||
const cursorColor =
|
||||
this._options.cursorColor ??
|
||||
this._terminal.options.theme?.cursor ??
|
||||
DEFAULT_CURSOR;
|
||||
|
||||
renderOverlay(this._overlay, {
|
||||
lines,
|
||||
startCol,
|
||||
totalCols,
|
||||
cellW,
|
||||
cellH,
|
||||
promptRow: activePrompt.row,
|
||||
font: this._font,
|
||||
showCursor: this._options.showCursor,
|
||||
cursorColor,
|
||||
});
|
||||
} catch {
|
||||
// Hide on render error but preserve pendingText —
|
||||
// next rerender() will retry when terminal is ready.
|
||||
if (this._overlay) {
|
||||
this._overlay.innerHTML = '';
|
||||
this._overlay.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "test", "examples"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
include: ['test/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user