diff --git a/packages/pi-tui/src/tui.ts b/packages/pi-tui/src/tui.ts index 7a078f2d3..3408016a0 100644 --- a/packages/pi-tui/src/tui.ts +++ b/packages/pi-tui/src/tui.ts @@ -616,9 +616,10 @@ export class TUI extends Container { const height = this.terminal.rows; let viewportTop = Math.max(0, this.maxLinesRendered - height); let prevViewportTop = this.previousViewportTop; + let contentCursorRow = this.cursorRow; let hardwareCursorRow = this.hardwareCursorRow; const computeLineDiff = (targetRow: number): number => { - const currentScreenRow = hardwareCursorRow - prevViewportTop; + const currentScreenRow = contentCursorRow - prevViewportTop; const targetScreenRow = targetRow - viewportTop; return targetScreenRow - currentScreenRow; }; @@ -805,6 +806,7 @@ export class TUI extends Container { buffer += "\r\n".repeat(scroll); prevViewportTop += scroll; viewportTop += scroll; + contentCursorRow = moveTargetRow; hardwareCursorRow = moveTargetRow; } diff --git a/src/tests/tui-autocomplete-ghost-lines.test.ts b/src/tests/tui-autocomplete-ghost-lines.test.ts new file mode 100644 index 000000000..2faf22247 --- /dev/null +++ b/src/tests/tui-autocomplete-ghost-lines.test.ts @@ -0,0 +1,85 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { CURSOR_MARKER, TUI, type Component, type Terminal } from "@gsd/pi-tui"; + +class MockTTYTerminal implements Terminal { + public writtenData: string[] = []; + + readonly isTTY = true; + + start(_onInput: (data: string) => void, _onResize: () => void): void {} + stop(): void {} + async drainInput(_maxMs?: number, _idleMs?: number): Promise {} + + write(data: string): void { + this.writtenData.push(data); + } + + get columns(): number { + return 80; + } + + get rows(): number { + return 24; + } + + get kittyProtocolActive(): boolean { + return false; + } + + moveBy(_lines: number): void {} + hideCursor(): void {} + showCursor(): void {} + clearLine(): void {} + clearFromCursor(): void {} + clearScreen(): void {} + setTitle(_title: string): void {} +} + +class DynamicLinesComponent implements Component { + public lines: string[]; + + constructor(lines: string[]) { + this.lines = lines; + } + + render(_width: number): string[] { + return this.lines; + } + + invalidate(): void {} +} + +describe("TUI autocomplete shrink clearing (#3721)", () => { + it("clears deleted autocomplete rows relative to the content bottom, not the IME cursor row", () => { + const terminal = new MockTTYTerminal(); + const tui = new TUI(terminal, false); + const component = new DynamicLinesComponent([ + "top border", + `prompt${CURSOR_MARKER}`, + "editor body", + "autocomplete row 1", + "autocomplete row 2", + "autocomplete row 3", + ]); + + tui.addChild(component); + (tui as any).doRender(); + + terminal.writtenData = []; + component.lines = [ + "top border", + `prompt${CURSOR_MARKER}`, + "editor body", + "autocomplete row 1", + ]; + + (tui as any).doRender(); + + assert.ok(terminal.writtenData.length >= 1, "shrink render should write a differential buffer"); + assert.ok( + terminal.writtenData[0].startsWith("\x1b[?2026h\x1b[2A\r"), + `expected shrink diff to move up from prior content bottom, got ${JSON.stringify(terminal.writtenData[0])}`, + ); + }); +});