From d98456cad77bdbfd9d76ae972dff9a17330c9417 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Wed, 8 Apr 2026 12:09:21 -0500 Subject: [PATCH] test(pi-tui): add regression tests for slash command TUI interactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/tests/tui-content-cursor-desync.test.ts | 176 ++++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/src/tests/tui-content-cursor-desync.test.ts b/src/tests/tui-content-cursor-desync.test.ts index dd840ed5d..b2a99c206 100644 --- a/src/tests/tui-content-cursor-desync.test.ts +++ b/src/tests/tui-content-cursor-desync.test.ts @@ -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);