import assert from "node:assert/strict"; import test from "node:test"; import { Text, visibleWidth } from "@earendil-works/pi-tui"; import { boxToolCall, boxToolResult } from "../src/tool-call-box.ts"; interface BoxThemeLike { fg(color: string, text: string): string; bold(text: string): string; } const passThroughTheme: BoxThemeLike = { fg: (_color: string, text: string): string => text, bold: (text: string): string => text, }; function render(component: unknown, width = 60): string[] { return (component as { render(totalWidth: number): string[] }).render(width); } test("boxed call renders the startup-style ASCII top frame around the call", () => { const call = boxToolCall(new Text("$ ls", 0, 0), passThroughTheme, true); const lines = render(call); assert.match(lines[0] ?? "", /╭─ Tools ─/); assert.ok(lines.some((line) => line.includes("$ ls")), "call content should be inside the box"); assert.match(lines.at(-1) ?? "", /│/, "call row should have side borders"); }); test("boxed result renders the bottom frame around the output", () => { const result = boxToolResult(new Text("a.txt", 0, 0), passThroughTheme, true); const lines = render(result); assert.match(lines.at(-1) ?? "", /╰─+╯/); assert.ok(lines.some((line) => line.includes("a.txt")), "result content should be inside the box"); }); test("stacked call and result form one contiguous ASCII box", () => { const call = boxToolCall(new Text("$ ls", 0, 0), passThroughTheme, true); const result = boxToolResult(new Text("a.txt\nb.txt", 0, 0), passThroughTheme, true); // Under renderShell "self" the host stacks the call half then the result // half (with a leading spacer), so concatenating their lines is exactly // what the TUI shows: one contiguous box. const lines = [...render(call), ...render(result)]; const tops = lines.filter((line) => /╭/.test(line)); const bottoms = lines.filter((line) => /╰/.test(line)); assert.equal(tops.length, 1, "exactly one top border across the pair"); assert.equal(bottoms.length, 1, "exactly one bottom border across the pair"); assert.match(lines[0] ?? "", /╭─ Tools ─/); assert.match(lines.at(-1) ?? "", /╰─+╯/); assert.ok(lines.some((line) => line.includes("$ ls")), "call row present"); assert.ok(lines.some((line) => line.includes("a.txt")), "first result row present"); assert.ok(lines.some((line) => line.includes("b.txt")), "second result row present"); const borderedRows = lines.filter((line) => /[╭╰│]/.test(line)); assert.ok(borderedRows.length >= 5, "top, two content rows, result row, bottom"); }); test("disabled box passthrough keeps the original component", () => { const call = new Text("$ ls", 0, 0); const result = new Text("a.txt", 0, 0); assert.equal(boxToolCall(call, passThroughTheme, false), call); assert.equal(boxToolResult(result, passThroughTheme, false), result); }); test("narrow widths fall back to raw rendering instead of overflowing", () => { const call = boxToolCall(new Text("$ ls", 0, 0), passThroughTheme, true); const lines = render(call, 4); assert.equal(lines.join("\n"), "$ ls"); }); test("long content is width-safe and does not exceed the border width", () => { const long = new Text("a".repeat(200), 0, 0); const call = boxToolCall(long, passThroughTheme, true); const lines = render(call, 40); for (const line of lines) { if (/[╭╰│]/.test(line)) { assert.ok(line.length <= 41, `border line too long: ${line.length}`); } } }); test("framed lines are cached by width and reused across renders", () => { const call = boxToolCall(new Text("$ ls", 0, 0), passThroughTheme, true); const first = render(call); const second = render(call); assert.equal(second, first, "unchanged render should reuse the cached array"); }); test("invalidate clears the frame cache", () => { const call = boxToolCall(new Text("$ ls", 0, 0), passThroughTheme, true); const first = render(call); call.invalidate?.(); const second = render(call); assert.notEqual(second, first, "invalidate should force a rebuild"); assert.deepEqual(second, first, "content is unchanged, so lines must be identical"); }); test("width change re-renders the frame", () => { const call = boxToolCall(new Text("$ ls", 0, 0), passThroughTheme, true); const narrow = render(call, 30); const wide = render(call, 60); assert.notEqual(wide, narrow, "different width must rebuild the frame"); assert.ok(wide[0]?.includes("─"), "wide frame should still be boxed"); }); test("unset outputPad keeps the recorded single-space layout", () => { const call = boxToolCall(new Text("$ ls", 0, 0), passThroughTheme, true); const lines = render(call, 20); assert.deepEqual(lines, ["╭─ Tools " + "─".repeat(10) + "╮", "│ $ ls" + " ".repeat(12) + " │"]); }); test("unset outputPad is byte-identical to outputPad 1", () => { const call = new Text("$ ls", 0, 0); const result = new Text("a.txt", 0, 0); assert.deepEqual( render(boxToolCall(call, passThroughTheme, true, { outputPad: 1 }), 20), render(boxToolCall(call, passThroughTheme, true), 20), ); assert.deepEqual( render(boxToolResult(result, passThroughTheme, true, { outputPad: 1 }), 20), render(boxToolResult(result, passThroughTheme, true), 20), ); }); test("outputPad 0, 1 and 3 place the content at narrow and wide widths", () => { for (const outputPad of [0, 1, 3]) { for (const width of [12, 60]) { const call = boxToolCall(new Text("abcdef", 0, 0), passThroughTheme, true, { outputPad }); const lines = render(call, width); const contentWidth = width - 2 - outputPad * 2; const expected = "│" + " ".repeat(outputPad) + "abcdef".slice(0, contentWidth).padEnd(contentWidth) + " ".repeat(outputPad) + "│"; assert.equal(lines[1], expected, `outputPad ${outputPad} at width ${width}`); assert.equal(visibleWidth(lines[1] ?? ""), width); for (const line of lines) { assert.ok(visibleWidth(line) <= width, `line wider than ${width}: ${JSON.stringify(line)}`); } } } }); test("the inner component renders at the width reduced by both pads", () => { const widths: number[] = []; const inner = { render: (width: number) => { widths.push(width); return ["abc"]; }, invalidate: () => {}, } as unknown as Parameters[0]; const call = boxToolCall(inner, passThroughTheme, true, { outputPad: 3 }); const first = render(call, 40); const second = render(call, 40); assert.deepEqual(widths, [32], "40 columns minus two borders and two three-column pads"); assert.equal(second, first, "the reduced-width frame is cached too"); assert.equal(first[1], "│" + " ".repeat(3) + "abc" + " ".repeat(29) + " ".repeat(3) + "│"); }); test("no framed line exceeds the width for outputPad 0, 1 and 3", () => { for (const outputPad of [0, 1, 3]) { for (const width of [8, 9, 10, 12, 40, 100]) { const call = boxToolCall(new Text("x".repeat(500), 0, 0), passThroughTheme, true, { outputPad }); const result = boxToolResult(new Text("y".repeat(500), 0, 0), passThroughTheme, true, { outputPad }); for (const line of [...render(call, width), ...render(result, width)]) { assert.ok( visibleWidth(line) <= width, `outputPad ${outputPad} at width ${width} overflowed: ${visibleWidth(line)} > ${width}`, ); } } } }); test("widths between the frame minimum and the title width use a plain top border", () => { const lines = render(boxToolCall(new Text("$ ls", 0, 0), passThroughTheme, true), 8); assert.equal(lines[0], "╭──────╮"); assert.equal(visibleWidth(lines[0] ?? ""), 8); const labelled = render(boxToolCall(new Text("$ ls", 0, 0), passThroughTheme, true), 10); assert.equal(labelled[0], "╭─ Tools ╮"); }); test("widths too narrow for the pads fall back to raw rendering instead of overflowing", () => { const raw = new Text("abcdefghij", 0, 0); const call = boxToolCall(raw, passThroughTheme, true, { outputPad: 3 }); const lines = render(call, 8); assert.deepEqual(lines, render(raw, 8)); assert.ok(lines.every((line) => !/[╭╰│]/.test(line)), "no frame at a width that cannot hold borders and pads"); }); test("re-wrapping with a different outputPad takes effect and drops the cached frame", () => { const raw = new Text("abc", 0, 0); const padded = boxToolCall(raw, passThroughTheme, true, { outputPad: 3 }); const first = render(padded, 20); assert.equal(first[1], "│" + " ".repeat(3) + "abc".padEnd(12) + " ".repeat(3) + "│"); const rewrapped = boxToolCall(raw, passThroughTheme, true, { outputPad: 0 }); assert.equal(rewrapped, padded, "the same component and role reuse one proxy, so frames do not stack"); const second = render(rewrapped, 20); assert.notEqual(second, first, "the cached frame must be rebuilt for the new outputPad"); assert.equal(second[1], "│abc" + " ".repeat(15) + "│"); }); test("outputPad 0 renders one contiguous box across the call and result halves", () => { const call = boxToolCall(new Text("$ ls", 0, 0), passThroughTheme, true, { outputPad: 0 }); const result = boxToolResult(new Text("a.txt", 0, 0), passThroughTheme, true, { outputPad: 0 }); const lines = [...render(call, 20), ...render(result, 20)]; assert.deepEqual(lines, [ "╭─ Tools " + "─".repeat(10) + "╮", "│$ ls" + " ".repeat(14) + "│", "│a.txt" + " ".repeat(13) + "│", "╰" + "─".repeat(18) + "╯", ]); }); test("re-wrapping the collapsed result for the expanded render stays idempotent", () => { // HTML export passes the collapsed component back as lastComponent and always // renders with outputPad 1; the frame must not stack. const raw = new Text("a.txt", 0, 0); const collapsed = boxToolResult(raw, passThroughTheme, true, { outputPad: 1 }); const collapsedLines = render(collapsed, 20); const expanded = boxToolResult(raw, passThroughTheme, true, { outputPad: 1 }); const expandedLines = render(expanded, 20); assert.equal(expanded, collapsed); assert.deepEqual(expandedLines, collapsedLines); assert.equal(expandedLines.filter((line) => line.includes("╰")).length, 1); });