@zenodinh/pi-render 0.0.0-stage → 0.1.2

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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -2
  3. package/index.ts +284 -0
  4. package/package.json +70 -5
  5. package/src/commands/canvas.ts +57 -0
  6. package/src/core/code-theme.ts +275 -0
  7. package/src/core/log.ts +57 -0
  8. package/src/core/paint.ts +64 -0
  9. package/src/core/registry.ts +135 -0
  10. package/src/core/settings.ts +119 -0
  11. package/src/core/types/code-theme.ts +24 -0
  12. package/src/core/types/host.ts +169 -0
  13. package/src/core/types/log.ts +28 -0
  14. package/src/core/types/paint.ts +54 -0
  15. package/src/core/types/registry.ts +44 -0
  16. package/src/core/types/settings.ts +31 -0
  17. package/src/core/types.ts +34 -0
  18. package/src/renderers/content/artifacts/cache.ts +234 -0
  19. package/src/renderers/content/artifacts/cards.ts +136 -0
  20. package/src/renderers/content/artifacts/engines.ts +396 -0
  21. package/src/renderers/content/artifacts/local-binary.ts +80 -0
  22. package/src/renderers/content/artifacts/prereqs.ts +128 -0
  23. package/src/renderers/content/artifacts/server.ts +181 -0
  24. package/src/renderers/content/code-panel.ts +161 -0
  25. package/src/renderers/content/image-card.ts +252 -0
  26. package/src/renderers/content/index.ts +79 -0
  27. package/src/renderers/content/json-panel.ts +116 -0
  28. package/src/renderers/content/table.ts +174 -0
  29. package/src/renderers/content/types.ts +20 -0
  30. package/src/renderers/tool/index.ts +113 -0
  31. package/src/renderers/tool/runtime.ts +267 -0
  32. package/src/renderers/tool/specs/bash.ts +168 -0
  33. package/src/renderers/tool/specs/codemode.ts +248 -0
  34. package/src/renderers/tool/specs/edit.ts +213 -0
  35. package/src/renderers/tool/specs/ls.ts +136 -0
  36. package/src/renderers/tool/specs/read.ts +296 -0
  37. package/src/renderers/tool/specs/search.ts +325 -0
  38. package/src/renderers/tool/specs/write.ts +142 -0
  39. package/src/renderers/tool/types.ts +45 -0
  40. package/themes/dracula-soft.json +81 -0
  41. package/themes/one-dark.json +80 -0
