@zenodinh/pi-render 0.0.0-stage → 0.1.0

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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -2
  3. package/index.ts +279 -0
  4. package/package.json +66 -5
  5. package/src/commands/canvas.test.ts +150 -0
  6. package/src/commands/canvas.ts +57 -0
  7. package/src/core/code-theme.test.ts +266 -0
  8. package/src/core/code-theme.ts +275 -0
  9. package/src/core/log.test.ts +127 -0
  10. package/src/core/log.ts +57 -0
  11. package/src/core/paint.test.ts +322 -0
  12. package/src/core/paint.ts +64 -0
  13. package/src/core/registry.test.ts +307 -0
  14. package/src/core/registry.ts +135 -0
  15. package/src/core/settings.test.ts +183 -0
  16. package/src/core/settings.ts +119 -0
  17. package/src/core/types/code-theme.ts +24 -0
  18. package/src/core/types/host.ts +160 -0
  19. package/src/core/types/log.ts +28 -0
  20. package/src/core/types/paint.ts +54 -0
  21. package/src/core/types/registry.ts +44 -0
  22. package/src/core/types/settings.ts +31 -0
  23. package/src/core/types.ts +34 -0
  24. package/src/renderers/content/artifacts/artifacts.test.ts +199 -0
  25. package/src/renderers/content/artifacts/cache.ts +234 -0
  26. package/src/renderers/content/artifacts/cards.test.ts +216 -0
  27. package/src/renderers/content/artifacts/cards.ts +136 -0
  28. package/src/renderers/content/artifacts/engines-extra.test.ts +556 -0
  29. package/src/renderers/content/artifacts/engines.ts +396 -0
  30. package/src/renderers/content/artifacts/local-binary.test.ts +207 -0
  31. package/src/renderers/content/artifacts/local-binary.ts +80 -0
  32. package/src/renderers/content/artifacts/prereqs.ts +128 -0
  33. package/src/renderers/content/artifacts/server.ts +181 -0
  34. package/src/renderers/content/code-panel.ts +161 -0
  35. package/src/renderers/content/image-card.test.ts +170 -0
  36. package/src/renderers/content/image-card.ts +252 -0
  37. package/src/renderers/content/index.ts +79 -0
  38. package/src/renderers/content/json-panel.ts +116 -0
  39. package/src/renderers/content/panels.test.ts +188 -0
  40. package/src/renderers/content/table.test.ts +209 -0
  41. package/src/renderers/content/table.ts +174 -0
  42. package/src/renderers/content/transformer.test.ts +254 -0
  43. package/src/renderers/content/types.ts +20 -0
  44. package/src/renderers/tool/index.ts +113 -0
  45. package/src/renderers/tool/resolver.test.ts +257 -0
  46. package/src/renderers/tool/runtime.test.ts +313 -0
  47. package/src/renderers/tool/runtime.ts +267 -0
  48. package/src/renderers/tool/specs/bash.test.ts +110 -0
  49. package/src/renderers/tool/specs/bash.ts +168 -0
  50. package/src/renderers/tool/specs/codemode.test.ts +212 -0
  51. package/src/renderers/tool/specs/codemode.ts +248 -0
  52. package/src/renderers/tool/specs/edit.test.ts +260 -0
  53. package/src/renderers/tool/specs/edit.ts +213 -0
  54. package/src/renderers/tool/specs/ls.test.ts +173 -0
  55. package/src/renderers/tool/specs/ls.ts +136 -0
  56. package/src/renderers/tool/specs/read.test.ts +340 -0
  57. package/src/renderers/tool/specs/read.ts +296 -0
  58. package/src/renderers/tool/specs/search.test.ts +197 -0
  59. package/src/renderers/tool/specs/search.ts +325 -0
  60. package/src/renderers/tool/specs/write.test.ts +145 -0
  61. package/src/renderers/tool/specs/write.ts +142 -0
  62. package/src/renderers/tool/types.ts +45 -0
  63. package/themes/dracula-soft.json +81 -0
  64. package/themes/one-dark.json +80 -0
  65. package/themes/themes.test.ts +251 -0
