import assert from "node:assert/strict"; import test from "node:test"; import { visibleWidth } from "@earendil-works/pi-tui"; import { currentTaskLabel, currentTaskTokens } from "../src/herdr.ts"; import { TaskState } from "../src/state.ts"; import { renderProgressHeader, renderTaskLine, renderToolResult, renderTree, snapshotStats, } from "../src/ui.ts"; import { updateTaskWidget } from "../src/widget.ts"; const plainTheme = { fg: (_color: string, text: string) => text, bold: (text: string) => text, strikethrough: (text: string) => `~${text}~`, }; function stripAnsi(text: string): string { return text.replace(/\x1b\[[0-9;]*m/g, ""); } function fixture(): TaskState { const state = new TaskState(); state.add([ { name: "Build auth", description: "Auth work", status: "in-progress", children: [ { name: "Resolve token issue", description: "Investigate token validation and provider behavior.", status: "blocked", children: [{ name: "Inspect provider response", description: "Inspect response" }], }, { name: "Add middleware", description: "Middleware" }, ], }, { name: "Define schema", description: "Schema", status: "done" }, ]); return state; } test("renders an adaptive progress bar and stays within width", () => { const stats = fixture().stats(); const wide = stripAnsi(renderProgressHeader(stats, plainTheme as never, 40)); assert.match(wide, /^ Tasks 1\/5 · !1 ━{4}─{16}$/); assert.ok(visibleWidth(wide) <= 40); const narrow = renderProgressHeader(stats, plainTheme as never, 12); assert.ok(visibleWidth(narrow) <= 12); assert.doesNotMatch(stripAnsi(narrow), /━|─/); }); test("renders lightweight connectors without a detached root continuation", () => { const output = renderTree(fixture().list(), plainTheme as never, 80).map(stripAnsi); assert.deepEqual(output, [ " ◉ 1. Build auth", " ├─ ! 2. Resolve token issue", " │ └─ ○ 3. Inspect provider response", " └─ ○ 4. Add middleware", " ✓ 5. ~Define schema~", ]); }); test("truncates every task row at narrow widths", () => { const lines = renderTree(fixture().list(), plainTheme as never, 20); assert.ok(lines.every((line) => visibleWidth(line) <= 20)); assert.ok(lines.some((line) => stripAnsi(line).includes("..."))); }); test("uses semantic icon colors and old-plugin text styling", () => { const calls: Array<{ color: string; text: string }> = []; const theme = { fg: (color: string, text: string) => { calls.push({ color, text }); return text; }, bold: (text: string) => text, strikethrough: (text: string) => `~${text}~`, }; const tasks = fixture().list(); for (const task of tasks) renderTaskLine(task, "", theme as never, 80); assert.ok(calls.some((call) => call.color === "dim" && call.text === "○")); assert.ok(calls.some((call) => call.color === "warning" && call.text === "◉")); assert.ok(calls.some((call) => call.color === "error" && call.text === "!")); assert.ok(calls.some((call) => call.color === "success" && call.text === "✓")); assert.ok(calls.some((call) => call.color === "warning" && call.text === "Build auth")); assert.ok(calls.some((call) => call.color === "dim" && call.text === "~Define schema~")); assert.ok(!calls.some((call) => call.color === "error" && call.text === "Resolve token issue")); }); test("selects the most specific in-progress task and compresses parallel leaves", () => { const state = new TaskState(); state.add([ { name: "Parent", description: "Parent", status: "in-progress", children: [{ name: "Child", description: "Child", status: "in-progress" }], }, { name: "Parallel", description: "Parallel", status: "in-progress" }, ]); assert.equal(currentTaskLabel(state.list()), "#2 Child +1"); assert.deepEqual(currentTaskTokens(state.list()), { task: "#2 Child", task_count: "+1" }); }); test("uses blocked tasks only when nothing is in progress", () => { const state = new TaskState(); state.add([ { name: "Blocked one", description: "One", status: "blocked" }, { name: "Blocked two", description: "Two", status: "blocked" }, ]); assert.equal(currentTaskLabel(state.list()), "#1 Blocked one +1"); assert.deepEqual(currentTaskTokens(state.list()), { task: "#1 Blocked one", task_count: "+1" }); state.update([{ id: 1, status: "done" }, { id: 2, status: "done" }]); assert.equal(currentTaskLabel(state.list()), null); assert.deepEqual(currentTaskTokens(state.list()), { task: null, task_count: null }); }); test("hides an empty widget and renders a populated one", () => { let widget: unknown = "unset"; const ctx = { ui: { setWidget: (_id: string, content: unknown) => { widget = content; }, }, }; const state = new TaskState(); updateTaskWidget(state, ctx as never, true); assert.equal(widget, undefined); state.add([{ name: "Task", description: "Task" }]); updateTaskWidget(state, ctx as never, true); assert.equal(typeof widget, "function"); const component = (widget as unknown as (tui: unknown, theme: unknown) => { render(width: number): string[] })( {}, plainTheme, ); const lines = component.render(30); assert.equal(lines.length, 2); assert.ok(lines.every((line) => visibleWidth(line) <= 30)); }); test("tool results collapse summaries and expand themed details", () => { const state = fixture(); const details = { action: "list" as const, snapshot: state.snapshot(), affected: state.list() }; const result = { content: [{ type: "text", text: "list" }], details }; const collapsed = renderToolResult(result, false, plainTheme as never).render(80).map(stripAnsi); assert.deepEqual(collapsed, ["✓ 5 tasks · 1/5 done"]); const expanded = renderToolResult(result, true, plainTheme as never).render(80).map(stripAnsi); assert.equal(expanded.length, 6); assert.match(expanded[1] ?? "", /Build auth/); }); test("tool errors show one summary line until expanded", () => { const result = { content: [{ type: "text", text: "Update failed · 2 validation errors\n- first\n- second" }], isError: true, }; const collapsed = renderToolResult(result, false, plainTheme as never).render(80).map(stripAnsi); assert.deepEqual(collapsed, ["✗ Update failed · 2 validation errors"]); const expanded = renderToolResult(result, true, plainTheme as never).render(80).map(stripAnsi); assert.deepEqual(expanded, ["✗ Update failed · 2 validation errors", " - first", " - second"]); }); test("snapshot stats count every task node", () => { assert.deepEqual(snapshotStats(fixture().list()), { total: 5, pending: 2, inProgress: 1, blocked: 1, done: 1, }); });