@@ -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
+ }
@@ -0,0 +1,213 @@
1
+ // ported from pi-pretty-tui/src/features/edit-write-collapse/render.ts (EDIT half) — survives because:
2
+ // the host builtin edit already returns `details.diff` (recorded: @earendil-works/pi-coding-agent@1.0.4,
3
+ // dist/core/tools/edit.js:132), so the collapsed count summary and the expanded diff need no
4
+ // registration; the registration, its execute wrapper and its model instructions stay behind.
5
+
6
+ /**
7
+ * edit.ts — the `edit` row: collapsed changed-line counts, expanded diff.
8
+ *
9
+ * Boundary: `details` and the result content blocks are host payloads, so both are narrowed by
10
+ * `isRecord` before a field is read; args are read as strings, and only once `argsComplete`.
11
+ *
12
+ * shape: none — dispatch object does not apply: one tool name, no discriminator. The export below is
13
+ * trigger #7 (fixed-shape record, fields known statically); `TONE_ROLE` declares its own shape.
14
+ */
15
+
16
+ import { relative } from "node:path";
17
+ import type {
18
+ CallModel,
19
+ ModuleDescriptor,
20
+ RenderContext,
21
+ RowPaint,
22
+ RowSpec,
23
+ RowView,
24
+ ToolResult,
25
+ } from "../../../core/types.ts";
26
+
27
+ const TOOL = "edit";
28
+ const PENDING = "pending";
29
+ const UNAVAILABLE = "diff unavailable";
30
+ const ADDED_MARK = "+";
31
+ /** U+2212: a hyphen would read as diff-body text, not as a count. */
32
+ const REMOVED_MARK = "−";
33
+ const ERROR_MARK = "✗";
34
+ const ELLIPSIS = "…";
35
+ const MAX_ERROR_SUMMARY = 80;
36
+
37
+ type DiffTone = "added" | "removed" | "context";
38
+
39
+ interface DiffLine {
40
+ tone: DiffTone;
41
+ /** The host's line verbatim, sign and line number included. */
42
+ text: string;
43
+ }
44
+
45
+ interface DiffModel {
46
+ lines: readonly DiffLine[];
47
+ added: number;
48
+ removed: number;
49
+ }
50
+
51
+ interface EditModel {
52
+ /** Result text blocks joined; the error body and the diff-less fallback read it. */
53
+ text: string;
54
+ /** Absent when the result carried no diff — the host streams none until the edit lands. */
55
+ diff?: DiffModel;
56
+ }
57
+
58
+ /** Diff tones map straight onto the host toolDiff* tokens (paint exposes them as roles). */
59
+ // shape: dispatch object — trigger #1, three tones → three stateless role lookups.
60
+ const TONE_ROLE = {
61
+ added: "diffAdded",
62
+ removed: "diffRemoved",
63
+ context: "diffContext",
64
+ } as const satisfies Record<DiffTone, keyof RowPaint>;
65
+
66
+ // shape: none — one straight-line parse; the branch is a boundary check, not a discriminator.
67
+ function build(result: ToolResult): EditModel {
68
+ const text = resultText(result);
69
+ const diff = diffOf(result.details);
70
+ return diff === undefined ? { text } : { text, diff: parseDiff(diff) };
71
+ }
72
+
73
+ // shape: none — one pass; the tone branch is a two-value classification of the line's first character.
74
+ function parseDiff(diff: string): DiffModel {
75
+ const lines: DiffLine[] = [];
76
+ let added = 0;
77
+ let removed = 0;
78
+ for (const raw of normalizeLineEndings(diff).split("\n")) {
79
+ const tone = toneOf(raw);
80
+ if (tone === "added") added += 1;
81
+ else if (tone === "removed") removed += 1;
82
+ lines.push({ tone, text: raw });
83
+ }
84
+ return { lines, added, removed };
85
+ }
86
+
87
+ function toneOf(line: string): DiffTone {
88
+ if (line.startsWith("+")) return "added";
89
+ if (line.startsWith("-")) return "removed";
90
+ return "context";
91
+ }
92
+
93
+ /** The host's own edit call line is name + short path; the args gate keeps a half-streamed path out. */
94
+ // shape: none — single collapsed-title builder; no dispatch over a discriminator.
95
+ function buildCall(args: Record<string, unknown>, ctx: RenderContext): CallModel {
96
+ const path = ctx.argsComplete === false ? undefined : pathOf(args);
97
+ return { title: path === undefined ? TOOL : `${TOOL}: ${displayPath(ctx.cwd, path)}` };
98
+ }
99
+
100
+ // shape: none — three view-state branches of straight-line text; no dispatch over a discriminator value.
101
+ function project(m: EditModel, view: RowView, paint: RowPaint): string {
102
+ if (view.isError) {
103
+ const lines = errorLines(m.text);
104
+ return view.expanded ? errorBody(lines, paint) : errorSummary(lines, paint);
105
+ }
106
+ const diff = m.diff;
107
+ // A diff-less result reads pending collapsed (the predecessor's port) but shows its text expanded.
108
+ if (diff === undefined) return view.expanded ? fallbackBody(m.text, paint) : pendingSuffix(paint);
109
+ return view.expanded ? diffBody(diff.lines, paint) : countsSuffix(diff, paint);
110
+ }
111
+
112
+ /** The runtime draws this body on its own line under the call line; the one-space lead is the
113
+ predecessor's tool-body indent (TOOL_RESULT_INDENT), so the counts read as the title's subordinate. */
114
+ function countsSuffix(diff: DiffModel, paint: RowPaint): string {
115
+ return ` ${paint.diffAdded(`${ADDED_MARK}${diff.added}`)} ${paint.diffRemoved(`${REMOVED_MARK}${diff.removed}`)}`;
116
+ }
117
+
118
+ function pendingSuffix(paint: RowPaint): string {
119
+ return ` ${paint.muted(`· ${PENDING}`)}`;
120
+ }
121
+
122
+ function errorSummary(lines: readonly string[], paint: RowPaint): string {
123
+ const first = (lines.find((line) => line.trim() !== "") ?? "Error").trim();
124
+ const summary = first.length > MAX_ERROR_SUMMARY ? `${first.slice(0, MAX_ERROR_SUMMARY - 1)}${ELLIPSIS}` : first;
125
+ return ` ${paint.error(`${ERROR_MARK} ${summary}`)}`;
126
+ }
127
+
128
+ function errorBody(lines: readonly string[], paint: RowPaint): string {
129
+ return lines.map((line) => (line === "" ? "" : paint.error(line))).join("\n");
130
+ }
131
+
132
+ function diffBody(lines: readonly DiffLine[], paint: RowPaint): string {
133
+ return lines.map((line) => paint[TONE_ROLE[line.tone]](line.text)).join("\n");
134
+ }
135
+
136
+ function fallbackBody(text: string, paint: RowPaint): string {
137
+ const rows = [paint.muted(UNAVAILABLE)];
138
+ const raw = normalizeLineEndings(text).trim();
139
+ if (raw !== "") rows.push(...raw.split("\n").map((line) => paint.output(line)));
140
+ return rows.join("\n");
141
+ }
142
+
143
+ /** `details` is a host payload: the diff is read only when it is a string. */
144
+ function diffOf(details: unknown): string | undefined {
145
+ if (!isRecord(details)) return undefined;
146
+ const diff = details.diff;
147
+ return typeof diff === "string" ? diff : undefined;
148
+ }
149
+
150
+ /** Only text blocks with a string body contribute; a malformed block is skipped, never thrown on. */
151
+ function resultText(result: ToolResult): string {
152
+ const blocks = result.content;
153
+ if (!Array.isArray(blocks)) return "";
154
+ const parts: string[] = [];
155
+ for (const block of blocks) {
156
+ if (isRecord(block) && block.type === "text" && typeof block.text === "string") parts.push(block.text);
157
+ }
158
+ return parts.join("\n");
159
+ }
160
+
161
+ /** The host builtin's schema is `path`; `file_path` is the predecessor's legacy spelling. */
162
+ function pathOf(args: Record<string, unknown> | undefined): string | undefined {
163
+ return stringArg(args, "path") ?? stringArg(args, "file_path");
164
+ }
165
+
166
+ function stringArg(args: Record<string, unknown> | undefined, key: string): string | undefined {
167
+ const value = args?.[key];
168
+ return typeof value === "string" && value !== "" ? value : undefined;
169
+ }
170
+
171
+ /** The predecessor's shortPath without its HOME arm: a spec reads no env, so only cwd shortens. */
172
+ function displayPath(cwd: string | undefined, raw: string): string {
173
+ if (cwd === undefined) return raw;
174
+ const rel = relative(cwd, raw);
175
+ return rel === "" || rel.startsWith("..") ? raw : rel;
176
+ }
177
+
178
+ /**
179
+ * Predecessor renderToolError: CRLF normalized, outer blanks trimmed, blank runs collapsed; a payload
180
+ * with no text reads "Error" so a failed call never renders blank.
181
+ */
182
+ function errorLines(text: string): readonly string[] {
183
+ const lines: string[] = [];
184
+ for (const line of normalizeLineEndings(text).trim().split("\n")) {
185
+ if (line === "" && lines[lines.length - 1] === "") continue;
186
+ lines.push(line);
187
+ }
188
+ return lines[0] === "" ? ["Error"] : lines;
189
+ }
190
+
191
+ function normalizeLineEndings(text: string): string {
192
+ return text.replace(/\r\n?/g, "\n");
193
+ }
194
+
195
+ function isRecord(value: unknown): value is Record<string, unknown> {
196
+ return typeof value === "object" && value !== null && !Array.isArray(value);
197
+ }
198
+
199
+ const descriptor: ModuleDescriptor = {
200
+ key: "row.edit",
201
+ name: "Edit row",
202
+ defaultEnabled: true,
203
+ settings: [],
204
+ };
205
+
206
+ // shape: object literal satisfying the lane's fixed-shape record — trigger #7; the spec holds no
207
+ // closure state, so trigger #4 does not apply.
208
+ export const editRow = {
209
+ name: TOOL,
210
+ // An `edit` result is either a diff or text and both render, so build never returns "unknown".
211
+ spec: { buildCall, build, project } satisfies RowSpec<EditModel>,
212
+ descriptor,
213
+ };
@@ -0,0 +1,136 @@
1
+ // ported from pi-pretty-tui/src/tools/ls.ts — survives because: the collapsed entry count and the
2
+ // expanded tree are pure projections of the result text. The predecessor's registration, execute
3
+ // wrapper and injected `entryCount` (tools/metrics.ts) die with it — the count is parsed from the
4
+ // content lines, so the row needs no wrapper to know what it drew.
5
+ // Two deliberate deviations: file-type icon glyphs are dropped (the predecessor's tables hold
6
+ // hardcoded RGB escapes and paint owns every escape), and `process.env.HOME` becomes `homedir()` so
7
+ // no raw environment read crosses this boundary.
8
+
9
+ /**
10
+ * ls.ts — the ls row spec: a collapsed entry count, an expanded directory tree.
11
+ *
12
+ * Boundary: the host result is a structural shape, never a validated one — content blocks narrow
13
+ * field by field, and `result.details` is never read. Argument values stay behind the argsComplete
14
+ * gate: an unread argument can never be rendered as a guessed one.
15
+ *
16
+ * shape: object literal implementing RowSpec — trigger #7, the three contract fields are fixed-shape
17
+ * functions with no per-row state of their own, so a closure or class would be ceremony.
18
+ */
19
+
20
+ import { homedir } from "node:os";
21
+ import { isAbsolute, relative } from "node:path";
22
+ import type { ToolResult } from "../../../core/types/host.ts";
23
+ import type { RowPaint } from "../../../core/types/paint.ts";
24
+ import type { ModuleDescriptor } from "../../../core/types/registry.ts";
25
+ import type { RowSpec } from "../types.ts";
26
+
27
+ const INDENT = " ";
28
+ const HINT = "ctrl+o to expand";
29
+ /** Entries past this fold into a "… N more entries" line; the predecessor's 80-line preview cap. */
30
+ const TREE_PREVIEW_LINES = 80;
31
+
32
+ /** The parsed ls result: the entry lines, plus the raw text the error rows render from. */
33
+ export interface LsModel {
34
+ /** Content-derived entry lines in host order; a directory keeps its trailing "/". */
35
+ entries: string[];
36
+ /** The result text verbatim — never re-derived, so an error body renders unedited. */
37
+ text: string;
38
+ }
39
+
40
+ /** boundary: a result block is unvalidated at runtime; each field is checked before it is read. */
41
+ function resultText(result: ToolResult): string {
42
+ const blocks = result.content;
43
+ if (!Array.isArray(blocks)) return "";
44
+ const parts: string[] = [];
45
+ for (const block of blocks) {
46
+ if (typeof block !== "object" || block === null) continue;
47
+ if (block.type === "text" && typeof block.text === "string") parts.push(block.text);
48
+ }
49
+ return parts.join("\n");
50
+ }
51
+
52
+ /** Inside cwd: relative. Outside: home-shortened. A relative argument already reads as written. */
53
+ function shortPath(cwd: string | undefined, path: string): string {
54
+ if (path.length === 0) return "";
55
+ if (!isAbsolute(path)) return path;
56
+ if (cwd === undefined || cwd.length === 0) return path;
57
+ const rel = relative(cwd, path);
58
+ if (!rel.startsWith("..")) return rel;
59
+ const home = homedir();
60
+ return home.length > 1 && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
61
+ }
62
+
63
+ /** Blank-run collapse only: one blank line survives, consecutive ones fold — an error body stays legible. */
64
+ function compactLines(text: string): string[] {
65
+ const out: string[] = [];
66
+ let blank = false;
67
+ for (const line of text.replaceAll("\r\n", "\n").replaceAll("\r", "\n").trim().split("\n")) {
68
+ const isBlank = line.trim().length === 0;
69
+ if (isBlank && blank) continue;
70
+ out.push(line);
71
+ blank = isBlank;
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /** Collapsed: the error's first line, then the hint. Expanded: the compacted body, indented. */
77
+ function errorBody(text: string, expanded: boolean, paint: RowPaint): string {
78
+ const [first = "Error", ...rest] = compactLines(text);
79
+ if (!expanded) return `${paint.error(first)}${paint.muted(` · ${HINT}`)}`;
80
+ return [first, ...rest].map((line) => (line.trim().length === 0 ? "" : `${INDENT}${paint.error(line)}`)).join("\n");
81
+ }
82
+
83
+ /** One line per entry: the connector from the gutter role, a directory name from accent. */
84
+ function tree(entries: string[], paint: RowPaint): string {
85
+ const shown = entries.slice(0, TREE_PREVIEW_LINES);
86
+ const lines = shown.map((entry, index) => {
87
+ const isDir = entry.endsWith("/");
88
+ const name = isDir ? entry.slice(0, -1) : entry;
89
+ // Only a fully-shown list ends in "└──"; a truncated list ends in its own "… more" line.
90
+ const last = index === shown.length - 1 && entries.length <= TREE_PREVIEW_LINES;
91
+ return `${INDENT}${paint.gutter(last ? "└── " : "├── ")}${isDir ? paint.accent(name) : name}`;
92
+ });
93
+ if (entries.length > TREE_PREVIEW_LINES) {
94
+ const hidden = entries.length - TREE_PREVIEW_LINES;
95
+ lines.push(`${INDENT}${paint.gutter("└── ")}${paint.muted(`… ${hidden} more entries`)}`);
96
+ }
97
+ return lines.join("\n");
98
+ }
99
+
100
+ const spec: RowSpec<LsModel> = {
101
+ buildCall(args, ctx) {
102
+ // argsComplete is the gate: the host streams arguments, and a guessed value is worse than none.
103
+ if (ctx.argsComplete !== true) return { title: "ls" };
104
+ const rawPath = args.path;
105
+ const path = typeof rawPath === "string" ? shortPath(ctx.cwd, rawPath) : "";
106
+ const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? args.limit : undefined;
107
+ return { title: `ls${path.length > 0 ? ` ${path}` : ""}${limit === undefined ? "" : ` (limit ${limit})`}` };
108
+ },
109
+
110
+ build(result) {
111
+ const text = resultText(result);
112
+ if (text.trim().length === 0) return "unknown";
113
+ return {
114
+ text,
115
+ entries: text
116
+ .split("\n")
117
+ .map((line) => line.trim())
118
+ .filter((line) => line.length > 0),
119
+ };
120
+ },
121
+
122
+ project(model, view, paint) {
123
+ if (view.isError) return errorBody(model.text, view.expanded, paint);
124
+ const count = model.entries.length;
125
+ const summary = `${count} ${count === 1 ? "entry" : "entries"}`;
126
+ if (!view.expanded) return paint.muted(`${summary} · ${HINT}`);
127
+ return [paint.muted(summary), "", tree(model.entries, paint)].join("\n");
128
+ },
129
+ };
130
+
131
+ // shape: object literal — trigger #7, the spec module record T-28 composes; fields are known statically.
132
+ export const lsRow: { name: string; spec: RowSpec<LsModel>; descriptor: ModuleDescriptor } = {
133
+ name: "ls",
134
+ spec,
135
+ descriptor: { key: "row.ls", name: "Ls row", defaultEnabled: true, settings: [] },
136
+ };