@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,296 @@
1
+ // ported from pi-pretty-tui/src/tools/read.ts — survives because: the compact row (skill header, docs
2
+ // label, short path, line count) and the numbered range body are drawing logic, and none of it needed
3
+ // the registration, the execute wrapper, the injected details or the metrics wrapper it lived in; the
4
+ // raw escapes it drew with move to project-time paint. Two port bugs die here: the predecessor numbered
5
+ // lines from a 0-based offset (it echoed the host's `offset` untranslated) and counted the host's own
6
+ // continuation notice as a source line.
7
+
8
+ /**
9
+ * read.ts — the read row: a compact file summary, and the numbered range when expanded.
10
+ *
11
+ * Boundary: the result's content blocks, `details` and the tool arguments are host payloads; each is
12
+ * narrowed by a predicate below before a field is read, and a result with no text block returns
13
+ * "unknown" so the runtime degrades to its raw row instead of drawing a plausible-but-empty one.
14
+ *
15
+ * No highlighting: a shiki engine reaches a row only through an injected dependency, and RowSpec has no
16
+ * deps parameter. Async highlight belongs to the runtime's decorate seam (its invalidate + staleness
17
+ * token), which this spec leaves unclaimed — plain numbered content stays readable and keeps `project`
18
+ * synchronous and allocation-light, as it re-runs on every frame.
19
+ *
20
+ * shape: none — dispatch object does not apply: the spec is one fixed record of three functions, with
21
+ * no discriminator and no state to close over. ReadModel and HEADER below declare their own shapes.
22
+ */
23
+
24
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
25
+ import type {
26
+ CallModel,
27
+ ModuleDescriptor,
28
+ RenderContext,
29
+ RowPaint,
30
+ RowSpec,
31
+ RowView,
32
+ ToolResult,
33
+ } from "../../../core/types.ts";
34
+
35
+ /** Which of the three header shapes the row draws. */
36
+ type HeaderKind = "path" | "docs" | "skill";
37
+
38
+ /** What build parsed once: a failed read's body, or a file range with its classified header. */
39
+ // shape: type + object literals — trigger #7, two closed parse outcomes; a region one outcome lacks is
40
+ // `T | undefined` (trigger #8) rather than a field the other outcome has to invent.
41
+ type ReadModel =
42
+ | { kind: "error"; label: string; lines: string[] }
43
+ | {
44
+ kind: "file";
45
+ header: HeaderKind;
46
+ /** Skill name, docs label, or cwd-shortened path; "" when the host sent no path. */
47
+ label: string;
48
+ /** 1-indexed number of the first shown line. */
49
+ offset: number;
50
+ lines: string[];
51
+ /** The host's own continuation notice, when it cut the read short. */
52
+ notice: string | undefined;
53
+ };
54
+
55
+ type FileModel = Extract<ReadModel, { kind: "file" }>;
56
+ type ErrorModel = Extract<ReadModel, { kind: "error" }>;
57
+
58
+ /** One metadata line is the collapse-first contract; a longer message is the expanded body's job. */
59
+ const SUMMARY_MAX = 120;
60
+
61
+ /** The host's user-limit notice — the one trailer it appends with no details.truncation block (read.js:132-137). */
62
+ const MORE_LINES_NOTICE = /^\[\d+ more lines in file\. Use offset=\d+ to continue\.\]$/;
63
+
64
+ /** The lead text per classification, one stateless painter each. */
65
+ // shape: dispatch object — trigger #1, three branches on `header`, handlers stateless.
66
+ const HEADER: Record<HeaderKind, (paint: RowPaint) => string> = {
67
+ path: (paint) => paint.title("→ read"),
68
+ docs: (paint) => paint.title("→ read docs"),
69
+ skill: (paint) => paint.accent("[skill]"),
70
+ };
71
+
72
+ /**
73
+ * The call line is the tool name only: the predecessor's read call slot was intentionally blank (the
74
+ * summary is the result slot's job), and this spec draws that summary itself — a path here would print
75
+ * twice on every collapsed row.
76
+ */
77
+ function buildCall(): CallModel {
78
+ return { title: "read" };
79
+ }
80
+
81
+ function build(result: ToolResult, ctx: RenderContext): ReadModel | "unknown" {
82
+ const path = argString(ctx.args, "path");
83
+ if (failed(result, ctx)) {
84
+ const lines = compact(textOf(result) ?? "");
85
+ return {
86
+ kind: "error",
87
+ label: path === undefined ? "" : shortPath(ctx.cwd, path),
88
+ lines: lines.length > 0 ? lines : ["failed"],
89
+ };
90
+ }
91
+ const text = textOf(result);
92
+ if (text === undefined) return "unknown";
93
+ const content = normalize(text);
94
+ const split = splitNotice(content, truncatedBody(result.details));
95
+ const classified = classify(path, ctx.cwd, content);
96
+ return {
97
+ kind: "file",
98
+ header: classified.header,
99
+ label: classified.label,
100
+ offset: argOffset(ctx.args),
101
+ lines: trimTrailing(split.body.split("\n")),
102
+ notice: split.notice,
103
+ };
104
+ }
105
+
106
+ function project(m: ReadModel, view: RowView, paint: RowPaint): string {
107
+ if (m.kind === "error") return view.expanded ? errorBody(m, paint) : errorSummary(m, paint);
108
+ return view.expanded ? fileBody(m, paint) : fileSummary(m, paint);
109
+ }
110
+
111
+ /** `→ read <label>` / `→ read docs <label>` / `[skill] <name>`, with the range when the read starts late. */
112
+ function header(m: FileModel, paint: RowPaint): string {
113
+ const range = m.offset > 1 ? paint.muted(`:${m.offset}`) : "";
114
+ const label = m.label === "" ? "" : ` ${paint.title(m.label)}`;
115
+ return `${HEADER[m.header](paint)}${label}${range}`;
116
+ }
117
+
118
+ function fileSummary(m: FileModel, paint: RowPaint): string {
119
+ const lines = m.lines.length;
120
+ return `${header(m, paint)} ${paint.muted(`${lines} ${lines === 1 ? "line" : "lines"} — ctrl+o to expand`)}`;
121
+ }
122
+
123
+ function fileBody(m: FileModel, paint: RowPaint): string {
124
+ // A fixed gutter width keeps the numbers aligned across frames; 3 is the predecessor's floor.
125
+ const width = Math.max(3, String(m.offset + m.lines.length - 1).length);
126
+ const numbered = m.lines.map(
127
+ (line, index) =>
128
+ `${paint.gutter(String(m.offset + index).padStart(width, " "))} ${paint.muted("│")} ${paint.output(line)}`,
129
+ );
130
+ const hint = m.header === "skill" ? ` ${paint.muted("ctrl+o to collapse")}` : "";
131
+ const notice = m.notice === undefined ? [] : [paint.warning(m.notice)];
132
+ return [header(m, paint) + hint, ...numbered, ...notice].join("\n");
133
+ }
134
+
135
+ function errorSummary(m: ErrorModel, paint: RowPaint): string {
136
+ const first = m.lines.find((line) => line.trim() !== "") ?? "failed";
137
+ const message = first.length > SUMMARY_MAX ? `${first.slice(0, SUMMARY_MAX - 1)}…` : first;
138
+ const label = m.label === "" ? "" : ` ${paint.title(m.label)}`;
139
+ return `${paint.error("→ read")}${label} ${paint.error(message)}`;
140
+ }
141
+
142
+ function errorBody(m: ErrorModel, paint: RowPaint): string {
143
+ return m.lines.map((line) => (line === "" ? "" : paint.error(line))).join("\n");
144
+ }
145
+
146
+ /** The host flags a failed call on the result and on the render context; either one means an error row. */
147
+ function failed(result: ToolResult, ctx: RenderContext): boolean {
148
+ return result.isError === true || ctx.isError === true;
149
+ }
150
+
151
+ /** boundary: arguments are host payloads; only a non-empty string is a path. */
152
+ function argString(args: Record<string, unknown> | undefined, key: string): string | undefined {
153
+ const value = args?.[key];
154
+ return typeof value === "string" && value !== "" ? value : undefined;
155
+ }
156
+
157
+ /** boundary: offset is 1-indexed host input; anything else means the read started at line 1. */
158
+ function argOffset(args: Record<string, unknown> | undefined): number {
159
+ const value = args?.offset;
160
+ return typeof value === "number" && Number.isInteger(value) && value > 1 ? value : 1;
161
+ }
162
+
163
+ /** boundary: a content block is a host payload; only a "text" block with a string body is readable. */
164
+ function textBlock(value: unknown): string | undefined {
165
+ if (typeof value !== "object" || value === null) return undefined;
166
+ if (!("type" in value) || value.type !== "text") return undefined;
167
+ if (!("text" in value)) return "";
168
+ return typeof value.text === "string" ? value.text : "";
169
+ }
170
+
171
+ /** The joined text of the result's text blocks; undefined when the result carries none. */
172
+ function textOf(result: ToolResult): string | undefined {
173
+ const blocks: unknown = result.content;
174
+ if (!isUnknownArray(blocks)) return undefined;
175
+ const texts: string[] = [];
176
+ for (const block of blocks) {
177
+ const text = textBlock(block);
178
+ if (text !== undefined) texts.push(text);
179
+ }
180
+ return texts.length === 0 ? undefined : texts.join("\n");
181
+ }
182
+
183
+ /** boundary: an array-shaped host payload stays `unknown[]`, so every element is narrowed on read. */
184
+ function isUnknownArray(value: unknown): value is unknown[] {
185
+ return Array.isArray(value);
186
+ }
187
+
188
+ /** boundary: details is a host payload; the body the host cut is readable only as a string field. */
189
+ function truncatedBody(details: unknown): string | undefined {
190
+ if (typeof details !== "object" || details === null) return undefined;
191
+ if (!("truncation" in details)) return undefined;
192
+ const truncation = details.truncation;
193
+ if (typeof truncation !== "object" || truncation === null) return undefined;
194
+ if (!("content" in truncation)) return undefined;
195
+ return typeof truncation.content === "string" ? truncation.content : undefined;
196
+ }
197
+
198
+ /**
199
+ * Separates the body from the host's trailing notice, so the notice is never numbered as a source line.
200
+ * `body` is the cut body from details when there is one; the user-limit notice has no details block and
201
+ * is recognized by its own wording.
202
+ */
203
+ function splitNotice(text: string, body: string | undefined): { body: string; notice: string | undefined } {
204
+ if (body !== undefined && text.startsWith(body)) {
205
+ const notice = text.slice(body.length).trim();
206
+ return { body, notice: notice === "" ? undefined : notice };
207
+ }
208
+ const at = text.lastIndexOf("\n\n[");
209
+ const notice = at === -1 ? "" : text.slice(at + 2).trim();
210
+ return MORE_LINES_NOTICE.test(notice) ? { body: text.slice(0, at), notice } : { body: text, notice: undefined };
211
+ }
212
+
213
+ /** A trailing newline is a line terminator, not a last line. */
214
+ function trimTrailing(lines: string[]): string[] {
215
+ let end = lines.length;
216
+ while (end > 0 && lines[end - 1] === "") end -= 1;
217
+ return end === lines.length ? lines : lines.slice(0, end);
218
+ }
219
+
220
+ /** CRLF files would otherwise leave a stray \r at every line end (P helpers.ts:12-14). */
221
+ function normalize(text: string): string {
222
+ return text.replace(/\r\n?/g, "\n");
223
+ }
224
+
225
+ /** Runs of blank lines are noise on an error body; one blank line keeps paragraphs apart (P helpers.ts:63-72). */
226
+ function compact(text: string): string[] {
227
+ const out: string[] = [];
228
+ for (const line of text.trim().split("\n")) {
229
+ if (line.trim() === "" && out[out.length - 1]?.trim() === "") continue;
230
+ out.push(line);
231
+ }
232
+ return trimTrailing(out);
233
+ }
234
+
235
+ /** The row's header text: a skill name, a docs label, or the shortened path (P read.ts:25-60). */
236
+ function classify(
237
+ path: string | undefined,
238
+ cwd: string | undefined,
239
+ content: string,
240
+ ): { header: HeaderKind; label: string } {
241
+ if (path === undefined) return { header: "path", label: "" };
242
+ if (basename(path) === "SKILL.md") return { header: "skill", label: skillName(path, content) };
243
+ const docs = docsLabel(path, cwd);
244
+ return docs === undefined ? { header: "path", label: shortPath(cwd, path) } : { header: "docs", label: docs };
245
+ }
246
+
247
+ /**
248
+ * Inside the tree the tool ran in, the root README and `docs/`, `examples/` read as a docs row
249
+ * (P read.ts:25-33) — anchored at the tool's cwd here: the predecessor compared against the pi package
250
+ * root through `getReadmePath()`, a host import this render-only spec may not make.
251
+ */
252
+ function docsLabel(path: string, cwd: string | undefined): string | undefined {
253
+ if (cwd === undefined) return undefined;
254
+ const inside = relative(cwd, resolve(cwd, path));
255
+ if (inside === "" || inside === ".." || inside.startsWith(`..${sep}`)) return undefined;
256
+ const label = inside.split(sep).join("/");
257
+ return label === "README.md" || label.startsWith("docs/") || label.startsWith("examples/") ? label : undefined;
258
+ }
259
+
260
+ /** SKILL.md's frontmatter `name:`, else its directory name (P read.ts:36-55). */
261
+ function skillName(path: string, content: string): string {
262
+ const fallback = basename(dirname(path));
263
+ const lines = content.split("\n");
264
+ if (lines[0]?.trim() !== "---") return fallback;
265
+ const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
266
+ for (const line of lines.slice(1, end === -1 ? 1 : end)) {
267
+ const match = /^name\s*:\s*(.+?)\s*$/.exec(line);
268
+ if (match === null) continue;
269
+ const value = (match[1] ?? "").trim();
270
+ const quoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"));
271
+ const name = quoted ? value.slice(1, -1).trim() : value;
272
+ return name === "" ? fallback : name;
273
+ }
274
+ return fallback;
275
+ }
276
+
277
+ /** The row shows a path the way the reader thinks of it: relative while the read is inside the tool's cwd. */
278
+ function shortPath(cwd: string | undefined, path: string): string {
279
+ if (cwd === undefined) return path;
280
+ const absolute = resolve(cwd, path);
281
+ const inside = relative(cwd, absolute);
282
+ return inside !== "" && !inside.startsWith("..") && !isAbsolute(inside) ? inside : absolute;
283
+ }
284
+
285
+ /** The read row spec, keyed in the resolver's table by the host's own tool name. */
286
+ // shape: none — one record of three fixed functions over the two parse outcomes above; no branch chain
287
+ // is left for a dispatch object (the error/file split is the union, the header split is HEADER).
288
+ export const readSpec: RowSpec<ReadModel> = { buildCall, build, project };
289
+
290
+ /** The record T-28 composes: the table key, the spec, and the descriptor it registers. */
291
+ // shape: object literal — trigger #7, the spec module record T-28 composes; fields are known statically.
292
+ export const readRow: { name: string; spec: RowSpec<ReadModel>; descriptor: ModuleDescriptor } = {
293
+ name: "read",
294
+ spec: readSpec,
295
+ descriptor: { key: "row.read", name: "Read row", defaultEnabled: true, settings: [] },
296
+ };
@@ -0,0 +1,197 @@
1
+ // Fixture provenance:
2
+ // - pi-fff fixtures (GREP_FFF, GREP_FFF_DETAILS, FUZZY, FIND_WEAK, D0..D2, and the parity bodies):
3
+ // invented — synthesized from pi-fff 0.11.0's formatting code via the DP4 dossier
4
+ // (system-analysis/working/dossier-dp4-search-fingerprints.md, 2026-10-06); no live capture exists.
5
+ // - host fixtures (HOST_GREP): invented — synthesized from host pi 1.0.4 grep.js:206 (DP4 raw-data).
6
+ // - the parity bodies are the format examples the predecessor parser pinned at
7
+ // pi-pretty-tui/src/core/fff-format.ts:12-18,31-41; it shipped no test file (verified — nothing in
8
+ // pi-pretty-tui imports parseGrepOutput/parseFindOutput), so AC-6 ports those expectations as fixtures.
9
+
10
+ // shape: none — test module: every behavior is asserted only through the exported SEARCH_SPECS /
11
+ // createSearchSpec seam.
12
+ import { describe, expect, test } from "vitest";
13
+ import { assertNoRealEscapes, markerPaint, testRenderContext } from "../../../../test/fakes/index.ts";
14
+ import { createSearchSpec, SEARCH_SPECS, SEARCH_TOOL_NAMES, type SearchModel, type SearchToolName } from "./search.ts";
15
+
16
+ const PAINT = markerPaint();
17
+ const COLLAPSED = { expanded: false, isError: false, isPartial: false };
18
+ const EXPANDED = { expanded: true, isError: false, isPartial: false };
19
+
20
+ type MatchesModel = Extract<SearchModel, { kind: "matches" }>;
21
+ type FilesModel = Extract<SearchModel, { kind: "files" }>;
22
+ type EmptyModel = Extract<SearchModel, { kind: "empty" }>;
23
+
24
+ function parse(name: SearchToolName, text: string, details: unknown, args: Record<string, unknown> = {}) {
25
+ const spec = createSearchSpec(name);
26
+ const row = testRenderContext({ args });
27
+ const invocation = row.result({ content: [{ type: "text", text }], details });
28
+ return { spec, model: spec.build(invocation.result, invocation.ctx) };
29
+ }
30
+
31
+ function narrowMatches(model: SearchModel | "unknown"): MatchesModel {
32
+ if (model === "unknown" || model.kind !== "matches") {
33
+ throw new Error(`expected a matches model, got ${JSON.stringify(model)}`);
34
+ }
35
+ return model;
36
+ }
37
+
38
+ function narrowFiles(model: SearchModel | "unknown"): FilesModel {
39
+ if (model === "unknown" || model.kind !== "files") {
40
+ throw new Error(`expected a files model, got ${JSON.stringify(model)}`);
41
+ }
42
+ return model;
43
+ }
44
+
45
+ function narrowEmpty(model: SearchModel | "unknown"): EmptyModel {
46
+ if (model === "unknown" || model.kind !== "empty") {
47
+ throw new Error(`expected an empty model, got ${JSON.stringify(model)}`);
48
+ }
49
+ return model;
50
+ }
51
+
52
+ const GREP_FFF = [
53
+ "src/payments.ts [modified in git]",
54
+ " 1: export interface PaymentRequest {",
55
+ " 3: route(...)",
56
+ "",
57
+ "src/other.ts [often touched file]",
58
+ " 7: const total = sum(payments)",
59
+ ].join("\n");
60
+ const GREP_FFF_DETAILS = { totalMatched: 3, totalFiles: 2 };
61
+
62
+ const HOST_GREP = ["src/payments.ts:1: export interface PaymentRequest {", "src/payments.ts:3: route(...)"].join("\n");
63
+
64
+ const FUZZY = [
65
+ "[0 exact matches. Maybe you meant this?]",
66
+ "src/payments.ts",
67
+ " 1: export interface PaymentRequest {",
68
+ ].join("\n");
69
+
70
+ const FIND_WEAK = [
71
+ "src/pay.ts",
72
+ "src/payments.ts",
73
+ "",
74
+ '[Query "p" produced only weak scattered fuzzy matches. Output capped at 5/30.]',
75
+ ].join("\n");
76
+
77
+ // The totals pi-fff always attaches to the shapes above (DP4: FFF:1126-1131).
78
+ const D0 = { totalMatched: 0, totalFiles: 0 };
79
+ const D1 = { totalMatched: 1, totalFiles: 1 };
80
+ const D2 = { totalMatched: 2, totalFiles: 2 };
81
+
82
+ describe("renderers/tool/specs/search — pi-fff fingerprint rows", () => {
83
+ test("AC-1 a pi-fff grep result renders a grouped row with the file count and highlighted matches", () => {
84
+ // fails_when: raw text shown for a pi-fff result.
85
+ const { spec, model } = parse("ffgrep", GREP_FFF, GREP_FFF_DETAILS, { pattern: "route" });
86
+ const matches = narrowMatches(model);
87
+ expect(matches.matchCount).toBe(3);
88
+ expect(matches.blocks).toHaveLength(2);
89
+ expect(matches.blocks[0]?.path).toBe("src/payments.ts");
90
+ expect(matches.blocks[0]?.annotation).toBe("modified in git");
91
+
92
+ expect(spec.project(matches, COLLAPSED, PAINT)).toBe(PAINT.muted("3 matches in 2 files"));
93
+
94
+ const expanded = spec.project(matches, EXPANDED, PAINT);
95
+ expect(expanded).toContain(PAINT.muted("3 matches in 2 files"));
96
+ expect(expanded).toContain(PAINT.accent("src/payments.ts"));
97
+ expect(expanded).toContain(PAINT.muted("[modified in git]"));
98
+ expect(expanded).toContain(PAINT.match("route"));
99
+ assertNoRealEscapes(expanded);
100
+ });
101
+
102
+ test("AC-2 host builtin output is not pi-fff, so build returns unknown", () => {
103
+ // fails_when: host output gets pi-fff styling (a plausible-but-wrong row).
104
+ expect(parse("grep", HOST_GREP, undefined).model).toBe("unknown");
105
+ expect(parse("find", "src/payments.ts\ntest/payments.test.ts", undefined).model).toBe("unknown");
106
+ });
107
+
108
+ test("AC-3 the empty strings separate on details presence only", () => {
109
+ // fails_when: both render the same way.
110
+ const styled = narrowEmpty(parse("ffgrep", "No matches found", D0).model);
111
+ expect(styled.noun).toBe("matches");
112
+ const emptyRow = parse("ffgrep", "No matches found", D0);
113
+ expect(emptyRow.spec.project(styled, COLLAPSED, PAINT)).toBe(PAINT.muted("0 matches"));
114
+ expect(parse("ffgrep", "No matches found", undefined).model).toBe("unknown");
115
+
116
+ const files = narrowEmpty(parse("fffind", "No files found matching pattern", D0).model);
117
+ expect(files.noun).toBe("files");
118
+ expect(parse("fffind", "No files found matching pattern", undefined).model).toBe("unknown");
119
+ });
120
+
121
+ test("AC-4 the fuzzy prefix is a notice, never a phantom file header", () => {
122
+ // fails_when: a phantom header inflates the file count (the ported bug).
123
+ const matches = narrowMatches(parse("ffgrep", FUZZY, D1).model);
124
+ expect(matches.blocks).toHaveLength(1);
125
+ expect(matches.blocks[0]?.path).toBe("src/payments.ts");
126
+ expect(matches.notices).toEqual(["0 exact matches. Maybe you meant this?"]);
127
+ });
128
+
129
+ test("AC-5 a multi-sentence notice stays one notice", () => {
130
+ // fails_when: the weak-match notice fragments on its internal ". ".
131
+ const files = narrowFiles(parse("fffind", FIND_WEAK, { totalMatched: 30, totalFiles: 30 }).model);
132
+ expect(files.files).toHaveLength(2);
133
+ expect(files.notices).toEqual(['Query "p" produced only weak scattered fuzzy matches. Output capped at 5/30.']);
134
+ });
135
+
136
+ test("AC-1 (find) a pi-fff find result groups by directory with counts and annotations", () => {
137
+ // fails_when: entries render ungrouped, or a count or annotation is wrong.
138
+ const body = ["src/payments.ts", "test/payments.test.ts [modified in git]", "src/api/payments.ts"].join("\n");
139
+ const { spec, model } = parse("fffind", body, { totalMatched: 3, totalFiles: 3 });
140
+ const files = narrowFiles(model);
141
+ expect(spec.project(files, COLLAPSED, PAINT)).toBe(PAINT.muted("3 files"));
142
+
143
+ const expanded = spec.project(files, EXPANDED, PAINT);
144
+ expect(expanded).toContain(PAINT.accent("src/"));
145
+ expect(expanded).toContain(PAINT.accent("src/api/"));
146
+ expect(expanded).toContain(PAINT.accent("test/"));
147
+ expect(expanded).toContain(PAINT.output("payments.test.ts"));
148
+ expect(expanded).toContain(PAINT.muted("[modified in git]"));
149
+ assertNoRealEscapes(expanded);
150
+ });
151
+ });
152
+
153
+ describe("renderers/tool/specs/search — AC-6 predecessor fff-format parity", () => {
154
+ test("find: bare path list, annotation suffix, empty string", () => {
155
+ expect(narrowFiles(parse("fffind", "src/payments.ts\ntest/payments.test.ts", D2).model).files).toHaveLength(2);
156
+ const annotated = narrowFiles(parse("fffind", "src/payments.ts [modified in git]", D1).model);
157
+ expect(annotated.files[0]).toEqual({ path: "src/payments.ts", annotation: "modified in git" });
158
+ expect(narrowEmpty(parse("fffind", "No files found matching pattern", D0).model).noun).toBe("files");
159
+ });
160
+
161
+ test("grep: numbered match and context lines, file blocks, empty string", () => {
162
+ const body = ["src/payments.ts", " 2- export interface Payment {", " 3: route(...)", " 4- }"].join("\n");
163
+ const matches = narrowMatches(parse("ffgrep", body, D1).model);
164
+ expect(matches.matchCount).toBe(1);
165
+ expect(matches.blocks[0]?.lines.map((l) => [l.lineNo, l.isMatch])).toEqual([
166
+ [2, false],
167
+ [3, true],
168
+ [4, false],
169
+ ]);
170
+ expect(narrowEmpty(parse("ffgrep", "No matches found", D0).model).noun).toBe("matches");
171
+ });
172
+
173
+ test("a notice trailer surfaces as one notice", () => {
174
+ const body = ["src/payments.ts", " 1: x", "", '[Continue with cursor="fff_c1" to continue]'].join("\n");
175
+ expect(narrowMatches(parse("ffgrep", body, D1).model).notices).toEqual([
176
+ 'Continue with cursor="fff_c1" to continue',
177
+ ]);
178
+ });
179
+ });
180
+
181
+ describe("renderers/tool/specs/search — name binding", () => {
182
+ test("SEARCH_SPECS carries exactly the four names, each buildCall titles its own name", () => {
183
+ expect(SEARCH_SPECS.map((record) => record.name)).toEqual(["find", "grep", "fffind", "ffgrep"]);
184
+ expect(SEARCH_TOOL_NAMES).toEqual(["find", "grep", "fffind", "ffgrep"]);
185
+ const row = testRenderContext({ args: { pattern: "x", path: "src" } });
186
+ for (const record of SEARCH_SPECS) {
187
+ expect(record.spec.buildCall(row.args ?? {}, row).title.startsWith(record.name)).toBe(true);
188
+ }
189
+ expect(new Set(SEARCH_SPECS.map((record) => record.descriptor.key))).toEqual(new Set(["row.search"]));
190
+ });
191
+
192
+ test("args gate: buildCall shows the name only until argsComplete", () => {
193
+ // fails_when: a partial argument value is rendered.
194
+ const row = testRenderContext({ args: {}, argsComplete: false });
195
+ expect(createSearchSpec("fffind").buildCall({}, row).title).toBe("fffind");
196
+ });
197
+ });