feat: package Pi extensions as Bun workspaces
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user