@zenodinh/pi-render 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +20 -6
  2. package/index.ts +29 -33
  3. package/package.json +6 -2
  4. package/src/core/paint.ts +17 -2
  5. package/src/core/types/host.ts +15 -18
  6. package/src/core/types.ts +0 -1
  7. package/src/renderers/content/artifacts/cards.ts +13 -6
  8. package/src/renderers/content/code-panel.ts +53 -31
  9. package/src/renderers/content/index.ts +70 -21
  10. package/src/renderers/content/json-panel.ts +9 -17
  11. package/src/renderers/content/link-paragraph.ts +153 -0
  12. package/src/renderers/content/table.ts +4 -1
  13. package/src/renderers/tool/runtime.ts +44 -5
  14. package/src/renderers/tool/specs/search.ts +18 -10
  15. package/src/renderers/tool/types.ts +6 -1
  16. package/src/commands/canvas.test.ts +0 -150
  17. package/src/commands/canvas.ts +0 -57
  18. package/src/core/code-theme.test.ts +0 -266
  19. package/src/core/log.test.ts +0 -127
  20. package/src/core/paint.test.ts +0 -322
  21. package/src/core/registry.test.ts +0 -307
  22. package/src/core/settings.test.ts +0 -183
  23. package/src/renderers/content/artifacts/artifacts.test.ts +0 -199
  24. package/src/renderers/content/artifacts/cards.test.ts +0 -216
  25. package/src/renderers/content/artifacts/engines-extra.test.ts +0 -556
  26. package/src/renderers/content/artifacts/local-binary.test.ts +0 -207
  27. package/src/renderers/content/image-card.test.ts +0 -170
  28. package/src/renderers/content/panels.test.ts +0 -188
  29. package/src/renderers/content/table.test.ts +0 -209
  30. package/src/renderers/content/transformer.test.ts +0 -254
  31. package/src/renderers/tool/resolver.test.ts +0 -257
  32. package/src/renderers/tool/runtime.test.ts +0 -313
  33. package/src/renderers/tool/specs/bash.test.ts +0 -110
  34. package/src/renderers/tool/specs/codemode.test.ts +0 -212
  35. package/src/renderers/tool/specs/edit.test.ts +0 -260
  36. package/src/renderers/tool/specs/ls.test.ts +0 -173
  37. package/src/renderers/tool/specs/read.test.ts +0 -340
  38. package/src/renderers/tool/specs/search.test.ts +0 -197
  39. package/src/renderers/tool/specs/write.test.ts +0 -145
  40. package/themes/themes.test.ts +0 -251
