import assert from "node:assert/strict"; import test from "node:test"; import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; import { CURSOR_MARKER, visibleWidth, wrapTextWithAnsi, type Component, type Focusable, type TUI } from "@earendil-works/pi-tui"; import { createPlanReaderModel, createPlanReaderState, reducePlanReaderState, renderPlanReader, sanitizeTerminalText, workflowEditor, workflowInput, WorkflowSelectionOverlay, } from "./tui.ts"; const theme = { fg: (_color: string, text: string) => text, bg: (_color: string, text: string) => text, bold: (text: string) => text, }; const plan = [ "# Workflow plan", "intro", "## First section", "first body", "### Detail", "detail body", "## Last section", "last body", ].join("\n"); test("selection overlay windows long lists, stays width-safe, and returns stable values", () => { let selected: string | undefined; const overlay = new WorkflowSelectionOverlay( "Workflow Runs", Array.from({ length: 30 }, (_, index) => ({ value: `run-${index}`, label: `feature-${index}`, description: index === 0 ? "waiting" : undefined, })), theme as never, (value) => { selected = value; }, ); const initial = overlay.render(48); assert.ok(initial.some((line) => line.includes("1-12 of 30"))); assert.ok(initial.every((line) => visibleWidth(line) <= 48)); overlay.handleInput("\x1b[F"); const last = overlay.render(48); assert.ok(last.some((line) => line.includes("19-30 of 30"))); overlay.handleInput("\r"); assert.equal(selected, "run-29"); }); test("selection overlay budgets wrapped descriptions for short narrow terminals", () => { let selected: string | undefined; const overlay = new WorkflowSelectionOverlay( "Short terminal", Array.from({ length: 20 }, (_, index) => ({ value: `choice-${index}`, label: `choice-${index}`, description: index === 19 ? "A long selected description that wraps repeatedly without hiding navigation controls" : undefined, })), theme as never, (value) => { selected = value; }, 8, ); overlay.handleInput("\x1b[F"); const lines = overlay.render(28); assert.ok(lines.length <= 8); assert.ok(lines.every((line) => visibleWidth(line) <= 28)); assert.ok(lines.some((line) => line.includes("> choice-19"))); assert.ok(lines.some((line) => line.includes("enter select"))); overlay.handleInput("\x1b[A"); assert.ok(overlay.render(28).some((line) => line.includes("> choice-18"))); overlay.handleInput("\r"); assert.equal(selected, "choice-18"); }); type InteractiveOverlay = Component & Partial; type OverlayFactory = ( tui: TUI, overlayTheme: Theme, keybindings: object, done: (value: unknown) => void, ) => InteractiveOverlay; function interactionContext( interact: (component: InteractiveOverlay) => void, rows = 24, ) { const tui = { requestRender() {}, terminal: { columns: 80, rows }, } as unknown as TUI; return { mode: "tui", ui: { async custom(factory: OverlayFactory) { let completed: unknown; const component = factory( tui, theme as unknown as Theme, {}, (value) => { completed = value; }, ); interact(component); return completed; }, }, } as unknown as ExtensionContext; } test("input overlay propagates focus, renders a cursor marker, and handles input", async () => { let unfocused = ""; let focused = ""; const result = await workflowInput( interactionContext((component) => { unfocused = component.render(48).join("\n"); component.focused = true; component.handleInput?.("approved"); focused = component.render(48).join("\n"); component.handleInput?.("\r"); }), "Answer", "Provide a value", ); assert.equal(unfocused.includes(CURSOR_MARKER), false); assert.equal(focused.includes(CURSOR_MARKER), true); assert.match(focused, /approved/); assert.equal(result, "approved"); }); test("input overlay keeps focused input and controls visible below a long prompt", async () => { const prompt = Array.from( { length: 20 }, (_, index) => `long prompt segment ${index}`, ).join(" "); let unfocused = ""; let focused = ""; const result = await workflowInput( interactionContext((component) => { unfocused = component.render(40).join("\n"); component.focused = true; component.handleInput?.("yes"); focused = component.render(40).join("\n"); component.handleInput?.("\r"); }, 8), "Short terminal", prompt, ); assert.ok(unfocused.split("\n").length <= 5); assert.equal(unfocused.includes(CURSOR_MARKER), false); assert.ok(focused.split("\n").length <= 5); assert.ok(focused.split("\n").every((line) => visibleWidth(line) <= 40)); assert.equal(focused.includes(CURSOR_MARKER), true); assert.match(focused, /yes/); assert.match(focused, /enter submit esc back/); assert.match(focused, /…/); assert.equal(result, "yes"); }); test("editor overlay propagates focus and Escape resolves through the wrapper", async () => { let focused = ""; const result = await workflowEditor( interactionContext((component) => { component.focused = true; component.handleInput?.("x"); focused = component.render(56).join("\n"); component.handleInput?.("\x1b"); }), "Feedback", "Revise the plan", "draft", ); assert.equal(focused.includes(CURSOR_MARKER), true); assert.match(focused, /draft/); assert.equal(result, undefined); }); test("plan reader parses H1-H3 and tracks bounded navigation state", () => { const model = createPlanReaderModel("example", plan); assert.deepEqual( model.headings.map(({ level, text, line }) => ({ level, text, line })), [ { level: 1, text: "Workflow plan", line: 0 }, { level: 2, text: "First section", line: 2 }, { level: 3, text: "Detail", line: 4 }, { level: 2, text: "Last section", line: 6 }, ], ); let state = createPlanReaderState(); state = reducePlanReaderState(model, state, "up", { width: 60, height: 24 }); assert.equal(state.topRow, 0); state = reducePlanReaderState(model, state, "page-down", { width: 60, height: 12 }); assert.equal(state.topRow, 7); state = reducePlanReaderState(model, state, "home", { width: 60, height: 12 }); assert.equal(state.topRow, 0); state = reducePlanReaderState(model, state, "end", { width: 60, height: 24 }); assert.equal(state.topRow, 7); state = reducePlanReaderState(model, state, "down", { width: 60, height: 24 }); assert.equal(state.topRow, 7); }); test("plan reader renders outline at 80 columns and one pane below 80", () => { const model = createPlanReaderModel("example", plan); const wide = renderPlanReader(model, { topRow: 4 }, { width: 80, height: 24 }); assert.ok(wide.some((line) => line.includes("> Detail"))); assert.ok(wide.some((line) => line.includes("detail body"))); assert.ok(wide.every((line) => visibleWidth(line) <= 80)); assert.ok(wide.length <= 24); const narrow = renderPlanReader(model, { topRow: 4 }, { width: 60, height: 12 }); assert.match(narrow[0] ?? "", /Detail/); assert.equal(narrow.some((line) => line.includes("> Detail")), false); assert.ok(narrow.every((line) => visibleWidth(line) <= 60)); assert.ok(narrow.length <= 12); }); test("wide outline windows enough headings to keep the current section visible", () => { const contents = Array.from( { length: 40 }, (_, index) => `## Section ${index}\nbody ${index}`, ).join("\n"); const model = createPlanReaderModel("many sections", contents); const lines = renderPlanReader( model, { topRow: 70 }, { width: 80, height: 12 }, ); assert.ok(lines.some((line) => />\s+Section 35/.test(line))); assert.equal(lines.some((line) => line.includes("Section 0")), false); }); test("plan reader reports truthful minimum size below either boundary", () => { const model = createPlanReaderModel("example", plan); for (const dimensions of [ { width: 47, height: 11 }, { width: 47, height: 24 }, { width: 80, height: 11 }, ]) { const lines = renderPlanReader(model, createPlanReaderState(), dimensions); assert.ok(lines.some((line) => line.includes("needs 48x12"))); assert.ok( lines.some((line) => line.includes(`current terminal is ${dimensions.width}x${dimensions.height}`), ), ); assert.ok(lines.every((line) => visibleWidth(line) <= dimensions.width)); assert.ok(lines.length <= dimensions.height); } }); test("reader sanitizes terminal control sequences before rendering", () => { const unsafe = "# Safe\n\x1b[31mred\x1b[0m\n\x1b]8;;https://bad.invalid\x07link\x1b]8;;\x07\nend\u0000"; const sanitized = sanitizeTerminalText(unsafe); assert.equal(sanitized.includes("\x1b"), false); assert.equal(sanitized.includes("\u0000"), false); const model = createPlanReaderModel("unsafe", unsafe); const lines = renderPlanReader(model, createPlanReaderState(), { width: 48, height: 12 }); assert.equal(lines.join("\n").includes("\x1b"), false); assert.ok(lines.every((line) => visibleWidth(line) <= 48)); }); test("plan reader reaches every wrapped display row before following content", () => { const longLine = Array.from( { length: 40 }, (_, index) => `chunk-${String(index).padStart(2, "0")}`, ).join(" "); const model = createPlanReaderModel( "wrapped rows", `# Wrapped section\n${longLine}\nfollowing-content`, ); const expectedTopRows = [ "# Wrapped section", ...wrapTextWithAnsi(longLine, 46), "following-content", ]; let state = createPlanReaderState(); for (const expected of expectedTopRows) { const frame = renderPlanReader(model, state, { width: 48, height: 12 }); assert.equal(frame[2]?.trim(), expected); assert.match(frame[0] ?? "", /Wrapped sect/); state = reducePlanReaderState( model, state, "down", { width: 48, height: 12 }, ); } assert.ok(expectedTopRows.length > 5); }); test("reader only includes the active viewport near document end", () => { const contents = Array.from({ length: 200 }, (_, index) => `line-${index}`).join("\n"); const model = createPlanReaderModel("large", contents); const lines = renderPlanReader(model, { topRow: 190 }, { width: 60, height: 12 }); assert.ok(lines.some((line) => line.includes("line-190"))); assert.equal(lines.some((line) => line.includes("line-20")), false); assert.ok(lines.length <= 12); });