test(pi-tui): add regression tests for slash command TUI interactions

Add 3 new tests covering editor↔selector/input component swaps that
happen during /gsd prefs, /gsd migrate, and /gsd setup:

- editor-to-selector swap: verifies cursor tracking when editor with
  CURSOR_MARKER is replaced by a selector without one
- selector-to-editor swap: verifies cursor restores to CURSOR_MARKER
  position when editor returns after selector dismissal
- input component swap: verifies typing in prefs wizard text input
  produces correct cursor movement without jumps

All tests confirm hardwareCursorRow baseline computes correct movement
deltas for these interactive component transitions.
This commit is contained in:
Jeremy 2026-04-08 12:09:21 -05:00
parent d29d086f6a
commit d98456cad7

View file

@ -101,6 +101,182 @@ describe("TUI cursor tracking regression (#3764)", () => {
);
});
it("handles editor-to-selector swap without cursor corruption", () => {
// Simulates /gsd prefs: editor with CURSOR_MARKER is replaced by
// a selector component (no CURSOR_MARKER) that has different line count.
const terminal = new MockTTYTerminal();
const tui = new TUI(terminal, false);
// Initial state: chat + editor with cursor marker (typical idle state)
const chatLines = Array.from({ length: 15 }, (_, i) => `chat line ${i}`);
const editorComponent = new DynamicLinesComponent([
...chatLines,
`> ${CURSOR_MARKER}`, // editor input line with cursor
]);
tui.addChild(editorComponent);
(tui as any).doRender();
// Cursor should be at the CURSOR_MARKER line (row 15)
assert.strictEqual(
(tui as any).hardwareCursorRow,
15,
"hardwareCursorRow should be at editor cursor position (row 15)",
);
// Now swap editor for selector (simulating showExtensionSelector)
terminal.writtenData = [];
editorComponent.lines = [
...chatLines,
"─── Select preference ───",
"→ Model routing",
" Timeouts",
" Budget",
" Cancel",
"─────────────────────────",
];
(tui as any).doRender();
assert.ok(terminal.writtenData.length >= 1, "selector render should produce output");
const buffer = terminal.writtenData[0];
// Verify no extremely large cursor jumps that would cause visual corruption
const hugeJump = buffer.match(/\x1b\[(\d{2,})A/);
if (hugeJump) {
const jumpSize = parseInt(hugeJump[1], 10);
assert.ok(
jumpSize < 20,
`cursor jump of ${jumpSize} rows is too large — likely a baseline desync, got: ${JSON.stringify(buffer.slice(0, 200))}`,
);
}
// hardwareCursorRow should NOT be at old IME position
// since there's no CURSOR_MARKER in the selector
const hwRow = (tui as any).hardwareCursorRow;
assert.ok(
hwRow >= 15 && hwRow <= 20,
`hardwareCursorRow should be at rendered content (${hwRow}), not stuck at old IME position`,
);
// Now simulate user pressing ↓ in selector (one line changes)
terminal.writtenData = [];
editorComponent.lines = [
...chatLines,
"─── Select preference ───",
" Model routing",
"→ Timeouts",
" Budget",
" Cancel",
"─────────────────────────",
];
(tui as any).doRender();
if (terminal.writtenData.length > 0) {
const navBuffer = terminal.writtenData[0];
// The differential render should only update the 2 changed lines (16 and 17)
// Verify no large upward jumps from wrong baseline
const navJump = navBuffer.match(/\x1b\[(\d{2,})A/);
if (navJump) {
const jumpSize = parseInt(navJump[1], 10);
assert.ok(
jumpSize < 20,
`navigation caused jump of ${jumpSize} rows — cursor baseline may be wrong`,
);
}
}
});
it("handles selector-to-editor swap restoring cursor correctly", () => {
// After dismissing a selector, the editor returns with CURSOR_MARKER.
// The cursor must move to the new marker position without corruption.
const terminal = new MockTTYTerminal();
const tui = new TUI(terminal, false);
const chatLines = Array.from({ length: 10 }, (_, i) => `chat ${i}`);
const component = new DynamicLinesComponent([
...chatLines,
"─── Selector ───",
"→ Option A",
" Option B",
"────────────────",
]);
tui.addChild(component);
(tui as any).doRender();
// No CURSOR_MARKER → cursor stays at last rendered line
const hwRowAfterSelector = (tui as any).hardwareCursorRow;
// Swap back to editor with CURSOR_MARKER
terminal.writtenData = [];
component.lines = [
...chatLines,
`> ${CURSOR_MARKER}`,
];
(tui as any).doRender();
// CURSOR_MARKER is at row 10 — cursor should be positioned there
assert.strictEqual(
(tui as any).hardwareCursorRow,
10,
"hardwareCursorRow should move to editor cursor after selector dismiss",
);
});
it("handles input component swap (prefs wizard text input)", () => {
// Simulates /gsd prefs input step: selector replaced by text input with cursor
const terminal = new MockTTYTerminal();
const tui = new TUI(terminal, false);
const chatLines = Array.from({ length: 8 }, (_, i) => `msg ${i}`);
const component = new DynamicLinesComponent([
...chatLines,
"─── Enter value ───",
`Value: ${CURSOR_MARKER}`,
"───────────────────",
]);
tui.addChild(component);
(tui as any).doRender();
assert.strictEqual(
(tui as any).hardwareCursorRow,
9,
"hardwareCursorRow should be at input cursor (row 9)",
);
// Simulate typing in the input
terminal.writtenData = [];
component.lines = [
...chatLines,
"─── Enter value ───",
`Value: hello${CURSOR_MARKER}`,
"───────────────────",
];
(tui as any).doRender();
assert.ok(terminal.writtenData.length >= 1, "typing should trigger render");
const buffer = terminal.writtenData[0];
// Should not jump to wrong row — only line 9 changed
const upJump = buffer.match(/\x1b\[(\d+)A/);
if (upJump) {
const jumpSize = parseInt(upJump[1], 10);
// Cursor was at row 9 (IME), need to go to row 9 (changed line) = no jump needed
assert.ok(jumpSize <= 1, `typing in input caused unexpected up-jump of ${jumpSize}`);
}
assert.strictEqual(
(tui as any).hardwareCursorRow,
9,
"hardwareCursorRow should stay at input cursor after typing",
);
});
it("hardwareCursorRow tracks actual terminal position through IME and shrink", () => {
const terminal = new MockTTYTerminal();
const tui = new TUI(terminal, false);