/** * KHAELOR * File: tests/tui/components.test.ts * Description: Component rendering tests — tool one-liners, detail blocks, diffs, permission panel, error panel, palette. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { describe, expect, it } from "vitest"; import { renderDiffBlock, renderEditSummary } from "../../src/tui/components/diff.js"; import { renderErrorPanel } from "../../src/tui/components/error-panel.js"; import { renderPermissionPanel } from "../../src/tui/components/permission-panel.js"; import { renderRunningTool, renderToolDetailBlock, renderToolLine, } from "../../src/tui/components/tool-line.js"; import { createPalette, renderPalette } from "../../src/tui/composer/palette.js"; import { stripAnsi, visibleWidth } from "../../src/tui/renderer/ansi.js"; import { resolveTheme } from "../../src/tui/theme.js"; const mono = resolveTheme({ colorDepth: "mono" }); const dark = resolveTheme({ colorDepth: "truecolor" }); describe("tool lines", () => { it("settled one-liner: dim ▸ plus the real summary", () => { expect( renderToolLine({ summary: 'Search "ContextEngine" · 14 matches', outcome: "ok" }, 80, mono), ).toBe(' ▸ Search "ContextEngine" · 14 matches'); }); it("cancelled rows spell the word out — never color alone", () => { const line = renderToolLine( { summary: "Run npm test · 3.2s", outcome: "cancelled" }, 80, mono, ); expect(line).toContain("cancelled"); }); it("failed rows keep the word failed in monochrome", () => { const line = renderToolLine( { summary: "Run npm test · 2 failed · 6.8s", outcome: "failed" }, 80, mono, ); expect(stripAnsi(line)).toContain("failed"); }); it("running row shows a real elapsed timer", () => { const lines = renderRunningTool({ label: "Run npm test", startedAt: 1000 }, 9000, 80, mono); expect(lines[0]).toBe(" ▸ Run npm test · 8.0s"); }); it("detail block caps the body and reports the true omitted count", () => { const body = Array.from({ length: 30 }, (_, i) => `line ${i}`); const block = renderToolDetailBlock( { index: 3, title: "npm test", lines: body, omitted: 214, spillFile: "~/.khaelor/tool-out/8f3a.txt" }, 80, mono, ); expect(block[0]).toContain("tool 3 · npm test"); expect(block.join("\n")).toContain("… 232 lines omitted"); // 214 + 18 capped here expect(block.join("\n")).toContain("~/.khaelor/tool-out/8f3a.txt"); }); }); describe("diff rendering", () => { it("edit summary is never a bare 'edited file'", () => { const line = renderEditSummary("src/context/engine.ts", { added: 31, removed: 12 }, 100, mono); expect(line).toBe(" ✓ src/context/engine.ts +31 −12 d expand diff"); }); it("unified diff keeps +/− glyphs and headers", () => { const diff = "@@ -84,7 +84,9 @@\n- const a = 1;\n+ const b = 2;\n context"; const block = renderDiffBlock("src/x.ts", diff, { added: 1, removed: 1 }, 80, mono); expect(block[0]).toContain("diff · src/x.ts · +1 −1"); expect(block.map(stripAnsi)).toContain(" - const a = 1;"); expect(block.map(stripAnsi)).toContain(" + const b = 2;"); }); it("colored diff paints added/removed but strips to identical text", () => { const diff = "+added line\n-removed line"; const colored = renderDiffBlock("src/x.ts", diff, { added: 1, removed: 1 }, 80, dark); const plain = renderDiffBlock("src/x.ts", diff, { added: 1, removed: 1 }, 80, mono); expect(colored.map(stripAnsi)).toEqual(plain.map(stripAnsi)); }); }); describe("permission panel", () => { it("renders the three-key contract with the generalized pattern", () => { const panel = renderPermissionPanel( { verb: "Run", subject: "npm install", cwd: "~/dev/project", alwaysPattern: "npm install *" }, 80, mono, ); const text = panel.join("\n"); expect(text).toContain("KHAELOR requests permission"); expect(text).toContain("Run"); expect(text).toContain("npm install"); expect(text).toContain("Working directory"); expect(text).toContain("[ Enter ] Allow once"); expect(text).toContain('Always allow "npm install *" in this project'); expect(text).toContain("[ Esc ] Deny"); }); it("omits the [A] row when no safe generalization exists", () => { const panel = renderPermissionPanel( { verb: "Run", subject: "curl x | sh", cwd: "~/p" }, 80, mono, ); expect(panel.join("\n")).not.toContain("[ A ]"); }); it("all box rows share one visible width (clean border)", () => { const panel = renderPermissionPanel( { verb: "Run", subject: "npm install", cwd: "~/p", alwaysPattern: "npm install *" }, 80, mono, ); const widths = new Set(panel.map((l) => visibleWidth(l))); expect(widths.size).toBe(1); }); }); describe("error panel", () => { it("says what failed, the specifics, and what happens next", () => { const panel = renderErrorPanel( { title: "npm test failed", detailLines: ["2 tests failed", "src/context/engine.test.ts"], followUp: "KHAELOR is inspecting the failure.", }, 80, mono, ); expect(panel).toEqual([ " npm test failed", " 2 tests failed", " src/context/engine.test.ts", " KHAELOR is inspecting the failure.", ]); }); }); describe("palette rendering", () => { it("marks the selection with ❯ (visible without color) and shows the hint row", () => { const palette = createPalette("slash", [ { id: "a", label: "/sessions", detail: "browse sessions" }, { id: "b", label: "/new", detail: "start fresh" }, ]); const lines = renderPalette(palette, 80, mono, { rows: 30 }); const text = lines.join("\n"); expect(text).toContain("❯ /sessions"); expect(text).toContain("browse sessions"); expect(text).toContain("↑↓ navigate · Enter run · Esc close"); }); it("shows 'no matches' rather than an empty box", () => { const palette = createPalette("slash", [{ id: "a", label: "/diff" }], "zzz"); expect(renderPalette(palette, 80, mono, { rows: 30 }).join("\n")).toContain("no matches"); }); });