@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.
- package/LICENSE +21 -0
- package/README.md +110 -2
- package/index.ts +279 -0
- package/package.json +66 -5
- package/src/commands/canvas.test.ts +150 -0
- package/src/commands/canvas.ts +57 -0
- package/src/core/code-theme.test.ts +266 -0
- package/src/core/code-theme.ts +275 -0
- package/src/core/log.test.ts +127 -0
- package/src/core/log.ts +57 -0
- package/src/core/paint.test.ts +322 -0
- package/src/core/paint.ts +64 -0
- package/src/core/registry.test.ts +307 -0
- package/src/core/registry.ts +135 -0
- package/src/core/settings.test.ts +183 -0
- package/src/core/settings.ts +119 -0
- package/src/core/types/code-theme.ts +24 -0
- package/src/core/types/host.ts +160 -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/artifacts.test.ts +199 -0
- package/src/renderers/content/artifacts/cache.ts +234 -0
- package/src/renderers/content/artifacts/cards.test.ts +216 -0
- package/src/renderers/content/artifacts/cards.ts +136 -0
- package/src/renderers/content/artifacts/engines-extra.test.ts +556 -0
- package/src/renderers/content/artifacts/engines.ts +396 -0
- package/src/renderers/content/artifacts/local-binary.test.ts +207 -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.test.ts +170 -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/panels.test.ts +188 -0
- package/src/renderers/content/table.test.ts +209 -0
- package/src/renderers/content/table.ts +174 -0
- package/src/renderers/content/transformer.test.ts +254 -0
- package/src/renderers/content/types.ts +20 -0
- package/src/renderers/tool/index.ts +113 -0
- package/src/renderers/tool/resolver.test.ts +257 -0
- package/src/renderers/tool/runtime.test.ts +313 -0
- package/src/renderers/tool/runtime.ts +267 -0
- package/src/renderers/tool/specs/bash.test.ts +110 -0
- package/src/renderers/tool/specs/bash.ts +168 -0
- package/src/renderers/tool/specs/codemode.test.ts +212 -0
- package/src/renderers/tool/specs/codemode.ts +248 -0
- package/src/renderers/tool/specs/edit.test.ts +260 -0
- package/src/renderers/tool/specs/edit.ts +213 -0
- package/src/renderers/tool/specs/ls.test.ts +173 -0
- package/src/renderers/tool/specs/ls.ts +136 -0
- package/src/renderers/tool/specs/read.test.ts +340 -0
- package/src/renderers/tool/specs/read.ts +296 -0
- package/src/renderers/tool/specs/search.test.ts +197 -0
- package/src/renderers/tool/specs/search.ts +325 -0
- package/src/renderers/tool/specs/write.test.ts +145 -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
- package/themes/themes.test.ts +251 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// spec: specs/write.ts RowSpec — buildCall(args, ctx) -> { title }; build(result, ctx) -> model;
|
|
2
|
+
// project(model, view, paint) -> the row string. The collapsed summary derives from the args
|
|
3
|
+
// (path + content size); the host result carries only a one-line confirmation, so it is not trusted
|
|
4
|
+
// for counts. Cases: written row, empty content, trailing newline, error row, args gate, paint-only.
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { describe, expect, test } from "vitest";
|
|
8
|
+
import { assertNoRealEscapes, markerPaint, testRenderContext } from "../../../../test/fakes/index.ts";
|
|
9
|
+
import type { CallModel, RenderContext, RowPaint, RowView, ToolResult } from "../../../core/types.ts";
|
|
10
|
+
import { writeRow } from "./write.ts";
|
|
11
|
+
|
|
12
|
+
const { spec } = writeRow;
|
|
13
|
+
|
|
14
|
+
// recorded-from: pi host write tool_result, 2026-10-06 (T-18 host facts, pi 1.0.4) — details are
|
|
15
|
+
// undefined and content is the one-line confirmation. It carries no counts.
|
|
16
|
+
function writeResult(path: string): ToolResult {
|
|
17
|
+
return { content: [{ type: "text", text: `Successfully wrote to ${path}` }] };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// invented: host write error text; the predecessor recorded no error capture for this row.
|
|
21
|
+
const ERROR_RESULT: ToolResult = {
|
|
22
|
+
content: [{ type: "text", text: "ENOENT: no such file or directory, open '/repo/src/app.ts'" }],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const PATH = "/repo/src/app.ts";
|
|
26
|
+
// invented: arithmetic fixture — "line one"(8) + "\n"(1) + "line two"(8) = 17 bytes over 2 lines, so
|
|
27
|
+
// the pinned counts are literals the code under test cannot recompute to agree with itself.
|
|
28
|
+
const CONTENT = "line one\nline two";
|
|
29
|
+
|
|
30
|
+
// invented: identity paint so AC-1/AC-2 goldens are readable plain text; AC-5 drives the marker fake.
|
|
31
|
+
const PLAIN: RowPaint = {
|
|
32
|
+
title: (text) => text,
|
|
33
|
+
output: (text) => text,
|
|
34
|
+
muted: (text) => text,
|
|
35
|
+
accent: (text) => text,
|
|
36
|
+
error: (text) => text,
|
|
37
|
+
warning: (text) => text,
|
|
38
|
+
gutter: (text) => text,
|
|
39
|
+
match: (text) => text,
|
|
40
|
+
diffAdded: (text) => text,
|
|
41
|
+
diffRemoved: (text) => text,
|
|
42
|
+
diffContext: (text) => text,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const COLLAPSED: RowView = { expanded: false, isError: false, isPartial: false };
|
|
46
|
+
const EXPANDED: RowView = { expanded: true, isError: false, isPartial: false };
|
|
47
|
+
|
|
48
|
+
function row(args: Record<string, unknown>, overrides: Partial<RenderContext> = {}): RenderContext {
|
|
49
|
+
return testRenderContext({ cwd: "/repo", args }).derive(overrides);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Narrows build()'s "unknown" away so a spec regression surfaces as a named failure, not a type error. */
|
|
53
|
+
function written(args: Record<string, unknown>, overrides: Partial<RenderContext> = {}) {
|
|
54
|
+
const model = spec.build(writeResult(PATH), row(args, overrides));
|
|
55
|
+
if (model === "unknown") throw new Error("write build returned unknown for a written row");
|
|
56
|
+
return model;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("write spec — args gate (AC-4)", () => {
|
|
60
|
+
test("incomplete args show the tool name only", () => {
|
|
61
|
+
const ctx = row({}, { argsComplete: false });
|
|
62
|
+
const call: CallModel = spec.buildCall({}, ctx);
|
|
63
|
+
expect(call.title).toBe("write");
|
|
64
|
+
expect(call.title).not.toContain("src/");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a path streamed before argsComplete is not guessed onto the call line", () => {
|
|
68
|
+
const call = spec.buildCall({ path: PATH }, row({ path: PATH }, { argsComplete: false }));
|
|
69
|
+
expect(call.title).toBe("write");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("complete args put the shortened path on the call line", () => {
|
|
73
|
+
const call = spec.buildCall({ path: PATH }, row({ path: PATH }));
|
|
74
|
+
expect(call.title).toBe("write: src/app.ts");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("write spec — golden row (AC-1)", () => {
|
|
79
|
+
test("collapsed line shows path and args-derived counts", () => {
|
|
80
|
+
const out = spec.project(written({ path: PATH, content: CONTENT }), COLLAPSED, PLAIN);
|
|
81
|
+
expect(out).toBe("✎ src/app.ts (2 lines · 17 bytes)");
|
|
82
|
+
expect(out.split("\n")).toHaveLength(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("expanded body shows the written content", () => {
|
|
86
|
+
const out = spec.project(written({ path: PATH, content: CONTENT }), EXPANDED, PLAIN);
|
|
87
|
+
expect(out).toBe("line one\nline two");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("empty content is 0 lines · 0 bytes", () => {
|
|
91
|
+
const out = spec.project(written({ path: "/repo/src/empty.ts", content: "" }), COLLAPSED, PLAIN);
|
|
92
|
+
expect(out).toBe("✎ src/empty.ts (0 lines · 0 bytes)");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a trailing newline is not an extra line", () => {
|
|
96
|
+
const out = spec.project(written({ path: "/repo/src/one.ts", content: "a\n" }), COLLAPSED, PLAIN);
|
|
97
|
+
expect(out).toBe("✎ src/one.ts (1 lines · 2 bytes)");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("write spec — error row (AC-2)", () => {
|
|
102
|
+
test("collapsed line carries the error summary", () => {
|
|
103
|
+
const model = spec.build(ERROR_RESULT, row({ path: PATH }, { isError: true }));
|
|
104
|
+
if (model === "unknown") throw new Error("write build returned unknown for an error row");
|
|
105
|
+
const out = spec.project(model, COLLAPSED, PLAIN);
|
|
106
|
+
expect(out).toBe("✎ src/app.ts ✗ ENOENT: no such file or directory, open '/repo/src/app.ts'");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("expanded body carries the error text", () => {
|
|
110
|
+
const model = spec.build(ERROR_RESULT, row({ path: PATH }, { isError: true }));
|
|
111
|
+
if (model === "unknown") throw new Error("write build returned unknown for an error row");
|
|
112
|
+
const out = spec.project(model, EXPANDED, PLAIN);
|
|
113
|
+
expect(out).toBe("ENOENT: no such file or directory, open '/repo/src/app.ts'");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("write spec — paint-only color (AC-5)", () => {
|
|
118
|
+
test("collapsed and expanded emit only marker escapes", () => {
|
|
119
|
+
const paint = markerPaint();
|
|
120
|
+
const model = written({ path: PATH, content: CONTENT });
|
|
121
|
+
const collapsed = spec.project(model, COLLAPSED, paint);
|
|
122
|
+
const expanded = spec.project(model, EXPANDED, paint);
|
|
123
|
+
expect(() => assertNoRealEscapes(collapsed)).not.toThrow();
|
|
124
|
+
expect(() => assertNoRealEscapes(expanded)).not.toThrow();
|
|
125
|
+
// Role use is pinned: a bare interpolation (no paint call) would satisfy the escape check alone.
|
|
126
|
+
expect(collapsed).toContain(paint.title("src/app.ts"));
|
|
127
|
+
expect(collapsed).toContain(paint.muted("(2 lines · 17 bytes)"));
|
|
128
|
+
expect(expanded).toContain(paint.output("line one"));
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("write spec — no execution ported (AC-3)", () => {
|
|
133
|
+
test("the source contains no execute, tool-registration or model-facing text", () => {
|
|
134
|
+
const source = readFileSync(new URL("./write.ts", import.meta.url), "utf8");
|
|
135
|
+
for (const pattern of [/\bexecute\b/, /registerTool/, /as unknown as/, /\bprompt\b/, /\bToolDefinition\b/]) {
|
|
136
|
+
expect(source).not.toMatch(pattern);
|
|
137
|
+
}
|
|
138
|
+
expect(writeRow.descriptor).toEqual({
|
|
139
|
+
key: "row.write",
|
|
140
|
+
name: "Write row",
|
|
141
|
+
defaultEnabled: true,
|
|
142
|
+
settings: [],
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// ported from pi-pretty-tui/src/features/edit-write-collapse/render.ts — survives because: the
|
|
2
|
+
// collapsed write summary is derived from the CALL args (path + content size), the only place the
|
|
3
|
+
// data exists — the host write result carries just a one-line confirmation. The tool-run wrapper,
|
|
4
|
+
// metrics, model-facing text and the monkey-patched highlight token do not port.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* specs/write.ts — the `write` tool row: collapsed path + size, expanded written content.
|
|
8
|
+
*
|
|
9
|
+
* shape: none — a data bundle (name, spec, descriptor) over straight-line spec functions; the only
|
|
10
|
+
* branch is the two-value written/error model, below the ≥3-branch dispatch-object trigger.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
CallModel,
|
|
15
|
+
ModuleDescriptor,
|
|
16
|
+
RenderContext,
|
|
17
|
+
RowPaint,
|
|
18
|
+
RowSpec,
|
|
19
|
+
RowView,
|
|
20
|
+
ToolResult,
|
|
21
|
+
} from "../../../core/types.ts";
|
|
22
|
+
|
|
23
|
+
const ICON = "✎";
|
|
24
|
+
const ERROR_MARK = "✗";
|
|
25
|
+
const MAX_ERROR_SUMMARY = 80;
|
|
26
|
+
const MAX_PREVIEW_LINES = 20;
|
|
27
|
+
|
|
28
|
+
/** Two-value model: a failed write carries only its error text; a written row carries args-derived counts. */
|
|
29
|
+
type WriteModel =
|
|
30
|
+
| { kind: "error"; display: string; error: string }
|
|
31
|
+
| { kind: "written"; display: string; content: string | undefined; lines: number; bytes: number };
|
|
32
|
+
|
|
33
|
+
/** The error flag reaches the result renderer on ctx, not on the {content, details} wrapper (host DP3). */
|
|
34
|
+
function isError(result: ToolResult, ctx: RenderContext): boolean {
|
|
35
|
+
return ctx.isError === true || result.isError === true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// boundary: host args are unknown-typed; every field is read through a typeof guard, never a cast.
|
|
39
|
+
function readPath(args: Record<string, unknown> | undefined): string {
|
|
40
|
+
const direct = args?.file_path;
|
|
41
|
+
if (typeof direct === "string" && direct !== "") return direct;
|
|
42
|
+
const alt = args?.path;
|
|
43
|
+
return typeof alt === "string" ? alt : "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readContent(args: Record<string, unknown> | undefined): string | undefined {
|
|
47
|
+
const content = args?.content;
|
|
48
|
+
return typeof content === "string" ? content : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Shortens a path under cwd; anything else stays as given. */
|
|
52
|
+
function displayPath(raw: string, cwd: string | undefined): string {
|
|
53
|
+
if (raw === "") return "";
|
|
54
|
+
if (cwd && raw.startsWith(`${cwd}/`)) return raw.slice(cwd.length + 1);
|
|
55
|
+
return raw;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function textOf(result: ToolResult): string {
|
|
59
|
+
return (result.content ?? [])
|
|
60
|
+
.filter((block) => block.type === "text")
|
|
61
|
+
.map((block) => block.text ?? "")
|
|
62
|
+
.join("\n")
|
|
63
|
+
.trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Lines are logical (trailing blanks trimmed) so "" is 0 lines; bytes are the raw UTF-8 length. */
|
|
67
|
+
function writeCounts(content: string): { lines: number; bytes: number } {
|
|
68
|
+
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
69
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
70
|
+
return { lines: lines.length, bytes: Buffer.byteLength(content, "utf8") };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function firstLine(text: string): string {
|
|
74
|
+
const line = (text.split("\n")[0] ?? "").trim();
|
|
75
|
+
return line.length > MAX_ERROR_SUMMARY ? `${line.slice(0, MAX_ERROR_SUMMARY - 1)}…` : line;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** shape: none — a two-value expansion branch with one string body each. */
|
|
79
|
+
function writtenLine(m: Extract<WriteModel, { kind: "written" }>, paint: RowPaint): string {
|
|
80
|
+
const head = m.display ? `${ICON} ${paint.title(m.display)}` : ICON;
|
|
81
|
+
// No content yet (args still streaming) is the pinned special case: no fabricated counts.
|
|
82
|
+
if (m.content === undefined) return `${head} ${paint.muted("· writing…")}`;
|
|
83
|
+
return `${head} ${paint.muted(`(${m.lines} lines · ${m.bytes} bytes)`)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** shape: none — one preview slice plus an optional hidden-lines hint; no discriminator. */
|
|
87
|
+
function writtenBody(m: Extract<WriteModel, { kind: "written" }>, paint: RowPaint): string {
|
|
88
|
+
if (m.content === undefined) return "";
|
|
89
|
+
const lines = m.content.replace(/\r\n/g, "\n").split("\n");
|
|
90
|
+
const shown = lines.slice(0, MAX_PREVIEW_LINES).map((line) => paint.output(line));
|
|
91
|
+
const hidden = lines.length - shown.length;
|
|
92
|
+
if (hidden > 0) shown.push(paint.muted(`… ${hidden} more lines (${lines.length} total)`));
|
|
93
|
+
return shown.join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** shape: none — a single collapsed line; error text is truncated, never re-wrapped. */
|
|
97
|
+
function errorLine(m: Extract<WriteModel, { kind: "error" }>, paint: RowPaint): string {
|
|
98
|
+
const head = m.display ? `${ICON} ${paint.title(m.display)} ` : "";
|
|
99
|
+
return `${head}${paint.error(`${ERROR_MARK} ${firstLine(m.error)}`)}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** shape: none — one map over the error's lines; no branch. */
|
|
103
|
+
function errorBody(m: Extract<WriteModel, { kind: "error" }>, paint: RowPaint): string {
|
|
104
|
+
return m.error
|
|
105
|
+
.split("\n")
|
|
106
|
+
.map((line) => paint.error(line))
|
|
107
|
+
.join("\n");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** shape: none — a single collapsed-line builder; the args gate is one boolean check. */
|
|
111
|
+
function buildCall(args: Record<string, unknown>, ctx: RenderContext): CallModel {
|
|
112
|
+
const display = displayPath(readPath(args), ctx.cwd);
|
|
113
|
+
if (ctx.argsComplete === false || display === "") return { title: "write" };
|
|
114
|
+
return { title: `write: ${display}` };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** shape: none — the written/error model is discriminated on one boolean; no dispatch object. */
|
|
118
|
+
function build(result: ToolResult, ctx: RenderContext): WriteModel | "unknown" {
|
|
119
|
+
const display = displayPath(readPath(ctx.args), ctx.cwd);
|
|
120
|
+
if (isError(result, ctx)) return { kind: "error", display, error: textOf(result) || "Error" };
|
|
121
|
+
const content = readContent(ctx.args);
|
|
122
|
+
// Neither a path nor content: nothing recognizable, so the runtime degrades to the raw result.
|
|
123
|
+
if (display === "" && content === undefined) return "unknown";
|
|
124
|
+
const { lines, bytes } = writeCounts(content ?? "");
|
|
125
|
+
return { kind: "written", display, content, lines, bytes };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** shape: none — two straight-line branches (state, then model kind); no dispatch object. */
|
|
129
|
+
function project(m: WriteModel, view: RowView, paint: RowPaint): string {
|
|
130
|
+
if (m.kind === "error") return view.expanded ? errorBody(m, paint) : errorLine(m, paint);
|
|
131
|
+
return view.expanded ? writtenBody(m, paint) : writtenLine(m, paint);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const writeRow: {
|
|
135
|
+
name: string;
|
|
136
|
+
spec: RowSpec<WriteModel>;
|
|
137
|
+
descriptor: ModuleDescriptor;
|
|
138
|
+
} = {
|
|
139
|
+
name: "write",
|
|
140
|
+
spec: { buildCall, build, project },
|
|
141
|
+
descriptor: { key: "row.write", name: "Write row", defaultEnabled: true, settings: [] },
|
|
142
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* renderers/tool/types.ts — the tool-lane contracts (what a row spec is, and what it produces).
|
|
3
|
+
*
|
|
4
|
+
* Boundary: lane-local. A tool spec and the tool runtime import these; no other lane does. The
|
|
5
|
+
* host shapes they build on live in core (types/host.ts, types/paint.ts) — a row contract is the
|
|
6
|
+
* spec's own vocabulary, not the host's.
|
|
7
|
+
*
|
|
8
|
+
* shape: none — declaration-only module (no runtime unit), so no DSG-1 shape trigger applies.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { RenderContext, ToolResult } from "../../core/types/host.ts";
|
|
12
|
+
import type { RowPaint } from "../../core/types/paint.ts";
|
|
13
|
+
|
|
14
|
+
/** Collapsed call-line model: the args gate keeps it name-only until argsComplete. */
|
|
15
|
+
export interface CallModel {
|
|
16
|
+
/** The one-line call title, e.g. "bash: npm test". Required; never a guessed argument value. */
|
|
17
|
+
title: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Per-frame projection inputs, derived from the result plus the host's options. */
|
|
21
|
+
export interface RowView {
|
|
22
|
+
/** True while the row is drawn expanded. Required. */
|
|
23
|
+
expanded: boolean;
|
|
24
|
+
/** True when the call failed. Required. */
|
|
25
|
+
isError: boolean;
|
|
26
|
+
/** True while the result is still streaming. Required. */
|
|
27
|
+
isPartial: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One tool row's three-stage contract: cheap call build, parse-once result build, per-frame project.
|
|
32
|
+
*
|
|
33
|
+
* @typeParam M — the parsed result model this spec derives; `unknown` is the erased default, so a
|
|
34
|
+
* `Record<string, RowSpec>` accepts every concrete spec without a cast.
|
|
35
|
+
*/
|
|
36
|
+
export interface RowSpec<M = unknown> {
|
|
37
|
+
/** Builds the collapsed call line; runs on every argument change. Required. */
|
|
38
|
+
buildCall(args: Record<string, unknown>, ctx: RenderContext): CallModel;
|
|
39
|
+
/** Parses the result once per result, memoized in ctx.state. Required; returns "unknown" when unrecognized. */
|
|
40
|
+
build(result: ToolResult, ctx: RenderContext): M | "unknown";
|
|
41
|
+
/** Draws the collapsed-line body; re-runs on every frame, so it stays allocation-light. Required. */
|
|
42
|
+
project(m: M, view: RowView, paint: RowPaint): string;
|
|
43
|
+
/** Enriches M, e.g. with a resolved link target, then asks the host to redraw. Optional. */
|
|
44
|
+
decorate?(m: M, ctx: RenderContext): Promise<Partial<M>>;
|
|
45
|
+
}
|