mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
Merge pull request #576: fix(mobile): local echo keeps painting when the viewport is parked above the bottom
This commit is contained in:
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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user