singularity-forge/src/tests/tui-content-cursor-desync.test.ts
Mikael Hugo 02a4339a51 refactor: rename pi-* packages to forge-native names (Phase 1)
Rename all four packages/pi-* directories to forge-native names,
stripping the 'pi' identity and establishing forge's own:

- packages/pi-coding-agent → packages/coding-agent
- packages/pi-ai → packages/ai
- packages/pi-agent-core → packages/agent-core
- packages/pi-tui → packages/tui

Package names updated:
- @singularity-forge/pi-coding-agent → @singularity-forge/coding-agent
- @singularity-forge/pi-ai → @singularity-forge/ai
- @singularity-forge/pi-agent-core → @singularity-forge/agent-core
- @singularity-forge/pi-tui → @singularity-forge/tui

All import references, bare string references, path references,
internal variable names (_bundledPi*), and dist files updated.
@mariozechner/pi-* third-party compat aliases preserved.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-10 11:28:01 +02:00

325 lines
9 KiB
TypeScript

/**
* Regression test for #3764: TUI input clears and jumps up after PR #3744.
*
* PR #3744 introduced contentCursorRow which diverged from the actual terminal
* cursor position, causing computeLineDiff to compute wrong movement deltas.
* The fix reverts to using hardwareCursorRow (actual cursor position) as the
* baseline for all cursor movement calculations.
*/
import assert from "node:assert/strict";
import {
type Component,
CURSOR_MARKER,
type Terminal,
TUI,
} from "@singularity-forge/tui";
import { describe, it } from "vitest";
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<void> {}
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 cursor tracking regression (#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)
assert.strictEqual(
(tui as any).hardwareCursorRow,
1,
"hardwareCursorRow should be at IME cursor position (row 1)",
);
// Simulate typing — content changes on the same line
terminal.writtenData = [];
component.lines = [
"header",
`input: hello world${CURSOR_MARKER}`,
"status line",
];
(tui as any).doRender();
assert.ok(
terminal.writtenData.length >= 1,
"typing should trigger a render",
);
const buffer = terminal.writtenData[0];
// Should not contain large upward jumps (3+ rows)
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("handles editor-to-selector swap without cursor corruption", () => {
// Simulates /sf 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 /sf 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);
const component = new DynamicLinesComponent([
"line 1",
`line 2${CURSOR_MARKER}`,
"line 3",
"line 4",
"line 5",
]);
tui.addChild(component);
(tui as any).doRender();
// After IME positioning, hardwareCursorRow is at CURSOR_MARKER line (row 1)
assert.strictEqual(
(tui as any).hardwareCursorRow,
1,
"hardwareCursorRow should be at IME position (row 1) after first render",
);
// Shrink content
terminal.writtenData = [];
component.lines = ["line 1", `line 2${CURSOR_MARKER}`, "line 3"];
(tui as any).doRender();
// After shrink, hardwareCursorRow should be at IME position again
assert.strictEqual(
(tui as any).hardwareCursorRow,
1,
"hardwareCursorRow should be at IME position after shrink render",
);
});
});