mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
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:
1 parent
3a0cee6b90
commit
7aecbd29df
6 files changed
+199
-14
No files matched your search
@@ -72,3 +72,20 @@ export function readTextAfterPrompt(terminal: XtermTerminal, prompt: PromptPosit
|
|||||||
return '';
|
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,
|
FontStyle,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { getCellDimensions } from './cell-dimensions.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';
|
import { renderOverlay, charCellWidth } from './overlay-renderer.js';
|
||||||
|
|
||||||
const DEFAULT_PROMPT: PromptFinder = { type: 'character', char: '>', offset: 2 };
|
const DEFAULT_PROMPT: PromptFinder = { type: 'character', char: '>', offset: 2 };
|
||||||
@@ -122,11 +122,10 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
// Cache font properties
|
// Cache font properties
|
||||||
this._cacheFont();
|
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 = () => {
|
this._scrollHandler = () => {
|
||||||
try {
|
try {
|
||||||
const buf = this._terminal!.buffer.active;
|
if (!promptRowInViewport(this._terminal!)) {
|
||||||
if (buf.viewportY !== buf.baseY) {
|
|
||||||
this._overlay!.style.display = 'none';
|
this._overlay!.style.display = 'none';
|
||||||
if (this._scrollTimer) {
|
if (this._scrollTimer) {
|
||||||
clearTimeout(this._scrollTimer);
|
clearTimeout(this._scrollTimer);
|
||||||
@@ -565,8 +564,8 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
try {
|
try {
|
||||||
const buf = this._terminal.buffer.active;
|
const buf = this._terminal.buffer.active;
|
||||||
|
|
||||||
// Hide overlay when scrolled up — prompt is at bottom, not in viewport
|
// Hide the overlay while the cursor row is scrolled out of view
|
||||||
if (buf.viewportY !== buf.baseY) {
|
if (!promptRowInViewport(this._terminal)) {
|
||||||
this._overlay.style.display = 'none';
|
this._overlay.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { createMockTerminal } from './helpers.js';
|
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';
|
import type { XtermTerminal, PromptFinder } from '../src/types.js';
|
||||||
|
|
||||||
function term(lines: string[]) {
|
function term(lines: string[]) {
|
||||||
@@ -156,3 +156,28 @@ describe('readTextAfterPrompt', () => {
|
|||||||
cleanup();
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) {
|
function isTerminalQueryResponse(data) {
|
||||||
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
|
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
|
||||||
}
|
}
|
||||||
@@ -250,6 +262,7 @@
|
|||||||
isComposerNavKey,
|
isComposerNavKey,
|
||||||
classifyPredictInput,
|
classifyPredictInput,
|
||||||
isCodexComposerRow,
|
isCodexComposerRow,
|
||||||
|
cursorViewportRow,
|
||||||
CODEX_COMPOSER_ROW_RE,
|
CODEX_COMPOSER_ROW_RE,
|
||||||
BRACKETED_PASTE_START,
|
BRACKETED_PASTE_START,
|
||||||
USER_SCROLL_STICKY_SUPPRESS_MS,
|
USER_SCROLL_STICKY_SUPPRESS_MS,
|
||||||
@@ -3991,14 +4004,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (session.mode === 'opencode') {
|
if (session.mode === 'opencode') {
|
||||||
// OpenCode (Bubble Tea TUI): find the ┃ border on the cursor's row.
|
// OpenCode (Bubble Tea TUI): find the ┃ border on the cursor's row.
|
||||||
// The input area is "┃ <text>" — the ┃ is the anchor, offset 3 skips "┃ ".
|
// 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({
|
this._localEchoOverlay.setPrompt({
|
||||||
type: 'custom',
|
type: 'custom',
|
||||||
offset: 3,
|
offset: 3,
|
||||||
find: (terminal) => {
|
find: (terminal) => {
|
||||||
try {
|
try {
|
||||||
const buf = terminal.buffer.active;
|
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);
|
const line = buf.getLine(buf.viewportY + row);
|
||||||
if (!line) return null;
|
if (!line) return null;
|
||||||
const text = line.translateToString(true);
|
const text = line.translateToString(true);
|
||||||
@@ -4028,23 +4042,25 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// the viewport, while xterm's cursor still marks the editable input
|
// the viewport, while xterm's cursor still marks the editable input
|
||||||
// position. Fall back to cursor coordinates so phone typing appears at
|
// position. Fall back to cursor coordinates so phone typing appears at
|
||||||
// the terminal cursor instead of disappearing into pending state.
|
// 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({
|
this._localEchoOverlay.setPrompt({
|
||||||
type: 'custom',
|
type: 'custom',
|
||||||
offset: 0,
|
offset: 0,
|
||||||
find: (terminal) => {
|
find: (terminal) => {
|
||||||
try {
|
try {
|
||||||
const buf = terminal.buffer.active;
|
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);
|
const line = buf.getLine(buf.viewportY + row);
|
||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
const text = line.translateToString(true);
|
const text = line.translateToString(true);
|
||||||
const idx = text.lastIndexOf('\u276f');
|
const idx = text.lastIndexOf('\u276f');
|
||||||
if (idx >= 0) return { row, col: idx + 2 };
|
if (idx >= 0) return { row, col: idx + 2 };
|
||||||
}
|
}
|
||||||
return {
|
return { row: cursorRow, col: Math.max(0, Math.min(terminal.cols - 1, buf.cursorX)) };
|
||||||
row: Math.max(0, Math.min(terminal.rows - 1, buf.cursorY)),
|
|
||||||
col: Math.max(0, Math.min(terminal.cols - 1, buf.cursorX)),
|
|
||||||
};
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -470,3 +470,100 @@ describe('insertTerminalText under predict policy', () => {
|
|||||||
expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled();
|
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