@@ -1,212 +0,0 @@
1
- /**
2
- * codemode.test.ts — the codemode spec through its seam: buildCall, build, project.
3
- *
4
- * Every scenario enters at the exported spec (or its two sibling exports) and drives it with fixtures
5
- * and the T-11 marker double; nothing below the seam is imported.
6
- *
7
- * shape: fixture records + one literal expectation per case — trigger #7, fixed-shape host payloads
8
- * and views; a class or fixture framework would be ceremony over two object literals.
9
- */
10
-
11
- import { readFileSync } from "node:fs";
12
- import { describe, expect, it } from "vitest";
13
- import { assertNoRealEscapes, markerPaint, testRenderContext } from "../../../../test/fakes/index.ts";
14
- import type { RenderContext, RowPaint, RowView, ToolResult } from "../../../core/types.ts";
15
- import { descriptor, name, spec } from "./codemode.ts";
16
-
17
- /** The spec's own source: the two static claims (no execution, no escape literal) are read off it. */
18
- const SOURCE = readFileSync(new URL("./codemode.ts", import.meta.url), "utf8");
19
-
20
- const PAINT: RowPaint = markerPaint();
21
- const COLLAPSED: RowView = { expanded: false, isError: false, isPartial: false };
22
- const EXPANDED: RowView = { expanded: true, isError: false, isPartial: false };
23
-
24
- // recorded-from: @earendil-works/pi-coding-agent@1.0.4
25
- // dist/extensions/codemode/renderer.js:14-16,52-96 (read 2026-10-07) — the details.calls[] field set
26
- // ({name, args, durationMs, cost, error, status}), the "Script … / Wall time … / Output:" header block
27
- // the host strips, and its collapsed "Full output: <path>" line.
28
- // invented: no live codemode session was captured, so the names, paths and timings stand in for real ones.
29
- const CANONICAL: ToolResult = {
30
- content: [
31
- { type: "text", text: "Script completed\nWall time 1.9 seconds\nOutput:\n" },
32
- { type: "text", text: "all good\n" },
33
- ],
34
- details: {
35
- calls: [
36
- { name: "read", args: "src/core/log.ts", status: "ok", durationMs: 12, cost: 0.0042 },
37
- { name: "bash", args: "npm test", status: "ok", durationMs: 1400 },
38
- { name: "write", args: "out.txt", status: "ok", durationMs: 3 },
39
- ],
40
- fullOutputPath: "/tmp/pi-codemode/run-1.txt",
41
- },
42
- };
43
-
44
- /** Same shape, one call failed and the script's own error text in content. */
45
- const FAILED: ToolResult = {
46
- content: [{ type: "text", text: "boom: exit code 1\nsecond line of the failure\n" }],
47
- details: {
48
- calls: [
49
- { name: "read", args: "src/core/log.ts", status: "ok", durationMs: 8 },
50
- { name: "bash", args: "npm test", status: "error", durationMs: 900, error: "expected 3 tests, got 2" },
51
- ],
52
- },
53
- };
54
-
55
- /** A "Script failed" header: every call succeeded, the script did not. */
56
- const SCRIPT_FAILED: ToolResult = {
57
- content: [
58
- { type: "text", text: "Script failed\nWall time 2 seconds\nOutput:\n" },
59
- { type: "text", text: "TypeError: x is not a function\n" },
60
- ],
61
- details: { calls: [{ name: "read", args: "a.ts", status: "ok", durationMs: 5 }] },
62
- };
63
-
64
- /** Mid-flight: the last call is still running, so the script body is not final. */
65
- const STREAMING: ToolResult = {
66
- content: [
67
- { type: "text", text: "Script completed\nWall time 1.9 seconds\nOutput:\n" },
68
- { type: "text", text: "all good\n" },
69
- ],
70
- details: { calls: [{ name: "bash", args: "npm test", status: "running" }] },
71
- };
72
-
73
- /** The spec's parse stage, failing loudly when a fixture stops reading as a codemode result. */
74
- function build(result: ToolResult, ctx: RenderContext = testRenderContext().derive()) {
75
- const model = spec.build(result, ctx);
76
- if (model === "unknown") throw new Error("fixture did not parse as a codemode result");
77
- return model;
78
- }
79
-
80
- describe("codemode spec — the exports T-28 composes", () => {
81
- it("AC-1: declares the row.codemode descriptor with no settings", () => {
82
- // fails_when: the descriptor a boot test registers drifts from the agreed key or name
83
- expect(name).toBe("codemode");
84
- expect(descriptor).toEqual({ key: "row.codemode", name: "Codemode row", defaultEnabled: true, settings: [] });
85
- });
86
- });
87
-
88
- describe("codemode spec — AC-1 the golden rows", () => {
89
- it("AC-1: collapsed, the row is the call count and the status summary", () => {
90
- const row = spec.project(build(CANONICAL), COLLAPSED, PAINT);
91
- // fails_when: the count, the status, or the truncated-output path is missing or wrong
92
- expect(row).toBe(
93
- `${PAINT.muted("3 tool calls")} ${PAINT.muted("·")} ${PAINT.accent("✓")} ${PAINT.muted("·")} ${PAINT.muted("Full output: /tmp/pi-codemode/run-1.txt")}`,
94
- );
95
- });
96
-
97
- it("AC-1: expanded, the row lists every nested call above the script output", () => {
98
- const row = spec.project(build(CANONICAL), EXPANDED, PAINT);
99
- // fails_when: a call is dropped, mislabelled, or the output keeps the host's header line
100
- expect(row).toBe(
101
- [
102
- `${PAINT.accent("✓")} ${PAINT.title("read")} ${PAINT.muted("src/core/log.ts")} ${PAINT.muted("12ms")} ${PAINT.muted("$0.0042")}`,
103
- `${PAINT.accent("✓")} ${PAINT.title("bash")} ${PAINT.muted("npm test")} ${PAINT.muted("1.4s")}`,
104
- `${PAINT.accent("✓")} ${PAINT.title("write")} ${PAINT.muted("out.txt")} ${PAINT.muted("3ms")}`,
105
- "",
106
- PAINT.output("all good"),
107
- PAINT.muted("Full output: /tmp/pi-codemode/run-1.txt"),
108
- ].join("\n"),
109
- );
110
- });
111
-
112
- it("AC-1: a streaming script streams its calls and withholds the body", () => {
113
- const partial: RowView = { ...EXPANDED, isPartial: true };
114
- // fails_when: a not-yet-final output is served as the script's result
115
- expect(spec.project(build(STREAMING), partial, PAINT)).toBe(
116
- `${PAINT.warning("…")} ${PAINT.title("bash")} ${PAINT.muted("npm test")}`,
117
- );
118
- expect(spec.project(build(STREAMING), { ...COLLAPSED, isPartial: true }, PAINT)).toBe(
119
- `${PAINT.muted("1 tool call")} ${PAINT.muted("·")} ${PAINT.warning("…")}`,
120
- );
121
- });
122
-
123
- it("AC-1: a result with neither details nor text reports unknown", () => {
124
- // fails_when: an unrecognized result gets a plausible-looking row instead of the raw fallback
125
- expect(spec.build({ content: [] }, testRenderContext().derive())).toBe("unknown");
126
- });
127
- });
128
-
129
- describe("codemode spec — AC-2 the error rows", () => {
130
- it("AC-2: a failed call shows its error on the collapsed line", () => {
131
- const row = spec.project(build(FAILED), { ...COLLAPSED, isError: true }, PAINT);
132
- // fails_when: the error renders blank, shows prose instead of the message, or crashes
133
- expect(row).toBe(PAINT.error("✗ boom: exit code 1"));
134
- });
135
-
136
- it("AC-2: expanded, the error body replaces the call list", () => {
137
- const row = spec.project(build(FAILED), { ...EXPANDED, isError: true }, PAINT);
138
- // fails_when: the failure body is cut to its first line in the expanded row
139
- expect(row).toBe([PAINT.error("boom: exit code 1"), PAINT.error("second line of the failure")].join("\n"));
140
- });
141
-
142
- it("AC-2: a failed nested call marks the summary and prints its message expanded", () => {
143
- // fails_when: a nested failure reads as ok on the collapsed line, or its message is dropped
144
- expect(spec.project(build(FAILED), COLLAPSED, PAINT)).toBe(
145
- `${PAINT.muted("2 tool calls")} ${PAINT.muted("·")} ${PAINT.error("✗")}`,
146
- );
147
- expect(spec.project(build(FAILED), EXPANDED, PAINT)).toBe(
148
- [
149
- `${PAINT.accent("✓")} ${PAINT.title("read")} ${PAINT.muted("src/core/log.ts")} ${PAINT.muted("8ms")}`,
150
- `${PAINT.error("✗")} ${PAINT.title("bash")} ${PAINT.muted("npm test")} ${PAINT.muted("900ms")}`,
151
- PAINT.error(" expected 3 tests, got 2"),
152
- "",
153
- PAINT.output("boom: exit code 1"),
154
- PAINT.output("second line of the failure"),
155
- ].join("\n"),
156
- );
157
- });
158
-
159
- it("AC-2: a failed script header with successful calls still reads as an error", () => {
160
- const row = spec.project(build(SCRIPT_FAILED), COLLAPSED, PAINT);
161
- // fails_when: the header is stripped without its verdict, so a failed script reads ok
162
- expect(row).toBe(`${PAINT.muted("1 tool call")} ${PAINT.muted("·")} ${PAINT.error("✗")}`);
163
- expect(spec.project(build(SCRIPT_FAILED), EXPANDED, PAINT)).toContain("TypeError: x is not a function");
164
- });
165
- });
166
-
167
- describe("codemode spec — AC-3 nothing executable is ported", () => {
168
- it("AC-3: the source holds no registration, wrapper, or prompt text", () => {
169
- const forbidden = [/\bregisterTool\s*\(/, /\bexecute\b/, /\bprompt\b/, /\bas unknown as\b/];
170
- // fails_when: the port smuggles an execution or instruction claim back in
171
- expect(forbidden.filter((pattern) => pattern.test(SOURCE)).map(String)).toEqual([]);
172
- });
173
- });
174
-
175
- describe("codemode spec — AC-4 the args gate", () => {
176
- it("AC-4: an incomplete call is titled with the tool name alone", () => {
177
- const pending = testRenderContext({ args: {}, argsComplete: false }).derive();
178
- // fails_when: a partial argument value or a derived count is shown before the host says it is complete
179
- expect(spec.buildCall({ code: "const x = 1\nreturn x" }, pending)).toEqual({ title: "codemode" });
180
-
181
- const unknownGate = testRenderContext({ args: {} }).derive({ argsComplete: undefined });
182
- // fails_when: an absent gate flag reads as "complete" and the row guesses from the args
183
- expect(spec.buildCall({ code: "return 1" }, unknownGate)).toEqual({ title: "codemode" });
184
- });
185
-
186
- it("AC-4: a complete call is titled with the script's line count", () => {
187
- const ready = testRenderContext({ args: { code: "const x = 1\nreturn x" } }).derive();
188
- // fails_when: the gate never opens, so the row says nothing about the script it will run
189
- expect(spec.buildCall({ code: "const x = 1\nreturn x" }, ready)).toEqual({ title: "codemode · 2 lines" });
190
- expect(spec.buildCall({ code: "return 1" }, ready)).toEqual({ title: "codemode · 1 line" });
191
- });
192
- });
193
-
194
- describe("codemode spec — AC-5 color comes only from paint", () => {
195
- it("AC-5: every escape in a rendered row is a paint marker", () => {
196
- const rows = [
197
- spec.project(build(CANONICAL), COLLAPSED, PAINT),
198
- spec.project(build(CANONICAL), EXPANDED, PAINT),
199
- spec.project(build(FAILED), { ...COLLAPSED, isError: true }, PAINT),
200
- ];
201
- for (const row of rows) assertNoRealEscapes(row);
202
- // fails_when: a role is bypassed, so text reaches the row unpainted
203
- expect(rows[0]).toContain(PAINT.muted("3 tool calls"));
204
- expect(rows[1]).toContain(PAINT.output("all good"));
205
- });
206
-
207
- it("AC-5: the source holds no escape literal", () => {
208
- const esc = String.fromCharCode(27);
209
- // fails_when: a hardcoded color reaches the terminal without passing through a paint role
210
- expect([SOURCE.includes(esc), SOURCE.includes("\\x1b"), SOURCE.includes("\\u001b")]).toEqual([false, false, false]);
211
- });
212
- });
@@ -1,260 +0,0 @@
1
- /**
2
- * edit.test.ts — the `edit` row spec through its seam: the exported RowSpec's three functions.
3
- *
4
- * Fixtures are the host builtin's own shapes (provenance per fixture below). Every block names its
5
- * ticket AC and its `fails_when`, and each was inverted — with the behavior-driving line mutated the
6
- * block goes RED (T-17 report, INVERSION-LOG).
7
- */
8
-
9
- import { readFileSync } from "node:fs";
10
- import { fileURLToPath } from "node:url";
11
- import { describe, expect, it } from "vitest";
12
- import { assertNoRealEscapes, markerPaint, testRenderContext } from "../../../../test/fakes/index.ts";
13
- import type { RowView, ToolResult } from "../../../core/types.ts";
14
- import { editRow } from "./edit.ts";
15
-
16
- /* ── fixtures ────────────────────────────────────────────────────────── */
17
-
18
- // recorded-from @earendil-works/pi-coding-agent@1.0.4, dist/core/tools/edit-diff.js generateDiffString
19
- // (executed 2026-10-06): the diff string for a one-line body change is exactly this line shape —
20
- // " <num> ctx" | "-<num> old" | "+<num> new", numbers padded to the width of the highest line.
21
- const DIFF =
22
- ' 1 import { a } from "./a.ts";\n 2 \n 3 export function total(items) {\n-4 return items.length;\n+4 return items.reduce((n, i) => n + i, 0);\n 5 }';
23
-
24
- // recorded-from the same host generator (executed 2026-10-06): an asymmetric change — one line
25
- // replaced and one appended — so counts read from the wrong side cannot pass as the golden.
26
- const DIFF_ASYMMETRIC =
27
- ' 1 const a = 1;\n-2 export const sum = () => a;\n+2 export const sum = () => a + a;\n 3 export const label = "sum";\n+4 export const version = 2;';
28
-
29
- // recorded-from the same host, dist/core/tools/edit.js:123-134 (read 2026-10-06): args are
30
- // {path, edits[]} and a successful result carries details {diff, patch, firstChangedLine}.
31
- const ARGS: Record<string, unknown> = {
32
- path: "/work/repo/src/total.ts",
33
- edits: [{ oldText: " return items.length;", newText: " return items.reduce((n, i) => n + i, 0);" }],
34
- };
35
- const CWD = "/work/repo";
36
- const RESULT: ToolResult = {
37
- content: [{ type: "text", text: "Successfully replaced 1 block(s) in src/total.ts." }],
38
- details: { diff: DIFF, patch: "@@ -4,1 +4,1 @@", firstChangedLine: 4 },
39
- };
40
-
41
- // recorded-from the same host, dist/core/tools/edit.js:118 — its thrown-message template
42
- // `Could not edit file: ${path}. ${errorMessage}.`
43
- const ERROR_RESULT: ToolResult = {
44
- content: [{ type: "text", text: "Could not edit file: src/total.ts. Error code: ENOENT." }],
45
- };
46
-
47
- // invented: the host carries no fixture for a settled result with no details; this is the state the
48
- // predecessor's pending branch exists for (a result rendered before its diff streamed in).
49
- const DIFFLESS_RESULT: ToolResult = {
50
- content: [{ type: "text", text: "Successfully replaced 1 block(s) in src/total.ts." }],
51
- };
52
-
53
- // invented: the predecessor's 80-char summary cap needs an error longer than 80 to be observable.
54
- const LONG_ERROR_RESULT: ToolResult = {
55
- content: [
56
- {
57
- type: "text",
58
- text: "Error: oldText matched 3 locations in src/total.ts; make it unique by adding surrounding context.",
59
- },
60
- ],
61
- };
62
-
63
- /* ── marker goldens ──────────────────────────────────────────────────── */
64
-
65
- // test/fakes/paint.ts's role table, written out independently so a golden pins the exact bytes AND the
66
- // role: output 201/221, muted 202/222, accent 203/223, error 204/224.
67
- const OUTPUT = (text: string) => `\u001b[38;5;201m${text}\u001b[38;5;221m`;
68
- const MUTED = (text: string) => `\u001b[38;5;202m${text}\u001b[38;5;222m`;
69
- const ACCENT = (text: string) => `\u001b[38;5;212m${text}\u001b[38;5;232m`;
70
- const ERROR = (text: string) => `\u001b[38;5;204m${text}\u001b[38;5;224m`;
71
- const DIFF_ADDED = (text: string) => `\u001b[38;5;212m${text}\u001b[38;5;232m`;
72
- const DIFF_REMOVED = (text: string) => `\u001b[38;5;213m${text}\u001b[38;5;233m`;
73
- const DIFF_CONTEXT = (text: string) => `\u001b[38;5;214m${text}\u001b[38;5;234m`;
74
-
75
- const paint = markerPaint();
76
-
77
- /** The runtime's view derivation (T-12): host options + ctx flags arrive here. */
78
- function view(over: Partial<RowView> = {}): RowView {
79
- return { expanded: false, isError: false, isPartial: false, ...over };
80
- }
81
-
82
- /* ── the exported record ─────────────────────────────────────────────── */
83
-
84
- describe("edit row spec — the exported record", () => {
85
- it("AC-1: the record names the tool and its registry descriptor", () => {
86
- // fails_when: the resolver table or the /render panel reads the wrong name or key
87
- expect(editRow.name).toBe("edit");
88
- expect(editRow.descriptor).toEqual({
89
- key: "row.edit",
90
- name: "Edit row",
91
- defaultEnabled: true,
92
- settings: [],
93
- });
94
- });
95
- });
96
-
97
- /* ── buildCall ───────────────────────────────────────────────────────── */
98
-
99
- describe("edit row spec — buildCall", () => {
100
- it("AC-4: a completed call names the path relative to ctx.cwd", () => {
101
- const ctx = testRenderContext({ args: ARGS, cwd: CWD });
102
- // fails_when: the title drops the path, or shows it raw with the cwd prefix
103
- expect(editRow.spec.buildCall(ARGS, ctx)).toEqual({ title: "edit: src/total.ts" });
104
- });
105
-
106
- it("AC-4: a half-streamed arg bag yields the tool name only", () => {
107
- const ctx = testRenderContext({ args: ARGS, argsComplete: false });
108
- // fails_when: a partial arg set is rendered as a path (the gate is dropped)
109
- expect(editRow.spec.buildCall(ARGS, ctx)).toEqual({ title: "edit" });
110
- });
111
-
112
- it("AC-4: empty args and a legacy `file_path` each render without inventing a value", () => {
113
- const empty = testRenderContext({ args: {} });
114
- // fails_when: an absent path renders as a placeholder or a stray separator
115
- expect(editRow.spec.buildCall({}, empty)).toEqual({ title: "edit" });
116
- // fails_when: a non-string argument is coerced into the title (a fabricated value)
117
- expect(editRow.spec.buildCall({ path: 42 }, empty)).toEqual({ title: "edit" });
118
- const legacy = testRenderContext({ args: { file_path: "src/a.ts" } });
119
- // fails_when: the predecessor's arg spelling renders as the tool name only
120
- expect(editRow.spec.buildCall({ file_path: "src/a.ts" }, legacy)).toEqual({ title: "edit: src/a.ts" });
121
- const outside = { path: "/other/place/a.ts" };
122
- const ctx = testRenderContext({ args: outside, cwd: CWD });
123
- // fails_when: a path outside the cwd is rendered as a relative walk instead of the path itself
124
- expect(editRow.spec.buildCall(outside, ctx)).toEqual({ title: "edit: /other/place/a.ts" });
125
- });
126
- });
127
-
128
- /* ── project ─────────────────────────────────────────────────────────── */
129
-
130
- describe("edit row spec — project", () => {
131
- it("AC-1: collapsed is the golden changed-line summary", () => {
132
- const model = editRow.spec.build(RESULT);
133
- const out = editRow.spec.project(model, view(), paint);
134
- // fails_when: a plausible-looking row renders the wrong counts or the wrong roles
135
- expect(out).toBe(` ${ACCENT("+1")} ${DIFF_REMOVED("−1")}`);
136
- assertNoRealEscapes(out);
137
- // The runtime paints buildCall's title (the call line) above this body (T-12): the two lines
138
- // together are the collapsed row, so this golden covers the name and the counts.
139
- const ctx = testRenderContext({ args: ARGS, cwd: CWD });
140
- expect(`${editRow.spec.buildCall(ARGS, ctx).title}\n${out}`).toBe(
141
- `edit: src/total.ts\n ${ACCENT("+1")} ${DIFF_REMOVED("−1")}`,
142
- );
143
- });
144
-
145
- it("AC-1: an asymmetric diff counts each side separately", () => {
146
- const model = editRow.spec.build({ ...RESULT, details: { diff: DIFF_ASYMMETRIC, firstChangedLine: 2 } });
147
- const out = editRow.spec.project(model, view(), paint);
148
- // fails_when: the two counts are swapped or summed — a symmetric fixture hides exactly this
149
- expect(out).toBe(` ${ACCENT("+2")} ${DIFF_REMOVED("−1")}`);
150
- });
151
-
152
- it("AC-1: expanded is the golden diff, one painted line per host line", () => {
153
- const model = editRow.spec.build(RESULT);
154
- const out = editRow.spec.project(model, view({ expanded: true }), paint);
155
- const golden = [
156
- DIFF_CONTEXT(' 1 import { a } from "./a.ts";'),
157
- DIFF_CONTEXT(" 2 "),
158
- DIFF_CONTEXT(" 3 export function total(items) {"),
159
- DIFF_REMOVED("-4 return items.length;"),
160
- DIFF_ADDED("+4 return items.reduce((n, i) => n + i, 0);"),
161
- DIFF_CONTEXT(" 5 }"),
162
- ].join("\n");
163
- // fails_when: a line loses its tone, is dropped, reordered, or split differently
164
- expect(out).toBe(golden);
165
- assertNoRealEscapes(out);
166
- });
167
-
168
- it("AC-1: a diff-less result reads pending collapsed and shows its text expanded", () => {
169
- const model = editRow.spec.build(DIFFLESS_RESULT);
170
- // fails_when: a result with no diff renders as though it carried changes
171
- expect(editRow.spec.project(model, view(), paint)).toBe(` ${MUTED("· pending")}`);
172
- expect(editRow.spec.project(model, view({ expanded: true }), paint)).toBe(
173
- `${MUTED("diff unavailable")}\n${OUTPUT("Successfully replaced 1 block(s) in src/total.ts.")}`,
174
- );
175
- // fails_when: a malformed details payload renders as a diff line instead of degrading
176
- for (const details of [{ diff: 42 }, "oops", undefined]) {
177
- expect(editRow.spec.project(editRow.spec.build({ ...DIFFLESS_RESULT, details }), view(), paint)).toBe(
178
- ` ${MUTED("· pending")}`,
179
- );
180
- }
181
- const bare = editRow.spec.build({ content: [] });
182
- // fails_when: a result with no text paints an empty output line under the reason
183
- expect(editRow.spec.project(bare, view({ expanded: true }), paint)).toBe(MUTED("diff unavailable"));
184
- });
185
-
186
- it("AC-2: a failed call summarises the error on the collapsed line", () => {
187
- const model = editRow.spec.build(ERROR_RESULT);
188
- const out = editRow.spec.project(model, view({ isError: true }), paint);
189
- // fails_when: errors render blank or crash
190
- expect(out).toBe(` ${ERROR("✗ Could not edit file: src/total.ts. Error code: ENOENT.")}`);
191
- assertNoRealEscapes(out);
192
- const bare = editRow.spec.build({ content: [{ type: "image", text: "PNG bytes" }] });
193
- // invented: a non-text block carrying text — the contract renders text blocks only, so this pins the skip.
194
- // fails_when: a failed call whose result carries no text block renders a blank row (or the block's caption)
195
- expect(editRow.spec.project(bare, view({ isError: true }), paint)).toBe(` ${ERROR("✗ Error")}`);
196
- expect(editRow.spec.project(bare, view({ isError: true, expanded: true }), paint)).toBe(ERROR("Error"));
197
- });
198
-
199
- it("AC-2: an error longer than 80 characters is summarised, not wrapped", () => {
200
- const model = editRow.spec.build(LONG_ERROR_RESULT);
201
- const first = LONG_ERROR_RESULT.content?.[0]?.text ?? "";
202
- const out = editRow.spec.project(model, view({ isError: true }), paint);
203
- // fails_when: a long error blows the one-line row (the 80-char cap is dropped)
204
- expect(out).toBe(` ${ERROR(`✗ ${first.slice(0, 79)}…`)}`);
205
- });
206
-
207
- it("AC-2: expanded shows the error body with blank runs collapsed", () => {
208
- const model = editRow.spec.build({
209
- content: [{ type: "text", text: "Error: oldText not found.\n\n\n\nCheck the file and retry." }],
210
- });
211
- const out = editRow.spec.project(model, view({ isError: true, expanded: true }), paint);
212
- // fails_when: the expanded error body is blank, untrimmed, or the wrong role
213
- expect(out).toBe([ERROR("Error: oldText not found."), "", ERROR("Check the file and retry.")].join("\n"));
214
- });
215
-
216
- it("AC-5: every escape in every state is a paint marker", () => {
217
- const states = [
218
- editRow.spec.project(editRow.spec.build(RESULT), view(), paint),
219
- editRow.spec.project(editRow.spec.build(RESULT), view({ expanded: true }), paint),
220
- editRow.spec.project(editRow.spec.build(ERROR_RESULT), view({ isError: true }), paint),
221
- editRow.spec.project(editRow.spec.build(ERROR_RESULT), view({ isError: true, expanded: true }), paint),
222
- editRow.spec.project(editRow.spec.build(DIFFLESS_RESULT), view(), paint),
223
- editRow.spec.project(editRow.spec.build(DIFFLESS_RESULT), view({ expanded: true }), paint),
224
- ];
225
- // fails_when: a hardcoded colour appears (this catches it; so does the source scan below)
226
- for (const out of states) {
227
- assertNoRealEscapes(out);
228
- }
229
- });
230
- });
231
-
232
- /* ── source contract ─────────────────────────────────────────────────── */
233
-
234
- const SOURCE = readFileSync(fileURLToPath(new URL("./edit.ts", import.meta.url)), "utf8");
235
-
236
- describe("edit row spec — source contract", () => {
237
- it("AC-3: the port carries no registration, execute wrapper, or model-facing text", () => {
238
- const forbidden = [
239
- { pattern: /\bregisterTool\s*\(/, what: "a tool-name registration" },
240
- { pattern: /\bexecute\s*[(:]/, what: "an execute wrapper" },
241
- { pattern: /\b(?:promptSnippet|promptGuidelines|systemPrompt|description)\b/, what: "model-facing text" },
242
- { pattern: /\bas\s+unknown\s+as\b/, what: "an `as unknown as` cast" },
243
- ];
244
- // fails_when: the port smuggles execution or model-facing text back in
245
- expect(forbidden.filter(({ pattern }) => pattern.test(SOURCE)).map(({ what }) => what)).toEqual([]);
246
- });
247
-
248
- it("AC-3: the spec imports the type contract and nothing that can execute", () => {
249
- const specifiers = [...SOURCE.matchAll(/from\s+"([^"]+)"/g)].map((match) => match[1] ?? "");
250
- // fails_when: the spec grows a host, filesystem, or sibling-lane import
251
- expect(specifiers).toEqual(["node:path", "../../../core/types.ts"]);
252
- });
253
-
254
- it("AC-5: no escape literal in the row source — the scan half", () => {
255
- // biome-ignore lint/suspicious/noControlCharactersInRegex: the ESC introducer is what the scan looks for
256
- const ESCAPE = /\\x1[bB]|\\u001[bB]|\u001b/;
257
- // fails_when: a colour is hardcoded instead of coming from a paint role
258
- expect(ESCAPE.test(SOURCE)).toBe(false);
259
- });
260
- });
@@ -1,173 +0,0 @@
1
- /**
2
- * ls.test.ts — the ls row judged at its seam: buildCall's argument gate, build's content parse, and
3
- * project's collapsed count / expanded tree under the marker paint.
4
- *
5
- * Every assertion drives the exported `lsRow.spec`, the same object the resolver table hands the
6
- * runtime; module internals are never imported.
7
- */
8
-
9
- // shape: fixtures + pinned goldens — trigger #7, a fixed set of result shapes, each with its literal
10
- // expected row; a fixture framework or a table-driven runner would be ceremony over five strings.
11
- import { readFileSync } from "node:fs";
12
- import { homedir } from "node:os";
13
- import { join } from "node:path";
14
- import { describe, expect, it } from "vitest";
15
- import { assertNoRealEscapes, markerPaint, testRenderContext } from "../../../../test/fakes/index.ts";
16
- import type { ToolResult } from "../../../core/types/host.ts";
17
- import type { RowView } from "../types.ts";
18
- import type { LsModel } from "./ls.ts";
19
- import { lsRow } from "./ls.ts";
20
-
21
- const PAINT = markerPaint();
22
- const INDENT = " ";
23
- const COLLAPSED: RowView = { expanded: false, isError: false, isPartial: false };
24
- const EXPANDED: RowView = { expanded: true, isError: false, isPartial: false };
25
-
26
- // Golden wraps recorded-from test/fakes/paint.ts MARKER_CODES (T-11, 2026-10-06): each role frames its
27
- // text in its own escape pair, so a dropped, reordered or wrong-role marker fails the golden.
28
- const muted = (text: string): string => `\u001b[38;5;202m${text}\u001b[38;5;222m`;
29
- const gutter = (text: string): string => `\u001b[38;5;206m${text}\u001b[38;5;226m`;
30
- const accent = (text: string): string => `\u001b[38;5;203m${text}\u001b[38;5;223m`;
31
- const error = (text: string): string => `\u001b[38;5;204m${text}\u001b[38;5;224m`;
32
-
33
- // Fixture provenance — shape recorded-from @earendil-works/pi-coding-agent@1.0.4
34
- // dist/core/tools/ls.js:90-108 (read 2026-10-06): ONE text block whose lines are "<name>", a
35
- // directory carrying a trailing "/", sorted case-insensitively.
36
- // Fixture provenance — entries invented: the concrete names only drive the tree layout; any real
37
- // listing has this identical shape.
38
- const LISTING = "docs/\npackage.json\nREADME.md\nsrc/\nvitest.config.ts";
39
- const RESULT: ToolResult = { content: [{ type: "text", text: LISTING }] };
40
-
41
- /** Drives the seam the way the runtime does: parse once through build, then project. */
42
- function parse(result: ToolResult): LsModel {
43
- const model = lsRow.spec.build(result, testRenderContext());
44
- if (model === "unknown") throw new Error("fixture did not parse as an ls result");
45
- return model;
46
- }
47
-
48
- function title(args: Record<string, unknown>, cwd: string, argsComplete = true): string {
49
- const ctx = testRenderContext({ args, cwd, argsComplete });
50
- return lsRow.spec.buildCall(args, ctx).title;
51
- }
52
-
53
- describe("lsRow.spec.buildCall — the call line", () => {
54
- it("AC-1: a path inside cwd reads relative, and the limit rides along", () => {
55
- const shown = title({ path: "/Users/dev/repo/src", limit: 20 }, "/Users/dev/repo");
56
- // fails_when: the call line drops the path, shows an absolute path, or hides the limit
57
- expect(shown).toBe("ls src (limit 20)");
58
- });
59
-
60
- it("AC-4: until argsComplete the title is the tool name only", () => {
61
- const ctx = testRenderContext({ argsComplete: false, args: {}, cwd: "/Users/dev/repo" });
62
- // fails_when: a partial or already-streamed argument value reaches the call line
63
- expect(lsRow.spec.buildCall({ path: "/Users/dev/repo/src" }, ctx).title).toBe("ls");
64
- });
65
-
66
- it("AC-1: a relative argument reads as written; a path outside cwd is home-shortened", () => {
67
- const outside = join(homedir(), "elsewhere", "notes");
68
- // fails_when: a relative argument is joined against the wrong base, or an outside path stays absolute
69
- expect(title({ path: "src" }, "/Users/dev/repo")).toBe("ls src");
70
- expect(title({ path: outside }, "/Users/dev/repo")).toBe("ls ~/elsewhere/notes");
71
- });
72
- });
73
-
74
- describe("lsRow.spec — the result projection", () => {
75
- it("AC-1: the canonical listing renders the golden collapsed summary and expanded tree", () => {
76
- const collapsed = lsRow.spec.project(parse(RESULT), COLLAPSED, PAINT);
77
- const expanded = lsRow.spec.project(parse(RESULT), EXPANDED, PAINT);
78
- // fails_when: a plausible-looking row renders the wrong count, a missing tree, or a directory as a file
79
- expect(collapsed).toBe(muted("5 entries · ctrl+o to expand"));
80
- expect(expanded).toBe(
81
- [
82
- muted("5 entries"),
83
- "",
84
- `${INDENT}${gutter("├── ")}${accent("docs")}`,
85
- `${INDENT}${gutter("├── ")}package.json`,
86
- `${INDENT}${gutter("├── ")}README.md`,
87
- `${INDENT}${gutter("├── ")}${accent("src")}`,
88
- `${INDENT}${gutter("└── ")}vitest.config.ts`,
89
- ].join("\n"),
90
- );
91
- const one = lsRow.spec.project(parse({ content: [{ type: "text", text: "src/" }] }), COLLAPSED, PAINT);
92
- // fails_when: the summary reads "1 entries"
93
- expect(one).toBe(muted("1 entry · ctrl+o to expand"));
94
- });
95
-
96
- it("AC-2: an error result puts the first error line on the collapsed row and the body when expanded", () => {
97
- // invented: the host rejects ls with one line (ls.js:60,66,71); two message lines plus a blank
98
- // run is the multi-line error shape the row must still fold into one collapsed line.
99
- const failed: ToolResult = {
100
- content: [{ type: "text", text: "Path not found: /nope\ncheck the path and retry\n\n\nmore detail" }],
101
- };
102
- const model = parse(failed);
103
- // fails_when: errors render blank, render their body collapsed, or crash the row
104
- expect(lsRow.spec.project(model, { ...COLLAPSED, isError: true }, PAINT)).toBe(
105
- `${error("Path not found: /nope")}${muted(" · ctrl+o to expand")}`,
106
- );
107
- expect(lsRow.spec.project(model, { ...EXPANDED, isError: true }, PAINT)).toBe(
108
- [
109
- `${INDENT}${error("Path not found: /nope")}`,
110
- `${INDENT}${error("check the path and retry")}`,
111
- "",
112
- `${INDENT}${error("more detail")}`,
113
- ].join("\n"),
114
- );
115
- });
116
-
117
- it("AC-1: past the preview cap the tree ends in a count, not a last-entry connector", () => {
118
- // invented: the host caps at 500 entries (ls.js:22), so the predecessor's 80-line render cap is
119
- // what triggers the fold; the names only need to be 85 distinct entries.
120
- const many = Array.from({ length: 85 }, (_, i) => `f${String(i).padStart(2, "0")}.ts`).join("\n");
121
- const lines = lsRow.spec.project(parse({ content: [{ type: "text", text: many }] }), EXPANDED, PAINT).split("\n");
122
- // fails_when: a truncated tree claims a final entry, or the hidden count is absent or wrong
123
- expect(lines).toHaveLength(83);
124
- expect(lines[81]).toBe(`${INDENT}${gutter("├── ")}f79.ts`);
125
- expect(lines[82]).toBe(`${INDENT}${gutter("└── ")}${muted("… 5 more entries")}`);
126
- });
127
-
128
- it("AC-1: a result with no text content is unknown, so the runtime degrades to the raw row", () => {
129
- // fails_when: an empty result renders a plausible-but-empty ls row
130
- expect(lsRow.spec.build({ content: [] }, testRenderContext())).toBe("unknown");
131
- expect(lsRow.spec.build({}, testRenderContext())).toBe("unknown");
132
- expect(lsRow.spec.build({ content: [{ type: "text", text: " \n " }] }, testRenderContext())).toBe("unknown");
133
- expect(lsRow.spec.build({ content: [{ type: "image" }] }, testRenderContext())).toBe("unknown");
134
- });
135
- });
136
-
137
- describe("lsRow — the render-only and paint-only contracts", () => {
138
- // recorded-from: src/renderers/tool/specs/ls.ts, read from disk at test time.
139
- const SOURCE = readFileSync(new URL("./ls.ts", import.meta.url), "utf8");
140
-
141
- it("AC-3: the ported source carries no execute wrapper and no model-facing text", () => {
142
- const patterns = [
143
- /\bregisterTool\b/,
144
- /\bexecute\s*[:(]/,
145
- /\bwrapExecuteWithMetrics\b/,
146
- /\bpromptSnippet\b/,
147
- /\bsnippet\b/,
148
- /\bdescription\s*:/,
149
- /\bparameters\s*:/,
150
- ];
151
- // fails_when: the port smuggles a registration, an execute wrapper, or prompt text back in
152
- expect(patterns.filter((pattern) => pattern.test(SOURCE)).map(String)).toEqual([]);
153
- });
154
-
155
- it("AC-5: every escape project emits is a paint marker, and the source holds none", () => {
156
- const outputs = [
157
- lsRow.spec.project(parse(RESULT), COLLAPSED, PAINT),
158
- lsRow.spec.project(parse(RESULT), EXPANDED, PAINT),
159
- lsRow.spec.project(parse(RESULT), { ...EXPANDED, isError: true }, PAINT),
160
- ];
161
- for (const output of outputs) assertNoRealEscapes(output);
162
- // biome-ignore lint/suspicious/noControlCharactersInRegex: the escape introducer is exactly what we scan for
163
- const escapeLiteral = /\\x1[bB]|\\u001[bB]|\u001b/;
164
- // fails_when: a hardcoded color or reset ships in the spec instead of a paint role
165
- expect(escapeLiteral.test(SOURCE)).toBe(false);
166
- });
167
-
168
- it("AC-5: the descriptor defaults the row on, with no settings", () => {
169
- // fails_when: T-28 registers a row under an unexpected key, or one that starts disabled
170
- expect(lsRow.name).toBe("ls");
171
- expect(lsRow.descriptor).toEqual({ key: "row.ls", name: "Ls row", defaultEnabled: true, settings: [] });
172
- });
173
- });