Files
harness-tools/extensions/pi-workflow/tui.test.ts
T

305 lines
10 KiB
TypeScript

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<Focusable>;
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);
});