diff --git a/packages/xterm-zerolag-input/README.md b/packages/xterm-zerolag-input/README.md index 04bc45db..c0f83704 100644 --- a/packages/xterm-zerolag-input/README.md +++ b/packages/xterm-zerolag-input/README.md @@ -153,7 +153,7 @@ Step 3 handles tab completion and arrow-key edits: if the user tabs to complete | Method | Description | |--------|-------------| -| `setFlushed(count, text)` | Mark characters as sent-but-unacknowledged. Triggers a render. | +| `setFlushed(count, text, render?)` | Mark characters as sent-but-unacknowledged. Pass `render=false` when restoring during a tab switch before the new buffer has loaded (prevents stale prompt column locking). Default: `true`. | | `getFlushed()` | Returns `{ count: number, text: string }`. | | `clearFlushed()` | Clear flushed state (call when server echo has arrived). | @@ -166,6 +166,7 @@ The overlay can scan the terminal buffer for text that already exists after the | `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. | +| `undoDetection()` | Undo the last `detectBufferText()` — clears flushed state and re-enables detection. Use when tab completion detection found text matching the pre-tab baseline (no real completion happened) and needs to retry. | #### Rendering @@ -251,12 +252,10 @@ function switchToSession(newSessionId: string) { loadSessionBuffer(newSessionId); // 3. Restore overlay state for new session + zerolag.suppressBufferDetection(); // prevent false detection of UI text 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 + zerolag.setFlushed(saved.count, saved.text, false); // render=false: buffer not loaded yet } // 4. Re-render after buffer loads @@ -281,6 +280,10 @@ zerolag.resetBufferDetection(); const detected = zerolag.detectBufferText(); if (detected && detected !== baseline) { // Tab completion occurred — overlay now shows the completed text + zerolag.rerender(); +} else if (detected) { + // Same text as before Tab — no real completion. Undo and retry next cycle. + zerolag.undoDetection(); } ``` diff --git a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts index 079b53e4..983a5e0c 100644 --- a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts +++ b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts @@ -271,11 +271,16 @@ export class ZerolagInputAddon implements XtermAddon { * * @param count - Number of characters flushed * @param text - The actual flushed text (avoids reading stale terminal buffer) + * @param render - Whether to re-render immediately (default: `true`). + * Pass `false` when restoring flushed state during a tab/session switch + * before the new buffer has loaded — rendering against a stale buffer + * would lock the prompt column to the wrong position. Call `rerender()` + * explicitly after the buffer finishes loading. */ - setFlushed(count: number, text: string): void { + setFlushed(count: number, text: string, render = true): void { this._flushedOffset = count; this._flushedText = text; - this._render(); + if (render) this._render(); } /** @@ -347,6 +352,20 @@ export class ZerolagInputAddon implements XtermAddon { this._bufferDetectDone = false; } + /** + * Undo the last `detectBufferText()` call — clears flushed state and + * re-enables detection. + * + * Use case: Tab completion detection found text that matches the + * pre-tab baseline (no real completion happened). Call this to undo + * the detection so it can retry on the next flush cycle. + */ + undoDetection(): void { + this._flushedOffset = 0; + this._flushedText = ''; + this._bufferDetectDone = false; + } + /** * Suppress buffer detection until the next `clear()` or * `resetBufferDetection()` call. diff --git a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts index 894972f6..b1680cc4 100644 --- a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts +++ b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts @@ -160,6 +160,30 @@ describe('ZerolagInputAddon', () => { expect(addon.hasPending).toBe(true); }); + it('setFlushed with render=false does not render', () => { + const { addon } = tracked(); + addon.setFlushed(3, 'abc', false); + expect(addon.getFlushed()).toEqual({ count: 3, text: 'abc' }); + expect(addon.hasPending).toBe(true); + // Overlay should still be hidden (no render triggered) + expect(addon.state.visible).toBe(false); + }); + + it('setFlushed with render=false prevents stale column lock', () => { + const { addon, mock } = tracked(['$ old session text']); + // Simulate: clear overlay, then restore flushed WITHOUT render + addon.clear(); + addon.setFlushed(5, 'hello', false); + // lastPromptPos should still be null (no render happened) + expect(addon.state.promptPosition).toBeNull(); + // Now simulate buffer load and rerender + mock.setLines(['$ ']); + addon.rerender(); + // Prompt should be freshly scanned, no stale column lock + const pos = addon.state.promptPosition; + expect(pos).not.toBeNull(); + }); + it('clearFlushed resets flushed state', () => { const { addon } = tracked(); addon.setFlushed(3, 'abc'); @@ -291,6 +315,34 @@ describe('ZerolagInputAddon', () => { expect(addon.getFlushed().count).toBe(0); }); + it('undoDetection clears flushed and re-enables detection', () => { + const { addon } = tracked(['$ completed']); + // Detect buffer text (simulates tab completion detection) + addon.detectBufferText(); + expect(addon.getFlushed().count).toBe(9); // 'completed' + + // Undo because it matched baseline (no real completion) + addon.undoDetection(); + expect(addon.getFlushed().count).toBe(0); + expect(addon.getFlushed().text).toBe(''); + + // Detection should work again (guard reset) + const text = addon.detectBufferText(); + expect(text).toBe('completed'); + }); + + it('undoDetection does not hide overlay or clear pending', () => { + const { addon } = tracked(['$ buffer']); + addon.detectBufferText(); // sets flushed + addon.addChar('x'); // adds pending on top + + addon.undoDetection(); + // Flushed cleared, but pending preserved + expect(addon.pendingText).toBe('x'); + expect(addon.getFlushed().count).toBe(0); + expect(addon.hasPending).toBe(true); + }); + it('clear resets suppression (re-enables detection)', () => { const { addon } = tracked(['$ text']); addon.suppressBufferDetection(); @@ -394,7 +446,7 @@ describe('ZerolagInputAddon', () => { // Tab switch back: restore as flushed (text is now in PTY) addon.suppressBufferDetection(); // prevent false Ink detection - addon.setFlushed(totalCount, totalText); + addon.setFlushed(totalCount, totalText, false); // no render — buffer not loaded yet expect(addon.getFlushed()).toEqual({ count: 2, text: 'hi' }); expect(addon.hasPending).toBe(true); // has flushed content