diff --git a/packages/xterm-zerolag-input/README.md b/packages/xterm-zerolag-input/README.md index a8c582a3..04bc45db 100644 --- a/packages/xterm-zerolag-input/README.md +++ b/packages/xterm-zerolag-input/README.md @@ -18,6 +18,8 @@ When using xterm.js over a remote connection (SSH web clients, cloud IDEs, mobil npm install xterm-zerolag-input ``` +Zero runtime dependencies. Compatible with both `xterm` (pre-5.4) and `@xterm/xterm` (5.4+). + ## Quick Start ```typescript @@ -44,12 +46,10 @@ terminal.onData((data) => { if (source === 'flushed') ws.send(data); // only backspace text already in PTY } else if (data.length === 1 && data.charCodeAt(0) >= 32) { zerolag.addChar(data); - // Don't send to server yet — wait for Enter - // Or send immediately if your app uses char-at-a-time mode } }); -// 3. Re-render after terminal output (optional, for frameworks like Ink) +// 3. Re-render after terminal output (optional, for full-screen TUI frameworks) terminal.onWriteParsed(() => { if (zerolag.hasPending) zerolag.rerender(); }); @@ -57,10 +57,11 @@ terminal.onWriteParsed(() => { ## Prompt Detection -The addon needs to know where user input starts on the terminal line. Three strategies are supported: +The addon needs to know where user input starts on the terminal line. It scans the terminal buffer bottom-up looking for the prompt. Three strategies are supported: ### Character (default) -Scans bottom-up for a single character: + +Scans bottom-up for a single character. Uses `lastIndexOf` to find the rightmost occurrence on each line. ```typescript // Bash: user@host:~$ @@ -76,21 +77,23 @@ Scans bottom-up for a single character: { type: 'character', char: '>', offset: 2 } ``` -The `offset` is how many characters after the marker the input begins (e.g., `"$ "` = 2). +The `offset` is how many characters after the marker the user input begins (e.g., `"$ "` = 2, `">"` with no space = 1). ### Regex -For complex prompts: + +For complex prompts. The global (`g`) flag is safely stripped to prevent `lastIndex` mutation across renders. ```typescript -// Match end-of-prompt patterns +// Match dollar sign at end of prompt { type: 'regex', pattern: /\$\s*$/, offset: 2 } -// Match specific prompt format +// Match specific virtualenv prompt { type: 'regex', pattern: /\(venv\)\s+\w+\s+%/, offset: 2 } ``` ### Custom -Full control: + +Full control — provide your own function: ```typescript { @@ -98,64 +101,93 @@ Full control: offset: 0, find: (terminal) => { // Your logic here — return { row, col } or null + // row is viewport-relative (0 = top of viewport) return { row: terminal.rows - 1, col: 0 }; }, } ``` -## API +## API Reference ### `ZerolagInputAddon` Implements xterm.js `ITerminalAddon`. Load via `terminal.loadAddon(addon)`. +The addon does **not** hook `terminal.onData()` — you wire your own input handler and call the methods below. This gives you full control over which keystrokes are echoed vs forwarded. + #### Input Methods | Method | Description | |--------|-------------| -| `addChar(char)` | Add a single printable character to the overlay | -| `appendText(text)` | Append multiple characters (e.g., paste) | -| `removeChar(): 'pending' \| 'flushed' \| false` | Remove last char. Returns source (`'pending'` = unsent, `'flushed'` = send backspace to PTY) or `false` | -| `clear()` | Clear all state and hide overlay | +| `addChar(char)` | Add a single printable character to the overlay. Call for `charCode >= 32, length === 1`. On first keystroke after empty state, auto-detects existing buffer text as flushed. | +| `appendText(text)` | Append multiple characters at once (paste). Same auto-detection as `addChar`. | +| `removeChar()` | Remove last character. See [removeChar Cascade](#removechar-cascade) below. | +| `clear()` | Clear all state (pending + flushed), hide overlay. Call on Enter, Ctrl+C, Escape, or any action that submits/cancels input. Resets buffer detection guard. | + +#### removeChar Cascade + +`removeChar()` returns `'pending' | 'flushed' | false` indicating the source of the removed character: + +``` +Step 1: pendingText non-empty → pop last char → return 'pending' +Step 2: flushed text exists → decrement flushed → return 'flushed' +Step 3: both empty → detect buffer → return 'flushed' (if found) +Step 4: nothing found → → return false +``` + +**How to use the return value:** + +| Return | Meaning | Action | +|--------|---------|--------| +| `'pending'` | Removed a character that was never sent to PTY | Do nothing — no backspace needed | +| `'flushed'` | Removed a character that was already sent to PTY | Send `\x7f` (backspace) to PTY | +| `false` | Nothing to remove | Do nothing | + +Step 3 handles tab completion and arrow-key edits: if the user tabs to complete a command and immediately hits backspace, the overlay detects the completed text from the terminal buffer and removes from it. #### Flushed Text Tracking -For scenarios where text has been sent to the PTY but the echo hasn't arrived yet (e.g., tab switching between sessions): +"Flushed" text is text that has been sent to the PTY but whose echo hasn't arrived in the terminal buffer yet. This happens during: +- **Tab switching**: Pending overlay text is flushed to PTY before switching, then restored as flushed on switch-back +- **Tab completion**: Shell fills text on the prompt; overlay syncs via `detectBufferText()` | Method | Description | |--------|-------------| -| `setFlushed(count, text)` | Mark characters as sent-but-unacknowledged | -| `getFlushed()` | Get `{ count, text }` of flushed state | -| `clearFlushed()` | Clear flushed state (echo arrived) | +| `setFlushed(count, text)` | Mark characters as sent-but-unacknowledged. Triggers a render. | +| `getFlushed()` | Returns `{ count: number, text: string }`. | +| `clearFlushed()` | Clear flushed state (call when server echo has arrived). | + +#### Buffer Detection + +The overlay can scan the terminal buffer for text that already exists after the prompt but wasn't typed through the overlay (tab completion, arrow-key edits, shell history). + +| Method | Description | +|--------|-------------| +| `detectBufferText()` | Scan buffer for text after prompt. Returns the detected text string, or `null`. If found, sets it as flushed text. Guarded: only runs once per `clear()` cycle. | +| `resetBufferDetection()` | Re-enable detection (e.g., after tab completion response arrives). | +| `suppressBufferDetection()` | Prevent detection until next `clear()`. Use when switching to a session whose buffer has UI framework text (e.g., Ink status bars) after the prompt marker that would be falsely detected. | #### Rendering | Method | Description | |--------|-------------| -| `rerender()` | Force re-render at current prompt position | -| `refreshFont()` | Re-read font properties after size/theme change | - -#### Buffer Detection - -| Method | Description | -|--------|-------------| -| `detectBufferText()` | Scan buffer for text after prompt; returns detected text or `null` | -| `resetBufferDetection()` | Allow re-detection (auto-reset on `clear()`) | +| `rerender()` | Force re-render at current prompt position. Clears the render cache so the DOM is rebuilt. Call after terminal buffer reloads, full-screen redraws, or SSE reconnects. | +| `refreshFont()` | Re-read font properties (family, size, weight, letter-spacing, colors) from the terminal and re-render. Call after font size changes or theme switches. | #### Prompt Utilities | Method | Description | |--------|-------------| -| `findPrompt()` | Find prompt position using configured strategy | -| `readPromptText()` | Read text after prompt marker | +| `findPrompt()` | Find prompt position using the configured strategy. Returns `{ row, col }` (viewport-relative) or `null`. | +| `readPromptText()` | Read text after the prompt marker on the prompt line. Returns the text or `null`. | -#### State +#### State Properties | Property | Type | Description | |----------|------|-------------| -| `pendingText` | `string` | Characters typed but not acknowledged | -| `hasPending` | `boolean` | Whether overlay has any content | -| `state` | `ZerolagInputState` | Full read-only state snapshot | +| `pendingText` | `string` | Characters typed but not acknowledged. Read-only. | +| `hasPending` | `boolean` | `true` if overlay has any content (pending or flushed). | +| `state` | `ZerolagInputState` | Read-only snapshot: `{ pendingText, flushedLength, flushedText, visible, promptPosition }`. Safe to call before `activate()` (returns `visible: false`). | ### Options @@ -163,24 +195,154 @@ For scenarios where text has been sent to the PTY but the echo hasn't arrived ye interface ZerolagInputOptions { prompt?: PromptFinder; // Default: { type: 'character', char: '>', offset: 2 } zIndex?: number; // Default: 7 - backgroundColor?: string; // Default: from terminal theme - foregroundColor?: string; // Default: from terminal theme + backgroundColor?: string; // Default: from terminal theme, then '#0d0d0d' + foregroundColor?: string; // Default: from computed .xterm-rows style, then theme, then '#eeeeee' showCursor?: boolean; // Default: true - cursorColor?: string; // Default: from terminal theme + cursorColor?: string; // Default: from terminal theme cursor, then '#e0e0e0' scrollDebounceMs?: number; // Default: 50 } ``` +## Integration Patterns + +### Buffered Input (hold until Enter) + +The quick start example above uses buffered mode — characters accumulate in the overlay and are sent on Enter. This is common for remote shells where you want to batch input. + +### Char-at-a-Time (send immediately) + +For applications that need each keystroke sent immediately: + +```typescript +terminal.onData((data) => { + if (data === '\r') { + zerolag.clear(); + ws.send('\r'); + } else if (data === '\x7f') { + zerolag.removeChar(); // always 'pending' since nothing is buffered + ws.send(data); + } else if (data.length === 1 && data.charCodeAt(0) >= 32) { + zerolag.addChar(data); + ws.send(data); + // The overlay shows the char immediately; the PTY echo will arrive later + // and the overlay continues showing until the next rerender() + } +}); +``` + +### Tab Switching (multi-session) + +When your app has multiple terminal sessions in tabs: + +```typescript +function switchToSession(newSessionId: string) { + // 1. Save current overlay state + const pending = zerolag.pendingText; + const { count, text } = zerolag.getFlushed(); + if (pending) { + sendToPty(currentSessionId, pending); // flush unsent text to PTY + } + const totalCount = count + pending.length; + const totalText = text + pending; + savedFlushed.set(currentSessionId, { count: totalCount, text: totalText }); + zerolag.clear(); + + // 2. Switch terminal buffer to new session + loadSessionBuffer(newSessionId); + + // 3. Restore overlay state for new session + const saved = savedFlushed.get(newSessionId); + if (saved) { + zerolag.suppressBufferDetection(); // prevent false detection of UI text + zerolag.setFlushed(saved.count, saved.text); + } else { + zerolag.suppressBufferDetection(); // fresh session, no flushed state + } + + // 4. Re-render after buffer loads + terminal.write('', () => { + zerolag.rerender(); // finds prompt, positions overlay correctly + }); +} +``` + +### Tab Completion Detection + +After sending a Tab key to the PTY, you can detect whether the shell completed text: + +```typescript +// Before Tab: snapshot baseline +const baseline = zerolag.readPromptText(); +zerolag.clear(); +sendToPty('\t'); + +// After PTY response arrives: +zerolag.resetBufferDetection(); +const detected = zerolag.detectBufferText(); +if (detected && detected !== baseline) { + // Tab completion occurred — overlay now shows the completed text +} +``` + +### Full-Screen TUI Frameworks (Ink, Blessed) + +Frameworks like Ink redraw the entire screen on state changes, which can move the prompt. Re-render the overlay after each terminal write: + +```typescript +terminal.onWriteParsed(() => { + if (zerolag.hasPending) zerolag.rerender(); +}); +``` + +### SSE/WebSocket Reconnect + +After a connection drop and reconnect, the terminal buffer reloads. Preserve overlay text across reconnects: + +```typescript +function onReconnect() { + // Buffer reloaded — re-render overlay at new prompt position + zerolag.rerender(); +} +``` + +### Terminal Resize + +After the terminal is resized (columns/rows change), cell dimensions change: + +```typescript +fitAddon.fit(); +zerolag.rerender(); // recalculates cell dimensions and prompt position +``` + +### Font Size / Theme Changes + +```typescript +terminal.options.fontSize = 18; +zerolag.refreshFont(); // re-caches font properties, re-renders +``` + ## How It Works -1. A `