@@ -0,0 +1,212 @@
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
+ });
@@ -0,0 +1,248 @@
1
+ /**
2
+ * codemode.ts — the codemode row: a one-line call count plus status, or the nested calls and output.
3
+ *
4
+ * Boundary: `details` and `args` are host data typed `unknown` here, so every field is narrowed by a
5
+ * predicate before it is read; color arrives only through the `paint` argument — no escape is emitted.
6
+ *
7
+ * shape: fixed-shape record — trigger #7, the exported RowSpec's fields are known statically and hold no
8
+ * state; STATUS_ICON below declares its own dispatch shape.
9
+ *
10
+ * ported from pi-pretty-tui/src/features/codemode-collapse/render.ts — survives because: the collapsed
11
+ * count/status summary, the per-call line, and the rule that a streaming script withholds its output are
12
+ * what that renderer got right. Its registration, its background-fill wrapper, and its cast sites carry
13
+ * claims pi-render does not make.
14
+ */
15
+
16
+ import type { ModuleDescriptor, RowPaint, RowSpec, RowView, ToolResult } from "../../../core/types.ts";
17
+
18
+ /** The tool name this spec renders, exactly as the host spells it. */
19
+ export const name = "codemode";
20
+
21
+ /** Registry descriptor; T-28 owns the defineModule call, this file only declares it. */
22
+ export const descriptor: ModuleDescriptor = {
23
+ key: "row.codemode",
24
+ name: "Codemode row",
25
+ defaultEnabled: true,
26
+ settings: [],
27
+ };
28
+
29
+ type CallStatus = "running" | "ok" | "error" | "cancelled";
30
+
31
+ /** One nested call; `status` stays verbatim so an unrecognized value reports "?" rather than "ok". */
32
+ interface NestedCall {
33
+ name: string;
34
+ args: string;
35
+ status: string;
36
+ durationMs?: number;
37
+ cost?: number;
38
+ error?: string;
39
+ }
40
+
41
+ /** The parsed result: the nested calls, plus the script's output with the host's header removed. */
42
+ interface CodemodeModel {
43
+ calls: NestedCall[];
44
+ output: string;
45
+ /** A "Script failed" header with successful calls still reads as a failure. */
46
+ failed: boolean;
47
+ fullOutputPath?: string;
48
+ }
49
+
50
+ const MAX_ARGS_PREVIEW = 80;
51
+ const MAX_ERROR_SUMMARY = 80;
52
+
53
+ /** The host writes the header as its own text block, ending at "Output:" — dist/extensions/codemode/renderer.js:14. */
54
+ const SCRIPT_HEADER = /^Script (completed|failed)\nWall time [\d.]+ seconds\nOutput:\n/;
55
+
56
+ // shape: dispatch object — trigger #1, four stateless icon handlers on one status discriminator.
57
+ // `accent` carries "ok": RowPaint has no success role, and `muted` would read as metadata.
58
+ const STATUS_ICON: Record<CallStatus, (paint: RowPaint) => string> = {
59
+ running: (paint) => paint.warning("…"),
60
+ ok: (paint) => paint.accent("✓"),
61
+ error: (paint) => paint.error("✗"),
62
+ cancelled: (paint) => paint.muted("⊘"),
63
+ };
64
+
65
+ // shape: fixed-shape record — trigger #7, three pure stages over the model above, no state.
66
+ export const spec: RowSpec<CodemodeModel> = {
67
+ buildCall(args, ctx) {
68
+ const code = typeof args.code === "string" ? args.code : "";
69
+ // Fail closed: no argument-derived output until the host affirms its args are complete.
70
+ if (ctx.argsComplete !== true || code === "") return { title: name };
71
+ const lines = code.trimEnd().split("\n").length;
72
+ return { title: `${name} · ${lines} line${lines === 1 ? "" : "s"}` };
73
+ },
74
+
75
+ build(result) {
76
+ const details = result.details;
77
+ const record = isRecord(details) ? details : undefined;
78
+ const blocks = textBlocks(result);
79
+ if (record === undefined && blocks.length === 0) return "unknown";
80
+ const parsed = parseOutput(blocks);
81
+ const fullOutputPath = record?.fullOutputPath;
82
+ return {
83
+ calls: parseCalls(record),
84
+ output: parsed.output,
85
+ failed: parsed.failed,
86
+ fullOutputPath: typeof fullOutputPath === "string" ? fullOutputPath : undefined,
87
+ };
88
+ },
89
+
90
+ project(model, view, paint) {
91
+ if (view.isError) return errorRow(model, view, paint);
92
+ if (!view.expanded) return collapsedRow(model, view, paint);
93
+ return expandedBody(model, view, paint);
94
+ },
95
+ };
96
+
97
+ /* ── parse ─────────────────────────────────────────────────────────────────── */
98
+
99
+ function isRecord(value: unknown): value is Record<string, unknown> {
100
+ return typeof value === "object" && value !== null && !Array.isArray(value);
101
+ }
102
+
103
+ function textBlocks(result: ToolResult): string[] {
104
+ const blocks = result.content ?? [];
105
+ const texts: string[] = [];
106
+ for (const block of blocks) {
107
+ if (block.type === "text" && typeof block.text === "string") texts.push(block.text);
108
+ }
109
+ return texts;
110
+ }
111
+
112
+ /** Splits the header off the first block, wherever the host put the output that follows it. */
113
+ function parseOutput(blocks: string[]): { output: string; failed: boolean } {
114
+ const first = blocks[0];
115
+ if (first === undefined) return { output: "", failed: false };
116
+ const header = SCRIPT_HEADER.exec(first);
117
+ if (header === null) return { output: blocks.join("\n").trimEnd(), failed: false };
118
+ const rest = [first.slice(header[0].length), ...blocks.slice(1)].filter((part) => part !== "");
119
+ return { output: rest.join("\n").trimEnd(), failed: header[1] === "failed" };
120
+ }
121
+
122
+ function parseCalls(details: Record<string, unknown> | undefined): NestedCall[] {
123
+ const raw = details?.calls;
124
+ if (!Array.isArray(raw)) return [];
125
+ const calls: NestedCall[] = [];
126
+ for (const item of raw) {
127
+ if (!isRecord(item)) continue;
128
+ const call: NestedCall = {
129
+ name: typeof item.name === "string" ? item.name : "",
130
+ args: typeof item.args === "string" ? item.args : "",
131
+ status: typeof item.status === "string" ? item.status : "",
132
+ };
133
+ if (typeof item.durationMs === "number" && Number.isFinite(item.durationMs)) call.durationMs = item.durationMs;
134
+ if (typeof item.cost === "number" && Number.isFinite(item.cost)) call.cost = item.cost;
135
+ if (typeof item.error === "string" && item.error !== "") call.error = item.error;
136
+ calls.push(call);
137
+ }
138
+ return calls;
139
+ }
140
+
141
+ /* ── project ───────────────────────────────────────────────────────────────── */
142
+
143
+ function collapsedRow(model: CodemodeModel, view: RowView, paint: RowPaint): string {
144
+ const icon = statusIcon(aggregateStatus(model, view.isPartial), paint);
145
+ return `${paint.muted(countLabel(model.calls.length))} ${paint.muted("·")} ${icon}${pathSuffix(model, paint)}`;
146
+ }
147
+
148
+ function errorRow(model: CodemodeModel, view: RowView, paint: RowPaint): string {
149
+ const first = firstLine(model.output);
150
+ if (!view.expanded) {
151
+ const summary = first === "" ? countLabel(model.calls.length) : clip(first, MAX_ERROR_SUMMARY);
152
+ return `${paint.error(`✗ ${summary}`)}${pathSuffix(model, paint)}`;
153
+ }
154
+ if (first === "") return collapsedRow(model, view, paint);
155
+ return paintLines(model.output, paint.error).join("\n");
156
+ }
157
+
158
+ function expandedBody(model: CodemodeModel, view: RowView, paint: RowPaint): string {
159
+ const rows: string[] = [];
160
+ for (const call of model.calls) rows.push(...callLines(call, paint));
161
+ // A streaming script's output is not final: its calls stream, its body waits (the host's contract).
162
+ const output = view.isPartial ? "" : model.output;
163
+ if (output !== "") {
164
+ if (rows.length > 0) rows.push("");
165
+ rows.push(...paintLines(output, paint.output));
166
+ }
167
+ const path = model.fullOutputPath;
168
+ if (path !== undefined) rows.push(paint.muted(`Full output: ${path}`));
169
+ return rows.join("\n");
170
+ }
171
+
172
+ function callLines(call: NestedCall, paint: RowPaint): string[] {
173
+ let line = `${statusIcon(call.status, paint)} ${paint.title(call.name)}`;
174
+ if (call.args !== "") line += ` ${paint.muted(clip(call.args, MAX_ARGS_PREVIEW))}`;
175
+ const duration = formatDuration(call.durationMs);
176
+ if (duration !== "") line += ` ${paint.muted(duration)}`;
177
+ const cost = formatCost(call.cost);
178
+ if (cost !== "") line += ` ${paint.muted(cost)}`;
179
+ if (call.error === undefined) return [line];
180
+ // The host hangs a failed call's message under its own line, indented.
181
+ return [
182
+ line,
183
+ paint.error(
184
+ call.error
185
+ .split("\n")
186
+ .map((part) => ` ${part}`)
187
+ .join("\n"),
188
+ ),
189
+ ];
190
+ }
191
+
192
+ /* ── helpers ───────────────────────────────────────────────────────────────── */
193
+
194
+ function aggregateStatus(model: CodemodeModel, isPartial: boolean): CallStatus {
195
+ if (model.calls.some((call) => call.status === "running")) return "running";
196
+ if (model.failed || model.calls.some((call) => call.status === "error")) return "error";
197
+ if (model.calls.some((call) => call.status === "cancelled")) return "cancelled";
198
+ return isPartial ? "running" : "ok";
199
+ }
200
+
201
+ function statusIcon(status: string, paint: RowPaint): string {
202
+ return isCallStatus(status) ? STATUS_ICON[status](paint) : paint.muted("?");
203
+ }
204
+
205
+ /** A switch, not a table lookup: an inherited member such as "toString" must not read as a status. */
206
+ function isCallStatus(status: string): status is CallStatus {
207
+ switch (status) {
208
+ case "running":
209
+ case "ok":
210
+ case "error":
211
+ case "cancelled":
212
+ return true;
213
+ default:
214
+ return false;
215
+ }
216
+ }
217
+
218
+ function countLabel(count: number): string {
219
+ return `${count} tool call${count === 1 ? "" : "s"}`;
220
+ }
221
+
222
+ function pathSuffix(model: CodemodeModel, paint: RowPaint): string {
223
+ const path = model.fullOutputPath;
224
+ return path === undefined ? "" : ` ${paint.muted("·")} ${paint.muted(`Full output: ${path}`)}`;
225
+ }
226
+
227
+ function paintLines(text: string, role: (line: string) => string): string[] {
228
+ return text.split("\n").map((line) => role(line));
229
+ }
230
+
231
+ function formatDuration(ms: number | undefined): string {
232
+ if (ms === undefined) return "";
233
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
234
+ }
235
+
236
+ function formatCost(cost: number | undefined): string {
237
+ if (cost === undefined || cost === 0) return "";
238
+ return `$${cost >= 0.01 ? cost.toFixed(2) : cost.toPrecision(2)}`;
239
+ }
240
+
241
+ function firstLine(text: string): string {
242
+ const end = text.indexOf("\n");
243
+ return (end === -1 ? text : text.slice(0, end)).trim();
244
+ }
245
+
246
+ function clip(text: string, max: number): string {
247
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
248
+ }