From a28a56b3e4ec4088463b1e419867547b3bf3a48d Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 7 Apr 2026 22:39:00 -0500 Subject: [PATCH] test(pi-tui): add regression tests for contentCursorRow tracking Verify that contentCursorRow is correctly maintained across renders and that IME repositioning does not cause spurious cursor jumps during normal typing or content shrinking. Refs #3764 --- src/tests/tui-content-cursor-desync.test.ts | 151 ++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 src/tests/tui-content-cursor-desync.test.ts diff --git a/src/tests/tui-content-cursor-desync.test.ts b/src/tests/tui-content-cursor-desync.test.ts new file mode 100644 index 000000000..1fa31148e --- /dev/null +++ b/src/tests/tui-content-cursor-desync.test.ts @@ -0,0 +1,151 @@ +/** + * Regression test for #3764: TUI input clears and jumps up after PR #3744. + * + * PR #3744 used this.cursorRow (content end) as the movement baseline in + * computeLineDiff, but it should be the post-render cursor position + * (finalCursorRow). This test verifies that after IME cursor repositioning, + * the next render computes correct movement deltas — no spurious jumps. + */ + +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 contentCursorRow tracking (#3764)", () => { + it("does not produce spurious cursor jumps when content changes after IME positioning", () => { + const terminal = new MockTTYTerminal(); + const tui = new TUI(terminal, false); + const component = new DynamicLinesComponent([ + "header", + `input: hello${CURSOR_MARKER}`, + "status line", + ]); + + tui.addChild(component); + (tui as any).doRender(); + + // After first render, hardwareCursorRow is at IME position (row 1), + // but contentCursorRow should be at finalCursorRow (row 2, end of content). + // Verify contentCursorRow is set correctly. + assert.strictEqual( + (tui as any).contentCursorRow, + 2, + "contentCursorRow should be at content end (row 2) after first render", + ); + assert.strictEqual( + (tui as any).hardwareCursorRow, + 1, + "hardwareCursorRow should be at IME cursor position (row 1) after positionHardwareCursor", + ); + + // Simulate typing — content changes on the same line + terminal.writtenData = []; + component.lines = [ + "header", + `input: hello world${CURSOR_MARKER}`, + "status line", + ]; + + (tui as any).doRender(); + + // The differential render should update line 1 (the changed input line). + // With the bug from PR #3744, computeLineDiff would use this.cursorRow (2) + // instead of contentCursorRow (2), which happened to be the same — but the + // critical test is that the buffer does NOT contain large cursor jumps. + assert.ok(terminal.writtenData.length >= 1, "typing should trigger a render"); + + const buffer = terminal.writtenData[0]; + // Should not contain \x1b[2A or \x1b[3A etc. (large upward jumps) + const largeUpJump = buffer.match(/\x1b\[([3-9]|\d{2,})A/); + assert.strictEqual( + largeUpJump, + null, + `should not produce large upward cursor jumps, got: ${JSON.stringify(buffer)}`, + ); + }); + + it("contentCursorRow persists correctly across renders with shrinking content", () => { + const terminal = new MockTTYTerminal(); + const tui = new TUI(terminal, false); + const component = new DynamicLinesComponent([ + "line 1", + `line 2${CURSOR_MARKER}`, + "line 3", + "line 4", + "line 5", + ]); + + tui.addChild(component); + (tui as any).doRender(); + + assert.strictEqual( + (tui as any).contentCursorRow, + 4, + "contentCursorRow should be 4 after rendering 5 lines", + ); + + // Shrink content + terminal.writtenData = []; + component.lines = [ + "line 1", + `line 2${CURSOR_MARKER}`, + "line 3", + ]; + + (tui as any).doRender(); + + assert.strictEqual( + (tui as any).contentCursorRow, + 2, + "contentCursorRow should update to 2 after shrinking to 3 lines", + ); + }); +});