fix(mobile): local echo keeps painting when the viewport is parked above the bottom

Anything typed on a phone vanished whenever the terminal viewport was not scrolled fully to the bottom. Codeman parks the viewport a few rows above the bottom on purpose (scrollToLastNonEmptyLine after a tab switch, or after the keyboard drops and the terminal regrows, so trailing blank rows stay off screen), and the overlay hid itself on a bare viewportY !== baseY test even though the prompt and the cursor were on screen. The text was buffered the whole time and Enter still sent it, which is what made the keyboard look dead.

The overlay now gates on whether the cursor row is inside the viewport (promptRowInViewport), in both the render path and the scroll handler, so a deep scroll into history still hides it. A buffer without cursorY keeps the old bottom-only rule.

Two of Codeman's custom prompt finders made the matching mistake of treating cursorY, which xterm reports relative to baseY, as a screen row; with the gate relaxed that would have painted the text on the wrong line. Both go through cursorViewportRow now, and the Claude finder looks for its glyph from the cursor's screen row up to the top of the live screen only, since a parked viewport shows scrolled-off history whose old composer rows used to be unreachable and would anchor the overlay on the wrong line.
This commit is contained in:
JD
2026-10-09 19:11:22 -04:00
parent 3a0cee6b90
commit 7aecbd29df
6 changed files with 199 additions and 14 deletions
@@ -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');
});
});