@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.
- package/LICENSE +21 -0
- package/README.md +110 -2
- package/index.ts +284 -0
- package/package.json +70 -5
- package/src/commands/canvas.ts +57 -0
- package/src/core/code-theme.ts +275 -0
- package/src/core/log.ts +57 -0
- package/src/core/paint.ts +64 -0
- package/src/core/registry.ts +135 -0
- package/src/core/settings.ts +119 -0
- package/src/core/types/code-theme.ts +24 -0
- package/src/core/types/host.ts +169 -0
- package/src/core/types/log.ts +28 -0
- package/src/core/types/paint.ts +54 -0
- package/src/core/types/registry.ts +44 -0
- package/src/core/types/settings.ts +31 -0
- package/src/core/types.ts +34 -0
- package/src/renderers/content/artifacts/cache.ts +234 -0
- package/src/renderers/content/artifacts/cards.ts +136 -0
- package/src/renderers/content/artifacts/engines.ts +396 -0
- package/src/renderers/content/artifacts/local-binary.ts +80 -0
- package/src/renderers/content/artifacts/prereqs.ts +128 -0
- package/src/renderers/content/artifacts/server.ts +181 -0
- package/src/renderers/content/code-panel.ts +161 -0
- package/src/renderers/content/image-card.ts +252 -0
- package/src/renderers/content/index.ts +79 -0
- package/src/renderers/content/json-panel.ts +116 -0
- package/src/renderers/content/table.ts +174 -0
- package/src/renderers/content/types.ts +20 -0
- package/src/renderers/tool/index.ts +113 -0
- package/src/renderers/tool/runtime.ts +267 -0
- package/src/renderers/tool/specs/bash.ts +168 -0
- package/src/renderers/tool/specs/codemode.ts +248 -0
- package/src/renderers/tool/specs/edit.ts +213 -0
- package/src/renderers/tool/specs/ls.ts +136 -0
- package/src/renderers/tool/specs/read.ts +296 -0
- package/src/renderers/tool/specs/search.ts +325 -0
- package/src/renderers/tool/specs/write.ts +142 -0
- package/src/renderers/tool/types.ts +45 -0
- package/themes/dracula-soft.json +81 -0
- package/themes/one-dark.json +80 -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,325 @@
|
|
|
1
|
+
// ported from pi-pretty-tui/src/core/fff-format.ts + src/renderers/find.ts + src/renderers/grep.ts —
|
|
2
|
+
// survives because: pi-fff hands only text plus an untagged {totalMatched,totalFiles} details bag, so
|
|
3
|
+
// the fingerprint parse and the grouped/highlighting projections are the whole renderer. The
|
|
4
|
+
// predecessor's registrations, Proxy shim and prompt text do not port.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* search.ts — the find/grep/fffind/ffgrep row spec: parse pi-fff's text contract, step aside for the
|
|
8
|
+
* host builtin.
|
|
9
|
+
*
|
|
10
|
+
* Boundary: `result.details` is untrusted and narrowed by `isPidfffDetails` before any field is read.
|
|
11
|
+
* Only a bag carrying numeric totals is pi-fff — the one output this spec styles; a host builtin result
|
|
12
|
+
* (details absent or notice-shaped) returns "unknown" and the runtime draws the engine's raw text.
|
|
13
|
+
*
|
|
14
|
+
* shape: none — no module-level unit to shape: the name→spec table is data and the fingerprint table
|
|
15
|
+
* is constants. `createSearchSpec` declares its own shape below.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { basename, dirname } from "node:path";
|
|
19
|
+
import type { ModuleDescriptor, RenderContext, RowPaint, RowSpec, RowView, ToolResult } from "../../../core/types.ts";
|
|
20
|
+
|
|
21
|
+
/** pi-fff's empty strings; both are byte-identical to the host builtin's, so details decide engine. */
|
|
22
|
+
const GREP_EMPTY = "No matches found";
|
|
23
|
+
const FIND_EMPTY = "No files found matching pattern";
|
|
24
|
+
|
|
25
|
+
/** ` 42: content` (match) or ` 12- content` (context) — one leading space, unpadded number. */
|
|
26
|
+
const GREP_LINE_RE = /^ (\d+)([:-]) (.*)$/;
|
|
27
|
+
/** A path's ` [annotation]` suffix; pi-fff indents the tag with two spaces. */
|
|
28
|
+
const ANNOTATION_RE = /\s{2}\[([^\]]*)\]$/;
|
|
29
|
+
/** pi-fff appends notices as `\n\n[a. b]`. */
|
|
30
|
+
const NOTICE_TRAILER_RE = /\n\n\[([^\r\n]*)\]\s*$/;
|
|
31
|
+
/** pi-fff prepends the fuzzy-fallback line as a bare bracket line, not a trailer. */
|
|
32
|
+
const NOTICE_LEAD_RE = /^\[([^\]\r\n]*)\]\r?\n/;
|
|
33
|
+
|
|
34
|
+
/** Rows rendered when expanded before the remainder is summarised; the predecessor's caps. */
|
|
35
|
+
const MAX_FILES = 300;
|
|
36
|
+
const MAX_BLOCKS = 200;
|
|
37
|
+
|
|
38
|
+
/** Pi-fff's always-present totals bag — the only signal separating it from the host builtin. */
|
|
39
|
+
interface PidfffDetails {
|
|
40
|
+
totalMatched: number;
|
|
41
|
+
totalFiles: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface SearchEntry {
|
|
45
|
+
path: string;
|
|
46
|
+
annotation?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface SearchLine {
|
|
50
|
+
lineNo: number;
|
|
51
|
+
content: string;
|
|
52
|
+
/** false for a context line (` 12- `), true for the matched line (` 12: `). */
|
|
53
|
+
isMatch: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface SearchBlock {
|
|
57
|
+
path: string;
|
|
58
|
+
annotation?: string;
|
|
59
|
+
lines: SearchLine[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The parsed result. `files` is find-shaped (bare paths); `matches` is grep-shaped (numbered lines). */
|
|
63
|
+
export type SearchModel =
|
|
64
|
+
| { kind: "empty"; noun: "files" | "matches"; notices: string[] }
|
|
65
|
+
| { kind: "files"; files: SearchEntry[]; notices: string[] }
|
|
66
|
+
| { kind: "matches"; blocks: SearchBlock[]; matchCount: number; notices: string[]; highlight: RegExp | null };
|
|
67
|
+
|
|
68
|
+
/** The four names pi-fff and the host use for the two search tools. */
|
|
69
|
+
export type SearchToolName = "find" | "grep" | "fffind" | "ffgrep";
|
|
70
|
+
|
|
71
|
+
/** The contract-test surface: a pi-fff rename that drops a name must fail CI, not silently unstyle. */
|
|
72
|
+
export const SEARCH_TOOL_NAMES: readonly SearchToolName[] = ["find", "grep", "fffind", "ffgrep"];
|
|
73
|
+
|
|
74
|
+
/** One registry toggle governs all four names — per-name granularity is not worth four entries. */
|
|
75
|
+
const SEARCH_DESCRIPTOR: ModuleDescriptor = {
|
|
76
|
+
key: "row.search",
|
|
77
|
+
name: "Search rows",
|
|
78
|
+
defaultEnabled: true,
|
|
79
|
+
settings: [],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export interface SearchSpecRecord {
|
|
83
|
+
name: SearchToolName;
|
|
84
|
+
spec: RowSpec<SearchModel>;
|
|
85
|
+
descriptor: ModuleDescriptor;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** T-28 iterates this into the resolver table; one name-bound spec per entry. */
|
|
89
|
+
export const SEARCH_SPECS: readonly SearchSpecRecord[] = SEARCH_TOOL_NAMES.map((name) => ({
|
|
90
|
+
name,
|
|
91
|
+
spec: createSearchSpec(name),
|
|
92
|
+
descriptor: SEARCH_DESCRIPTOR,
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* shape: closure returning an object literal — trigger #4, one RowSpec bound to the captured tool name
|
|
97
|
+
* over a shared name-agnostic parse and projection.
|
|
98
|
+
*/
|
|
99
|
+
export function createSearchSpec(name: SearchToolName): RowSpec<SearchModel> {
|
|
100
|
+
return {
|
|
101
|
+
buildCall(args, ctx) {
|
|
102
|
+
return { title: callTitle(name, args, ctx) };
|
|
103
|
+
},
|
|
104
|
+
build(result, ctx) {
|
|
105
|
+
const model = parseSearch(textOf(result), result.details);
|
|
106
|
+
if (model === "unknown" || model.kind !== "matches") return model;
|
|
107
|
+
return { ...model, highlight: buildHighlight(ctx.args) };
|
|
108
|
+
},
|
|
109
|
+
project,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The args gate: until the host has delivered the full argument set the row shows the name only. */
|
|
114
|
+
function callTitle(name: SearchToolName, args: Record<string, unknown>, ctx: RenderContext): string {
|
|
115
|
+
if (ctx.argsComplete !== true) return name;
|
|
116
|
+
const pattern = args.pattern == null ? "" : String(args.pattern);
|
|
117
|
+
const path = args.path == null ? "<missing>" : String(args.path);
|
|
118
|
+
switch (name) {
|
|
119
|
+
case "grep":
|
|
120
|
+
case "ffgrep": {
|
|
121
|
+
let title = `${name} /${pattern}/ in ${path}`;
|
|
122
|
+
if (args.glob != null) title += ` (${String(args.glob)})`;
|
|
123
|
+
if (args.limit != null) title += ` limit ${String(args.limit)}`;
|
|
124
|
+
if (args.literal === true) title += " (literal)";
|
|
125
|
+
if (args.ignoreCase === true) title += " (case-insensitive)";
|
|
126
|
+
return title;
|
|
127
|
+
}
|
|
128
|
+
default: {
|
|
129
|
+
let title = `${name} ${pattern} in ${path}`;
|
|
130
|
+
if (args.limit != null) title += ` limit ${String(args.limit)}`;
|
|
131
|
+
return title;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function textOf(result: ToolResult): string {
|
|
137
|
+
return (result.content ?? [])
|
|
138
|
+
.filter((c) => c.type === "text")
|
|
139
|
+
.map((c) => c.text ?? "")
|
|
140
|
+
.join("\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** `in`-narrowed, no cast: a totalMatched field is what every pi-fff result carries and no host one does. */
|
|
144
|
+
function isPidfffDetails(details: unknown): details is PidfffDetails {
|
|
145
|
+
if (typeof details !== "object" || details === null) return false;
|
|
146
|
+
if (!("totalMatched" in details) || !("totalFiles" in details)) return false;
|
|
147
|
+
return typeof details.totalMatched === "number" && typeof details.totalFiles === "number";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** One notice per bracket: splitting on ". " fragments the weak-match notice, which contains sentence groups. */
|
|
151
|
+
function splitNotices(text: string): { body: string; notices: string[] } {
|
|
152
|
+
const notices: string[] = [];
|
|
153
|
+
let body = text;
|
|
154
|
+
const trailer = NOTICE_TRAILER_RE.exec(body);
|
|
155
|
+
if (trailer) {
|
|
156
|
+
notices.push((trailer[1] ?? "").trim());
|
|
157
|
+
body = body.slice(0, trailer.index);
|
|
158
|
+
}
|
|
159
|
+
const lead = NOTICE_LEAD_RE.exec(body);
|
|
160
|
+
if (lead) {
|
|
161
|
+
notices.unshift((lead[1] ?? "").trim());
|
|
162
|
+
body = body.slice(lead[0].length);
|
|
163
|
+
}
|
|
164
|
+
return { body, notices };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function splitAnnotation(line: string): { path: string; annotation?: string } {
|
|
168
|
+
const m = ANNOTATION_RE.exec(line);
|
|
169
|
+
if (!m) return { path: line };
|
|
170
|
+
return { path: line.slice(0, m.index), annotation: m[1] ?? "" };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseSearch(text: string, details: unknown): SearchModel | "unknown" {
|
|
174
|
+
if (!isPidfffDetails(details)) return "unknown";
|
|
175
|
+
const totalFiles = details.totalFiles;
|
|
176
|
+
const { body, notices } = splitNotices(text.trim());
|
|
177
|
+
|
|
178
|
+
if (body.trim() === GREP_EMPTY) return { kind: "empty", noun: "matches", notices };
|
|
179
|
+
if (body.trim() === FIND_EMPTY) return { kind: "empty", noun: "files", notices };
|
|
180
|
+
if (body.trim() === "") return { kind: "empty", noun: "matches", notices };
|
|
181
|
+
|
|
182
|
+
const blocks: SearchBlock[] = [];
|
|
183
|
+
let current: SearchBlock | undefined;
|
|
184
|
+
let matchCount = 0;
|
|
185
|
+
let sawLine = false;
|
|
186
|
+
for (const line of body.split("\n")) {
|
|
187
|
+
if (line.trim() === "") continue; // pi-fff separates file blocks with a blank line.
|
|
188
|
+
const m = GREP_LINE_RE.exec(line);
|
|
189
|
+
if (m) {
|
|
190
|
+
// A numbered line with no preceding header means the format shifted under us.
|
|
191
|
+
if (!current) return "unknown";
|
|
192
|
+
sawLine = true;
|
|
193
|
+
const isMatch = m[2] === ":";
|
|
194
|
+
if (isMatch) matchCount++;
|
|
195
|
+
current.lines.push({ lineNo: Number(m[1] ?? 0), content: m[3] ?? "", isMatch });
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const { path, annotation } = splitAnnotation(line);
|
|
199
|
+
current = annotation === undefined ? { path, lines: [] } : { path, annotation, lines: [] };
|
|
200
|
+
blocks.push(current);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (sawLine) return { kind: "matches", blocks, matchCount, notices, highlight: null };
|
|
204
|
+
|
|
205
|
+
const files: SearchEntry[] = blocks.map((b) =>
|
|
206
|
+
b.annotation === undefined ? { path: b.path } : { path: b.path, annotation: b.annotation },
|
|
207
|
+
);
|
|
208
|
+
// A pi-fff find never lists more files than its totals report; the wildcard-guard message arrives
|
|
209
|
+
// as one such bare line, so this keeps it out of the file list instead of rendering a phantom row.
|
|
210
|
+
if (files.length > totalFiles) return "unknown";
|
|
211
|
+
return { kind: "files", files, notices };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Ported whole from the predecessor: a regex from args, degrading to literal, then to no highlighting. */
|
|
215
|
+
function buildHighlight(args: Record<string, unknown> | undefined): RegExp | null {
|
|
216
|
+
const raw = args?.pattern;
|
|
217
|
+
if (typeof raw !== "string" || raw === "") return null;
|
|
218
|
+
const flags = args?.ignoreCase === true || args?.caseSensitive === false ? "gi" : "g";
|
|
219
|
+
const source = args?.literal === true ? escapeRegExp(raw) : raw;
|
|
220
|
+
try {
|
|
221
|
+
return new RegExp(`(${source})`, flags);
|
|
222
|
+
} catch {
|
|
223
|
+
try {
|
|
224
|
+
return new RegExp(`(${escapeRegExp(raw)})`, flags);
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function escapeRegExp(value: string): string {
|
|
232
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function highlight(content: string, re: RegExp | null, paint: RowPaint): string {
|
|
236
|
+
if (!re) return paint.output(content);
|
|
237
|
+
return content.replace(re, (_match: string, group: string) => paint.match(group));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Grouped by directory, preserving pi-fff's frecency order within each group. */
|
|
241
|
+
function renderFiles(files: readonly SearchEntry[], paint: RowPaint): string {
|
|
242
|
+
const groups = new Map<string, SearchEntry[]>();
|
|
243
|
+
for (const f of files) {
|
|
244
|
+
const dir = dirname(f.path) || ".";
|
|
245
|
+
const bucket = groups.get(dir);
|
|
246
|
+
if (bucket) bucket.push(f);
|
|
247
|
+
else groups.set(dir, [f]);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const out: string[] = [];
|
|
251
|
+
let shown = 0;
|
|
252
|
+
let hidden = 0;
|
|
253
|
+
for (const [dir, entries] of groups) {
|
|
254
|
+
if (shown >= MAX_FILES) {
|
|
255
|
+
hidden += entries.length;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (out.length > 0) out.push("");
|
|
259
|
+
out.push(paint.accent(`${dir}/`));
|
|
260
|
+
for (const e of entries) {
|
|
261
|
+
if (shown >= MAX_FILES) {
|
|
262
|
+
hidden++;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
out.push(`${paint.output(basename(e.path))}${annotation(e.annotation, paint)}`);
|
|
266
|
+
shown++;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (hidden > 0) out.push(paint.muted(`… ${hidden} more files`));
|
|
270
|
+
return out.join("\n");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** File blocks with a numbered gutter; matched lines highlight, context lines read as evidence. */
|
|
274
|
+
function renderBlocks(blocks: readonly SearchBlock[], re: RegExp | null, paint: RowPaint): string {
|
|
275
|
+
let width = 3;
|
|
276
|
+
for (const b of blocks) {
|
|
277
|
+
for (const l of b.lines) width = Math.max(width, String(l.lineNo).length);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const out: string[] = [];
|
|
281
|
+
let shown = 0;
|
|
282
|
+
let hidden = 0;
|
|
283
|
+
for (const b of blocks) {
|
|
284
|
+
if (shown >= MAX_BLOCKS) {
|
|
285
|
+
hidden++;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (out.length > 0) out.push("");
|
|
289
|
+
out.push(`${paint.accent(b.path)}${annotation(b.annotation, paint)}`);
|
|
290
|
+
for (const l of b.lines) {
|
|
291
|
+
const no = paint.muted(String(l.lineNo).padStart(width, " "));
|
|
292
|
+
const body = l.isMatch ? highlight(l.content, re, paint) : paint.muted(l.content);
|
|
293
|
+
out.push(`${no} ${paint.gutter(l.isMatch ? "│" : "┊")} ${body}`);
|
|
294
|
+
}
|
|
295
|
+
shown++;
|
|
296
|
+
}
|
|
297
|
+
if (hidden > 0) out.push(paint.muted(`… ${hidden} more files`));
|
|
298
|
+
return out.join("\n");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function annotation(value: string | undefined, paint: RowPaint): string {
|
|
302
|
+
return value === undefined ? "" : ` ${paint.muted(`[${value}]`)}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function project(m: SearchModel, view: RowView, paint: RowPaint): string {
|
|
306
|
+
const tail = m.notices.length ? `\n${paint.warning(`[${m.notices.join(". ")}]`)}` : "";
|
|
307
|
+
switch (m.kind) {
|
|
308
|
+
case "empty":
|
|
309
|
+
return `${paint.muted(`0 ${m.noun}`)}${tail}`;
|
|
310
|
+
case "files": {
|
|
311
|
+
const count = m.files.length;
|
|
312
|
+
const label = `${count} file${count === 1 ? "" : "s"}`;
|
|
313
|
+
if (!view.expanded) return `${paint.muted(label)}${tail}`;
|
|
314
|
+
return `${paint.muted(label)}\n${renderFiles(m.files, paint)}${tail}`;
|
|
315
|
+
}
|
|
316
|
+
case "matches": {
|
|
317
|
+
const fileCount = m.blocks.length;
|
|
318
|
+
const label = `${m.matchCount} match${m.matchCount === 1 ? "" : "es"} in ${fileCount} file${
|
|
319
|
+
fileCount === 1 ? "" : "s"
|
|
320
|
+
}`;
|
|
321
|
+
if (!view.expanded) return `${paint.muted(label)}${tail}`;
|
|
322
|
+
return `${paint.muted(label)}\n${renderBlocks(m.blocks, m.highlight, paint)}${tail}`;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|