305 lines
10 KiB
TypeScript
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);
|
|
});
|