diff --git a/packages/xterm-zerolag-input/src/prompt-finder.ts b/packages/xterm-zerolag-input/src/prompt-finder.ts index 9d660ede..0ebd2f40 100644 --- a/packages/xterm-zerolag-input/src/prompt-finder.ts +++ b/packages/xterm-zerolag-input/src/prompt-finder.ts @@ -72,3 +72,20 @@ export function readTextAfterPrompt(terminal: XtermTerminal, prompt: PromptPosit return ''; } } + +/** + * Whether the row the overlay draws on (the cursor row) is inside the viewport. A bare + * `viewportY === baseY` test is wrong for a host that parks the viewport a few rows above + * the bottom with the prompt still on screen; a buffer without `cursorY` keeps that rule. + */ +export function promptRowInViewport(terminal: XtermTerminal): boolean { + try { + const buf = terminal.buffer.active; + if (buf.viewportY === buf.baseY) return true; + if (typeof buf.cursorY !== 'number') return false; + const cursorRow = buf.baseY + buf.cursorY; + return cursorRow >= buf.viewportY && cursorRow < buf.viewportY + terminal.rows; + } catch { + return false; + } +} diff --git a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts index 12c0dfa8..3993de8b 100644 --- a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts +++ b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts @@ -8,7 +8,7 @@ import type { FontStyle, } from './types.js'; import { getCellDimensions } from './cell-dimensions.js'; -import { findPrompt, readTextAfterPrompt } from './prompt-finder.js'; +import { findPrompt, readTextAfterPrompt, promptRowInViewport } from './prompt-finder.js'; import { renderOverlay, charCellWidth } from './overlay-renderer.js'; const DEFAULT_PROMPT: PromptFinder = { type: 'character', char: '>', offset: 2 }; @@ -122,11 +122,10 @@ export class ZerolagInputAddon implements XtermAddon { // Cache font properties this._cacheFont(); - // Scroll detection: hide overlay when scrolled away from bottom + // Scroll detection: hide the overlay while the cursor row is scrolled out of view this._scrollHandler = () => { try { - const buf = this._terminal!.buffer.active; - if (buf.viewportY !== buf.baseY) { + if (!promptRowInViewport(this._terminal!)) { this._overlay!.style.display = 'none'; if (this._scrollTimer) { clearTimeout(this._scrollTimer); @@ -565,8 +564,8 @@ export class ZerolagInputAddon implements XtermAddon { try { const buf = this._terminal.buffer.active; - // Hide overlay when scrolled up — prompt is at bottom, not in viewport - if (buf.viewportY !== buf.baseY) { + // Hide the overlay while the cursor row is scrolled out of view + if (!promptRowInViewport(this._terminal)) { this._overlay.style.display = 'none'; return; } diff --git a/packages/xterm-zerolag-input/test/prompt-finder.test.ts b/packages/xterm-zerolag-input/test/prompt-finder.test.ts index 69942a31..4c09ff04 100644 --- a/packages/xterm-zerolag-input/test/prompt-finder.test.ts +++ b/packages/xterm-zerolag-input/test/prompt-finder.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { createMockTerminal } from './helpers.js'; -import { findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js'; +import { promptRowInViewport, findPrompt, readTextAfterPrompt } from '../src/prompt-finder.js'; import type { XtermTerminal, PromptFinder } from '../src/types.js'; function term(lines: string[]) { @@ -156,3 +156,28 @@ describe('readTextAfterPrompt', () => { cleanup(); }); }); + +describe('promptRowInViewport', () => { + const term = (viewportY: number, baseY: number, cursorY: number | undefined, rows = 24) => + ({ rows, buffer: { active: { viewportY, baseY, cursorY, getLine: () => undefined } } }) as never; + + it('is true at the bottom regardless of the cursor', () => { + expect(promptRowInViewport(term(10, 10, undefined))).toBe(true); + }); + + it('is true for a viewport parked above the bottom while the cursor row is on screen', () => { + // scrollToLastNonEmptyLine() parks rows - 2 above the last non-empty row + expect(promptRowInViewport(term(0, 16, 5))).toBe(true); + // cursor exactly on the last visible row + expect(promptRowInViewport(term(0, 23, 0))).toBe(true); + }); + + it('is false once the cursor row is scrolled out of the viewport', () => { + expect(promptRowInViewport(term(0, 24, 0))).toBe(false); // one past the last row + expect(promptRowInViewport(term(0, 200, 3))).toBe(false); // deep in history + }); + + it('keeps the bottom-only rule when the buffer has no cursorY', () => { + expect(promptRowInViewport(term(0, 1, undefined))).toBe(false); + }); +}); 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 74063761..e4653868 100644 --- a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts +++ b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts @@ -725,3 +725,34 @@ describe('ZerolagInputAddon', () => { }); }); }); + +describe('viewport scrolled away from the bottom', () => { + function parked(viewportY: number, baseY: number, cursorY: number, rows = 24) { + const mock = createMockTerminal({ buffer: { lines: ['$ '], viewportY, baseY, cursorY }, rows }); + const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 } }); + mock.terminal.loadAddon(addon); + cleanups.push(() => { + addon.dispose(); + mock.cleanup(); + }); + const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement; + return { addon, mock, overlay }; + } + + it('still paints with the viewport parked above the bottom while the cursor row is on screen', () => { + // The host parks the viewport to keep trailing blank rows out of view; the + // prompt and cursor are still visible, so the user's text must be too. + const { addon, overlay } = parked(0, 1, 0); + addon.appendText('abc'); + expect(addon.pendingText).toBe('abc'); + expect(overlay.style.display).not.toBe('none'); + expect(overlay.textContent).toContain('abc'); + }); + + it('hides once the cursor row is scrolled out of the viewport, even over a stale prompt glyph', () => { + const { addon, overlay } = parked(0, 30, 0); + addon.appendText('abc'); + expect(addon.pendingText).toBe('abc'); + expect(overlay.style.display).toBe('none'); + }); +}); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index d95960b2..678a8e0c 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -177,6 +177,18 @@ } } + // Screen row of the cursor, for the local-echo prompt finders: xterm's cursorY is + // baseY-relative, so a viewport parked above the bottom shifts it. Null when off screen. + function cursorViewportRow(terminal) { + try { + const buf = terminal.buffer.active; + const row = buf.baseY + (buf.cursorY || 0) - buf.viewportY; + return row >= 0 && row < terminal.rows ? row : null; + } catch { + return null; + } + } + function isTerminalQueryResponse(data) { return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data); } @@ -250,6 +262,7 @@ isComposerNavKey, classifyPredictInput, isCodexComposerRow, + cursorViewportRow, CODEX_COMPOSER_ROW_RE, BRACKETED_PASTE_START, USER_SCROLL_STICKY_SUPPRESS_MS, @@ -3991,14 +4004,15 @@ Object.assign(CodemanApp.prototype, { if (session.mode === 'opencode') { // OpenCode (Bubble Tea TUI): find the ┃ border on the cursor's row. // The input area is "┃ " — the ┃ is the anchor, offset 3 skips "┃ ". - // We use the cursor row (cursorY) to find the right line, then scan for ┃. + // We use the cursor's screen row to find the right line, then scan for ┃. this._localEchoOverlay.setPrompt({ type: 'custom', offset: 3, find: (terminal) => { try { const buf = terminal.buffer.active; - const row = buf.cursorY; + const row = window.CodemanTerminalInput.cursorViewportRow(terminal); + if (row === null) return null; const line = buf.getLine(buf.viewportY + row); if (!line) return null; const text = line.translateToString(true); @@ -4028,23 +4042,25 @@ Object.assign(CodemanApp.prototype, { // the viewport, while xterm's cursor still marks the editable input // position. Fall back to cursor coordinates so phone typing appears at // the terminal cursor instead of disappearing into pending state. + // The glyph is looked for from the cursor's screen row up to the top of the + // live screen only: rows above that are parked scrollback with old composer glyphs. this._localEchoOverlay.setPrompt({ type: 'custom', offset: 0, find: (terminal) => { try { const buf = terminal.buffer.active; - for (let row = terminal.rows - 1; row >= 0; row--) { + const cursorRow = window.CodemanTerminalInput.cursorViewportRow(terminal); + if (cursorRow === null) return null; + const lowest = Math.max(0, buf.baseY - buf.viewportY); + for (let row = cursorRow; row >= lowest; row--) { const line = buf.getLine(buf.viewportY + row); if (!line) continue; const text = line.translateToString(true); const idx = text.lastIndexOf('\u276f'); if (idx >= 0) return { row, col: idx + 2 }; } - return { - row: Math.max(0, Math.min(terminal.rows - 1, buf.cursorY)), - col: Math.max(0, Math.min(terminal.cols - 1, buf.cursorX)), - }; + return { row: cursorRow, col: Math.max(0, Math.min(terminal.cols - 1, buf.cursorX)) }; } catch { return null; } diff --git a/test/local-echo-codex-gating.test.ts b/test/local-echo-codex-gating.test.ts index e8f45d76..16eab040 100644 --- a/test/local-echo-codex-gating.test.ts +++ b/test/local-echo-codex-gating.test.ts @@ -470,3 +470,100 @@ describe('insertTerminalText under predict policy', () => { expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled(); }); }); + +describe('CodemanTerminalInput.cursorViewportRow', () => { + const cursorViewportRow = terminalInput.cursorViewportRow as (t: unknown) => number | null; + const term = (viewportY: number, baseY: number, cursorY: number, rows = 24) => ({ + rows, + buffer: { active: { viewportY, baseY, cursorY } }, + }); + + it('equals cursorY while the viewport sits at the bottom', () => { + expect(cursorViewportRow(term(10, 10, 3))).toBe(3); + }); + + it('shifts by baseY - viewportY when the viewport is parked above the bottom', () => { + // scrollToLastNonEmptyLine() parked the viewport at 47 of a baseY of 83: the + // cursor on line 83 is screen row 36, where a bare cursorY would say row 0 + expect(cursorViewportRow(term(47, 83, 0, 38))).toBe(36); + }); + + it('is null once the cursor row is scrolled off screen', () => { + expect(cursorViewportRow(term(0, 83, 0, 38))).toBeNull(); + }); + + it('is null when the buffer is unreadable', () => { + expect(cursorViewportRow({ rows: 24, buffer: null })).toBeNull(); + }); +}); + +describe('local-echo prompt finders with the viewport parked above the bottom', () => { + type Finder = { type: string; find: (t: unknown) => { row: number; col: number } | null }; + function finderFor(mode: string): Finder { + const overlay = makeOverlay(); + const app = makeApp(mode, overlay); + app._updateLocalEchoState(); + return overlay.prompts[overlay.prompts.length - 1] as Finder; + } + function terminal( + lines: Record, + geom: { viewportY: number; baseY: number; cursorY: number; cursorX?: number; rows?: number; cols?: number } + ) { + return { + rows: geom.rows ?? 38, + cols: geom.cols ?? 80, + buffer: { + active: { + viewportY: geom.viewportY, + baseY: geom.baseY, + cursorY: geom.cursorY, + cursorX: geom.cursorX ?? 5, + getLine: (y: number) => (y in lines ? { translateToString: () => lines[y] } : undefined), + }, + }, + }; + } + + it('claude: the cursor fallback reports the cursor screen row, not cursorY', () => { + const { find } = finderFor('claude'); + const parked = terminal({ 83: 'user@host:~$ ' }, { viewportY: 47, baseY: 83, cursorY: 0, cursorX: 13 }); + expect(find(parked)).toEqual({ row: 36, col: 13 }); + }); + + it('claude: reports no prompt once the cursor row is scrolled off screen', () => { + const { find } = finderFor('claude'); + expect(find(terminal({ 83: 'user@host:~$ ' }, { viewportY: 0, baseY: 83, cursorY: 0 }))).toBeNull(); + }); + + it('claude: a prompt glyph on the cursor row wins over the cursor fallback', () => { + const { find } = finderFor('claude'); + const parked = terminal({ 83: '\u276f ' }, { viewportY: 47, baseY: 83, cursorY: 0, cursorX: 2 }); + expect(find(parked)).toEqual({ row: 36, col: 2 }); + }); + + it('claude: a history glyph right above the live screen is ignored, however close to the cursor', () => { + const { find } = finderFor('claude'); + // line 82 is scrollback (baseY is 83): one row above the cursor on screen, still not the composer + const parked = terminal( + { 82: '\u276f old prompt', 83: 'user@host:~$ ' }, + { viewportY: 47, baseY: 83, cursorY: 0, cursorX: 13 } + ); + expect(find(parked)).toEqual({ row: 36, col: 13 }); + }); + + it('claude: a composer taller than a handful of rows still finds its glyph', () => { + const { find } = finderFor('claude'); + // glyph on line 70, cursor thirteen continuation rows below it on line 83, all live screen + const parked = terminal( + { 70: '\u276f a very long pasted prompt', 83: 'still typing' }, + { viewportY: 47, baseY: 70, cursorY: 13, cursorX: 12 } + ); + expect(find(parked)).toEqual({ row: 23, col: 2 }); + }); + + it('opencode: reads the border on the cursor screen row when parked', () => { + const { find } = finderFor('opencode'); + const parked = terminal({ 83: '\u2503 hello' }, { viewportY: 47, baseY: 83, cursorY: 0 }); + expect(find(parked)).toEqual({ row: 36, col: 0 }); + }); +});