mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-12 02:09:42 +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:
@@ -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