Merge pull request #576: fix(mobile): local echo keeps painting when the viewport is parked above the bottom

This commit is contained in:
Codeman maintainer committed 2026-10-10 03:07:18 +02:00
commit cf9022ac9a
6 files changed
+199 -14

No files matched your search

@@ -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;
}
}
@@ -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;
}
@@ -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);
});
});
@@ -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');
});
});
+23 -7
View File
@@ -203,6 +203,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);
}
@@ -276,6 +288,7 @@
isComposerNavKey,
classifyPredictInput,
isCodexComposerRow,
cursorViewportRow,
CODEX_COMPOSER_ROW_RE,
BRACKETED_PASTE_START,
USER_SCROLL_STICKY_SUPPRESS_MS,
@@ -4020,14 +4033,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 "┃ <text>" — 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);
@@ -4057,23 +4071,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;
}
+97
View File
@@ -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<number, string>,
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 });
});
});