@quandev104/pi-style 0.1.2 → 0.1.4
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/CHANGELOG.md +21 -0
- package/README.md +1 -2
- package/dist/extensions/pi-style.js +5919 -4966
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/command-service.ts +2 -2
- package/extension-src/pi-style/app/index.ts +0 -1
- package/extension-src/pi-style/domain/config-authorization.ts +1 -2
- package/extension-src/pi-style/domain/config-normalization.ts +3 -3
- package/extension-src/pi-style/domain/config-types.ts +2 -2
- package/extension-src/pi-style/domain/status.ts +1 -1
- package/extension-src/pi-style/domain/theme.ts +3 -0
- package/extension-src/pi-style/features/messages/index.ts +2 -8
- package/extension-src/pi-style/features/tools/boxed/bash.ts +663 -45
- package/extension-src/pi-style/features/tools/boxed/batch.ts +459 -0
- package/extension-src/pi-style/features/tools/boxed/edit.ts +28 -2
- package/extension-src/pi-style/features/tools/boxed/fallback.ts +47 -4
- package/extension-src/pi-style/features/tools/boxed/find.ts +48 -48
- package/extension-src/pi-style/features/tools/boxed/grep.ts +161 -89
- package/extension-src/pi-style/features/tools/boxed/index.ts +4 -0
- package/extension-src/pi-style/features/tools/boxed/ls.ts +39 -47
- package/extension-src/pi-style/features/tools/boxed/output-tree.ts +368 -0
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +25 -4
- package/extension-src/pi-style/features/tools/boxed/read.ts +32 -189
- package/extension-src/pi-style/features/tools/boxed/session-config.ts +70 -4
- package/extension-src/pi-style/features/tools/boxed/shared.ts +41 -1
- package/extension-src/pi-style/features/tools/boxed/write.ts +105 -47
- package/extension-src/pi-style/features/tools/index.ts +14 -0
- package/extension-src/pi-style/pi/compatibility-coordinator.ts +4 -26
- package/extension-src/pi-style/pi/compatibility-probe.ts +3 -33
- package/extension-src/pi-style/pi/compatibility-registry.ts +0 -1
- package/extension-src/pi-style/pi/config-session.ts +0 -2
- package/extension-src/pi-style/pi/index.ts +35 -1
- package/extension-src/pi-style/pi/session-coordinator.ts +47 -3
- package/extension-src/pi-style/shared/box.ts +117 -26
- package/extension-src/pi-style/shared/theme-extras.ts +0 -2
- package/package.json +1 -1
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// Boxless output-tree primitives shared by the ls/find/grep/bash renderers.
|
|
2
|
+
//
|
|
3
|
+
// ls/find/grep and bash `ls/find/grep/rg` results render their parsed output as
|
|
4
|
+
// a **boxless tree panel** — a summary header line followed by `├─/└─` rows —
|
|
5
|
+
// instead of a boxed command/response shell. This module owns:
|
|
6
|
+
//
|
|
7
|
+
// - output parsers that turn native tool text (entries, paths, `file:line:`
|
|
8
|
+
// match lines) into structured records, dropping trailing truncation notices;
|
|
9
|
+
// - `renderOutputTree`, which lays out a flat list of entries under a header
|
|
10
|
+
// (used by lone ls/find and bash ls/find);
|
|
11
|
+
// - `renderGrepTree`, which lays out grep matches grouped by file (used by grep
|
|
12
|
+
// and bash grep/rg).
|
|
13
|
+
//
|
|
14
|
+
// Design notes:
|
|
15
|
+
// - Pure + theme-consuming: no filesystem, no global state, no caching (callers
|
|
16
|
+
// cache at the component boundary).
|
|
17
|
+
// - Every row is width-safe via safeTruncateToWidth; the header is never
|
|
18
|
+
// truncated by this layer (callers pass a concise, pre-sized header).
|
|
19
|
+
// - Tree indent matches the quiet-tool batch panel (` ├─`) so the panels read
|
|
20
|
+
// as one visual family.
|
|
21
|
+
|
|
22
|
+
import type { BoxTheme } from "../../../shared/box.js";
|
|
23
|
+
import { safeTruncateToWidth } from "../../../shared/render-budget.js";
|
|
24
|
+
|
|
25
|
+
/** Indent for top-level tree rows; matches the quiet-tool batch panel. */
|
|
26
|
+
export const TREE_INDENT = " ";
|
|
27
|
+
/** Extra indent for rows nested under a grouping node. */
|
|
28
|
+
export const TREE_CHILD_INDENT = " ";
|
|
29
|
+
/** Default number of entries/matches shown before collapsing to "… N more". */
|
|
30
|
+
export const OUTPUT_TREE_HEAD_LIMIT = 6;
|
|
31
|
+
|
|
32
|
+
// ── Nerd Font file-type icons ───────────────────────────────────────────────
|
|
33
|
+
// Only used when the session glyph mode is nerd (see withIcons). Unicode/ASCII
|
|
34
|
+
// modes render plain entries.
|
|
35
|
+
|
|
36
|
+
const FILE_ICON_FOLDER = "\u{F415}"; // (nf-md-folder)
|
|
37
|
+
const FILE_ICON_DEFAULT = "\u{E612}"; // (nf-seti-default)
|
|
38
|
+
/** Search (magnifying-glass) icon for find/grep headers (nf-fa-search). */
|
|
39
|
+
export const SEARCH_ICON = "\u{F002}";
|
|
40
|
+
const FILE_ICONS: Readonly<Record<string, string>> = {
|
|
41
|
+
ts: "\u{E628}", // (nf-seti-typescript)
|
|
42
|
+
tsx: "\u{E7BA}", // (nf-seti-react)
|
|
43
|
+
js: "\u{E62C}", // (nf-seti-javascript)
|
|
44
|
+
jsx: "\u{E7BA}", // (nf-seti-react)
|
|
45
|
+
mjs: "\u{E62C}",
|
|
46
|
+
cjs: "\u{E62C}",
|
|
47
|
+
json: "\u{E62B}", // (nf-seti-json)
|
|
48
|
+
md: "\u{E609}", // (nf-seti-markdown)
|
|
49
|
+
mdx: "\u{E609}",
|
|
50
|
+
css: "\u{E749}", // (nf-seti-css)
|
|
51
|
+
scss: "\u{E749}",
|
|
52
|
+
sass: "\u{E749}",
|
|
53
|
+
less: "\u{E749}",
|
|
54
|
+
html: "\u{E60E}", // (nf-seti-html)
|
|
55
|
+
htm: "\u{E60E}",
|
|
56
|
+
py: "\u{E606}", // (nf-seti-python)
|
|
57
|
+
go: "\u{E627}", // (nf-seti-go)
|
|
58
|
+
rs: "\u{E7A8}", // (nf-seti-rust)
|
|
59
|
+
sh: "\u{E795}", // (nf-seti-shell)
|
|
60
|
+
bash: "\u{E795}",
|
|
61
|
+
zsh: "\u{E795}",
|
|
62
|
+
fish: "\u{E795}",
|
|
63
|
+
yml: "\u{E615}", // (nf-seti-yaml)
|
|
64
|
+
yaml: "\u{E615}",
|
|
65
|
+
toml: "\u{E615}",
|
|
66
|
+
java: "\u{E738}", // (nf-seti-java)
|
|
67
|
+
c: "\u{E61E}", // (nf-seti-c)
|
|
68
|
+
h: "\u{E61E}",
|
|
69
|
+
cpp: "\u{E61E}",
|
|
70
|
+
hpp: "\u{E61E}",
|
|
71
|
+
cs: "\u{E61E}",
|
|
72
|
+
svg: "\u{E62A}", // (nf-seti-svg)
|
|
73
|
+
png: "\u{E61D}", // (nf-seti-image)
|
|
74
|
+
jpg: "\u{E61D}",
|
|
75
|
+
jpeg: "\u{E61D}",
|
|
76
|
+
gif: "\u{E61D}",
|
|
77
|
+
webp: "\u{E61D}",
|
|
78
|
+
pdf: "\u{E67A}", // (nf-seti-pdf)
|
|
79
|
+
dockerfile: "\u{E7B0}", // (nf-seti-docker)
|
|
80
|
+
lock: "\u{E7B0}",
|
|
81
|
+
gitignore: "\u{E702}", // (nf-seti-git)
|
|
82
|
+
gitattributes: "\u{E702}",
|
|
83
|
+
vue: "\u{ED43}", // (nf-vue)
|
|
84
|
+
svelte: "\u{E697}",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Nerd Font file-type icon for a path, or "" when not applicable. */
|
|
88
|
+
export function fileIcon(path: string): string {
|
|
89
|
+
if (path.endsWith("/")) return FILE_ICON_FOLDER;
|
|
90
|
+
const name = path.split("/").pop() ?? path;
|
|
91
|
+
const lower = name.toLowerCase();
|
|
92
|
+
const ext = lower.includes(".") ? lower.slice(lower.lastIndexOf(".") + 1) : lower;
|
|
93
|
+
return FILE_ICONS[ext] ?? FILE_ICONS[lower] ?? FILE_ICON_DEFAULT;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Lines produced by these native tools to signal truncation (already in the
|
|
97
|
+
* output text, not separate metadata). Dropped before parsing. */
|
|
98
|
+
const NOTICE_LINE_PATTERN = /^\[[^\]]*\]$/;
|
|
99
|
+
|
|
100
|
+
/** A parsed grep match line. Context lines are not surfaced in the tree. */
|
|
101
|
+
export interface GrepMatch {
|
|
102
|
+
readonly file: string;
|
|
103
|
+
readonly line: number;
|
|
104
|
+
readonly content: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Drop trailing tool notices (`[Showing last …]`, `[Truncated: …]`) and blanks. */
|
|
108
|
+
function stripNotices(text: string): string[] {
|
|
109
|
+
return text
|
|
110
|
+
.replace(/\r/g, "")
|
|
111
|
+
.split("\n")
|
|
112
|
+
.map((line) => line.trimEnd())
|
|
113
|
+
.filter((line) => line.length > 0 && !NOTICE_LINE_PATTERN.test(line.trim()));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Parse native `ls` output into display entries. Directories keep their `/`
|
|
118
|
+
* suffix; the `(empty directory)` placeholder and truncation notices are
|
|
119
|
+
* removed. Output is already sorted alphabetically by the tool.
|
|
120
|
+
*/
|
|
121
|
+
export function parseLsOutput(rawText: string): string[] {
|
|
122
|
+
return stripNotices(rawText)
|
|
123
|
+
.map((line) => line.trim())
|
|
124
|
+
.filter((line) => line.length > 0 && line !== "(empty directory)");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Parse `ls -l`/`ls -la` long-format output into display entries: the entry
|
|
129
|
+
* name is the text after the time column; directory names get a trailing `/`.
|
|
130
|
+
* The `total N` summary and `.`/`..` entries are dropped. Standard POSIX
|
|
131
|
+
* columns: perms links owner group size month day time name. macOS `@`/`+`
|
|
132
|
+
* permission suffixes are tolerated.
|
|
133
|
+
*/
|
|
134
|
+
export function parseLsLongOutput(rawText: string): string[] {
|
|
135
|
+
const entries: string[] = [];
|
|
136
|
+
for (const line of stripNotices(rawText)) {
|
|
137
|
+
if (!/^[bcdlsp-][rwxtsST-]{9}/.test(line)) continue;
|
|
138
|
+
const parts = line.split(/\s+/);
|
|
139
|
+
const name = parts.slice(8).join(" ").trim();
|
|
140
|
+
if (!name || name === "." || name === "..") continue;
|
|
141
|
+
const isDir = (parts[0] ?? "").startsWith("d");
|
|
142
|
+
entries.push(isDir ? `${name}/` : name);
|
|
143
|
+
}
|
|
144
|
+
return entries;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Parse native `find` output into display paths (one per line). Notices are
|
|
149
|
+
* removed. The native tool returns paths relative to the search directory.
|
|
150
|
+
*/
|
|
151
|
+
export function parseFindOutput(rawText: string): string[] {
|
|
152
|
+
return stripNotices(rawText)
|
|
153
|
+
.map((line) => line.trim())
|
|
154
|
+
.filter((line) => line.length > 0);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Match line: path/to/file.ts:42: matched content (Pi grep adds a space;
|
|
158
|
+
// ripgrep/grep emit no space). Context lines (path-line- …) are dropped.
|
|
159
|
+
const GREP_MATCH_PATTERN = /^(.*):(\d+):[ \t]?(.*)$/;
|
|
160
|
+
// Single-file ripgrep/grep output: `42: content` (no filename).
|
|
161
|
+
const GREP_BARE_PATTERN = /^(\d+):[ \t]?(.*)$/;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Parse native `grep` output into match records. Only real match lines
|
|
165
|
+
* (`file:line: content`) are kept; context lines (`file-line- …`) are dropped
|
|
166
|
+
* so the tree stays focused on hits. Trailing notices are removed.
|
|
167
|
+
*/
|
|
168
|
+
export function parseGrepOutput(rawText: string): GrepMatch[] {
|
|
169
|
+
const matches: GrepMatch[] = [];
|
|
170
|
+
for (const line of stripNotices(rawText)) {
|
|
171
|
+
const match = GREP_MATCH_PATTERN.exec(line);
|
|
172
|
+
if (!match) continue;
|
|
173
|
+
const [, file, lineNo, content] = match;
|
|
174
|
+
if (!file || lineNo === undefined || content === undefined) continue;
|
|
175
|
+
const parsed = Number(lineNo);
|
|
176
|
+
if (!Number.isFinite(parsed) || parsed < 1) continue;
|
|
177
|
+
matches.push({ file, line: parsed, content });
|
|
178
|
+
}
|
|
179
|
+
return matches;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Parse single-file grep/ripgrep output — `line: content` without a filename —
|
|
184
|
+
* attributing every match to the given file. Used by bash `grep pattern file`.
|
|
185
|
+
*/
|
|
186
|
+
export function parseGrepBareOutput(rawText: string, file: string): GrepMatch[] {
|
|
187
|
+
const matches: GrepMatch[] = [];
|
|
188
|
+
for (const line of stripNotices(rawText)) {
|
|
189
|
+
const match = GREP_BARE_PATTERN.exec(line);
|
|
190
|
+
if (!match) continue;
|
|
191
|
+
const parsed = Number(match[1]);
|
|
192
|
+
if (!Number.isFinite(parsed) || parsed < 1) continue;
|
|
193
|
+
matches.push({ file, line: parsed, content: match[2] ?? "" });
|
|
194
|
+
}
|
|
195
|
+
return matches;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Group grep matches by file, preserving first-seen order. */
|
|
199
|
+
export function groupMatchesByFile(matches: readonly GrepMatch[]): { file: string; matches: GrepMatch[] }[] {
|
|
200
|
+
const order: string[] = [];
|
|
201
|
+
const buckets = new Map<string, GrepMatch[]>();
|
|
202
|
+
for (const match of matches) {
|
|
203
|
+
let bucket = buckets.get(match.file);
|
|
204
|
+
if (!bucket) {
|
|
205
|
+
bucket = [];
|
|
206
|
+
buckets.set(match.file, bucket);
|
|
207
|
+
order.push(match.file);
|
|
208
|
+
}
|
|
209
|
+
bucket.push(match);
|
|
210
|
+
}
|
|
211
|
+
return order.map((file) => ({ file, matches: buckets.get(file) ?? [] }));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface OutputTreeOptions {
|
|
215
|
+
/** Maximum entries shown before the "… N more" row. */
|
|
216
|
+
headLimit?: number;
|
|
217
|
+
/** Singular noun used in the collapse row (default "file"); pluralized automatically. */
|
|
218
|
+
moreUnit?: string;
|
|
219
|
+
/** Optional ANSI-themed color for entry text (defaults to "toolOutput"). */
|
|
220
|
+
entryColor?: string;
|
|
221
|
+
/** Indent prefix applied to every row (defaults to TREE_INDENT). */
|
|
222
|
+
indent?: string;
|
|
223
|
+
/** Nerd Font mode: prefix each entry with its file-type icon. */
|
|
224
|
+
withIcons?: boolean;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Render a flat output tree: `<header>` then `├─/└─` rows for the first entries
|
|
229
|
+
* and a trailing `└─ … N more <unit>` row when truncated. Used by lone ls/find
|
|
230
|
+
* (and bash ls/find).
|
|
231
|
+
*/
|
|
232
|
+
export function renderOutputTree(
|
|
233
|
+
theme: BoxTheme,
|
|
234
|
+
header: string,
|
|
235
|
+
entries: readonly string[],
|
|
236
|
+
width: number,
|
|
237
|
+
options: OutputTreeOptions = {},
|
|
238
|
+
): string[] {
|
|
239
|
+
const headLimit = options.headLimit ?? OUTPUT_TREE_HEAD_LIMIT;
|
|
240
|
+
const moreUnit = options.moreUnit ?? "file";
|
|
241
|
+
const entryColor = options.entryColor ?? "toolOutput";
|
|
242
|
+
const indent = options.indent ?? TREE_INDENT;
|
|
243
|
+
const safeWidth = Math.max(1, width);
|
|
244
|
+
const label = (entry: string) => (options.withIcons && entry ? `${fileIcon(entry)} ${entry}` : entry);
|
|
245
|
+
|
|
246
|
+
const out: string[] = [safeTruncateToWidth(header, safeWidth, "…")];
|
|
247
|
+
if (entries.length === 0) return out;
|
|
248
|
+
|
|
249
|
+
const visible = entries.slice(0, headLimit);
|
|
250
|
+
const more = entries.length - visible.length;
|
|
251
|
+
const lastIndex = visible.length - 1;
|
|
252
|
+
for (let i = 0; i < visible.length; i++) {
|
|
253
|
+
const branch = i < lastIndex || more > 0 ? "├─" : "└─";
|
|
254
|
+
const line = `${indent}${theme.fg("borderMuted", branch)} ${theme.fg(entryColor, label(visible[i] ?? ""))}`;
|
|
255
|
+
out.push(safeTruncateToWidth(line, safeWidth, "…"));
|
|
256
|
+
}
|
|
257
|
+
if (more > 0) {
|
|
258
|
+
const line = `${indent}${theme.fg("borderMuted", "└─")} ${theme.fg("dim", `… ${more} more ${pluralForm(moreUnit, more)}`)}`;
|
|
259
|
+
out.push(safeTruncateToWidth(line, safeWidth, "…"));
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface GrepTreeOptions {
|
|
265
|
+
/** Maximum matches shown (across all files) before the "… N more" row. */
|
|
266
|
+
headLimit?: number;
|
|
267
|
+
/** Indent prefix applied to top-level rows. */
|
|
268
|
+
indent?: string;
|
|
269
|
+
/** Nerd Font mode: prefix file nodes with their file-type icon. */
|
|
270
|
+
withIcons?: boolean;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function formatMatchRow(theme: BoxTheme, match: GrepMatch): string {
|
|
274
|
+
// Match rows render in the output text color (not primary) so they read like
|
|
275
|
+
// the matched code; only the file nodes carry the primary color.
|
|
276
|
+
const label = theme.fg("toolOutput", `*${match.line}`);
|
|
277
|
+
const sep = theme.fg("borderMuted", "│");
|
|
278
|
+
return `${label}${sep} ${theme.fg("toolOutput", match.content)}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Render a grep matches tree: `<header>` then matches grouped by file. With a
|
|
283
|
+
* single file the matches are direct children; with several files each file is
|
|
284
|
+
* a `├─ file` node and its matches hang off an indented trunk beneath. A
|
|
285
|
+
* trailing `└─ … N more matches` row appears when the match budget is exceeded.
|
|
286
|
+
*/
|
|
287
|
+
export function renderGrepTree(
|
|
288
|
+
theme: BoxTheme,
|
|
289
|
+
header: string,
|
|
290
|
+
matches: readonly GrepMatch[],
|
|
291
|
+
width: number,
|
|
292
|
+
options: GrepTreeOptions = {},
|
|
293
|
+
): string[] {
|
|
294
|
+
const headLimit = options.headLimit ?? OUTPUT_TREE_HEAD_LIMIT;
|
|
295
|
+
const indent = options.indent ?? TREE_INDENT;
|
|
296
|
+
const safeWidth = Math.max(1, width);
|
|
297
|
+
|
|
298
|
+
const out: string[] = [safeTruncateToWidth(header, safeWidth, "…")];
|
|
299
|
+
if (matches.length === 0) return out;
|
|
300
|
+
|
|
301
|
+
const groups = groupMatchesByFile(matches);
|
|
302
|
+
const singleFile = groups.length === 1;
|
|
303
|
+
|
|
304
|
+
// First decide which matches fit the budget so branch glyphs (├─ vs └─) and
|
|
305
|
+
// the trailing "… N more" row stay consistent.
|
|
306
|
+
const budget = matches.slice(0, headLimit);
|
|
307
|
+
const remaining = matches.length - budget.length;
|
|
308
|
+
const truncated = remaining > 0;
|
|
309
|
+
const totalVisible = budget.length;
|
|
310
|
+
|
|
311
|
+
const push = (line: string) => out.push(safeTruncateToWidth(line, safeWidth, "…"));
|
|
312
|
+
|
|
313
|
+
if (singleFile) {
|
|
314
|
+
budget.forEach((match, index) => {
|
|
315
|
+
const isLast = index === totalVisible - 1 && !truncated;
|
|
316
|
+
push(`${indent}${theme.fg("borderMuted", isLast ? "└─" : "├─")} ${formatMatchRow(theme, match)}`);
|
|
317
|
+
});
|
|
318
|
+
} else {
|
|
319
|
+
// Walk the budget, tracking position within each file group so the file
|
|
320
|
+
// node and its match subtree render as one connected unit.
|
|
321
|
+
let shown = 0;
|
|
322
|
+
for (let gi = 0; gi < groups.length && shown < totalVisible; gi++) {
|
|
323
|
+
const group = groups[gi];
|
|
324
|
+
if (!group) continue;
|
|
325
|
+
const isLastGroup = gi === groups.length - 1;
|
|
326
|
+
const trunk = isLastGroup ? " " : theme.fg("borderMuted", "│");
|
|
327
|
+
|
|
328
|
+
const visibleHere: GrepMatch[] = [];
|
|
329
|
+
for (const match of group.matches) {
|
|
330
|
+
if (shown >= totalVisible) break;
|
|
331
|
+
visibleHere.push(match);
|
|
332
|
+
shown++;
|
|
333
|
+
}
|
|
334
|
+
if (visibleHere.length === 0) continue;
|
|
335
|
+
|
|
336
|
+
const groupIsLastRendered = shown >= totalVisible && !truncated;
|
|
337
|
+
const fileLabel = options.withIcons ? `${fileIcon(group.file)} ${group.file}` : group.file;
|
|
338
|
+
// File nodes use the primary (accent) color, matching read/ls/find paths.
|
|
339
|
+
push(`${indent}${theme.fg("borderMuted", groupIsLastRendered ? "└─" : "├─")} ${theme.fg("accent", fileLabel)}`);
|
|
340
|
+
|
|
341
|
+
visibleHere.forEach((match, index) => {
|
|
342
|
+
const isLastInGroup = index === visibleHere.length - 1;
|
|
343
|
+
const isLastOverall = groupIsLastRendered && isLastInGroup;
|
|
344
|
+
push(
|
|
345
|
+
`${indent}${trunk}${TREE_CHILD_INDENT}${theme.fg("borderMuted", isLastOverall ? "└─" : "├─")} ${formatMatchRow(theme, match)}`,
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (truncated) {
|
|
352
|
+
push(
|
|
353
|
+
`${indent}${theme.fg("borderMuted", "└─")} ${theme.fg("dim", `… ${remaining} more ${pluralForm("match", remaining)}`)}`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Return the pluralized form of a noun for the given count. */
|
|
360
|
+
export function pluralForm(noun: string, count: number): string {
|
|
361
|
+
if (count === 1) return noun;
|
|
362
|
+
return /(s|x|z|ch|sh)$/i.test(noun) ? `${noun}es` : `${noun}s`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Pluralize a count noun: "1 file" / "3 files", "1 match" / "3 matches". */
|
|
366
|
+
export function pluralize(count: number, noun: string): string {
|
|
367
|
+
return `${count} ${pluralForm(noun, count)}`;
|
|
368
|
+
}
|
|
@@ -4,19 +4,35 @@ import { getLanguageFromPath } from "@earendil-works/pi-coding-agent";
|
|
|
4
4
|
import { stripAnsi } from "../../../shared/ansi.js";
|
|
5
5
|
import {
|
|
6
6
|
type BoxTheme,
|
|
7
|
-
|
|
7
|
+
formatBoxedRunningStatus,
|
|
8
8
|
getTextOutput,
|
|
9
9
|
renderBoxedToolCall,
|
|
10
10
|
renderBoxedToolResult,
|
|
11
11
|
} from "../../../shared/box.js";
|
|
12
12
|
import { formatElapsedMs, getElapsedMs } from "../../../shared/elapsed.js";
|
|
13
13
|
import { AdaptiveDiffComponent, buildSplitRows, countDiffStats } from "../../../shared/split-diff.js";
|
|
14
|
-
import { getStateElapsedMs } from "./session-config.js";
|
|
15
|
-
import {
|
|
14
|
+
import { getStateElapsedMs, isResultSeen } from "./session-config.js";
|
|
15
|
+
import {
|
|
16
|
+
type BoxedToolContext,
|
|
17
|
+
type BoxedToolDefinition,
|
|
18
|
+
displayPath,
|
|
19
|
+
noteBoxedCallState,
|
|
20
|
+
noteBoxedResultPhase,
|
|
21
|
+
noteExecutionStart,
|
|
22
|
+
stateElapsedMs,
|
|
23
|
+
} from "./shared.js";
|
|
16
24
|
|
|
17
25
|
const MAX_HIGHLIGHT_DIFF_CHARS = 12000;
|
|
18
26
|
const MAX_HIGHLIGHT_DIFF_ROWS = 120;
|
|
19
27
|
|
|
28
|
+
/** First-partial-pass result: the pending/running call card stands alone. */
|
|
29
|
+
const EMPTY_QUICK_EDIT_RESULT = Object.freeze({
|
|
30
|
+
invalidate() {},
|
|
31
|
+
render() {
|
|
32
|
+
return [];
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
20
36
|
interface QuickEditToolConfig {
|
|
21
37
|
toolLabel: string;
|
|
22
38
|
applyingLabel: string;
|
|
@@ -131,10 +147,12 @@ function renderQuickEditResult(
|
|
|
131
147
|
config: QuickEditToolConfig,
|
|
132
148
|
) {
|
|
133
149
|
if (options.isPartial) {
|
|
150
|
+
const firstResultPass = noteBoxedResultPhase(context, options.isPartial);
|
|
151
|
+
if (firstResultPass) return EMPTY_QUICK_EDIT_RESULT;
|
|
134
152
|
return renderBoxedToolResult(
|
|
135
153
|
theme,
|
|
136
154
|
() => [`${theme.fg("dim", "↳")} ${theme.fg("muted", `Applying ${config.applyingLabel}...`)}`],
|
|
137
|
-
{ isPartial: true },
|
|
155
|
+
{ showDivider: false, footerLines: [formatBoxedRunningStatus(theme, stateElapsedMs(context))], isPartial: true },
|
|
138
156
|
);
|
|
139
157
|
}
|
|
140
158
|
|
|
@@ -196,12 +214,15 @@ export function quickEditTool(config: QuickEditToolConfig): BoxedToolDefinition
|
|
|
196
214
|
return {
|
|
197
215
|
call(args, theme, context) {
|
|
198
216
|
noteExecutionStart(context);
|
|
217
|
+
noteBoxedCallState(context);
|
|
199
218
|
const detail = displayPath(String(args?.path ?? ""), context);
|
|
200
219
|
return renderBoxedToolCall(theme, config.toolLabel, [], {
|
|
201
220
|
headerDetail: detail,
|
|
202
221
|
isError: Boolean(context.isError),
|
|
203
222
|
isPartial: Boolean(context.isPartial),
|
|
204
223
|
isPending: Boolean(context.isPartial),
|
|
224
|
+
running: Boolean(context.executionStarted),
|
|
225
|
+
resultSeen: isResultSeen(context.state),
|
|
205
226
|
});
|
|
206
227
|
},
|
|
207
228
|
result(result, options, theme, context) {
|
|
@@ -1,204 +1,47 @@
|
|
|
1
1
|
// Boxed read tool renderer
|
|
2
2
|
// (renderCall/renderResult only; no tool re-registration).
|
|
3
|
+
//
|
|
4
|
+
// Read calls render as a boxless tree panel — a lone read is a batch of one,
|
|
5
|
+
// consecutive reads group into one panel (see batch.ts). There is no boxed
|
|
6
|
+
// single-call special case.
|
|
3
7
|
|
|
4
|
-
import { getLanguageFromPath, highlightCode } from "@earendil-works/pi-coding-agent";
|
|
5
8
|
import { stripAnsi } from "../../../shared/ansi.js";
|
|
6
|
-
import
|
|
9
|
+
import { getTextOutput } from "../../../shared/box.js";
|
|
7
10
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
} from "
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
compactFooterWithState,
|
|
22
|
-
noteExecutionStart,
|
|
23
|
-
pathRangeDetail,
|
|
24
|
-
resultFooterLines,
|
|
25
|
-
truncationOutputLines,
|
|
26
|
-
} from "./shared.js";
|
|
27
|
-
|
|
28
|
-
const MAX_HIGHLIGHT_OUTPUT_CHARS = 12000;
|
|
29
|
-
const MAX_HIGHLIGHT_OUTPUT_LINES = 300;
|
|
30
|
-
|
|
31
|
-
type NumberedReadLine = {
|
|
32
|
-
lineNumber: string;
|
|
33
|
-
content: string;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
type ParsedReadOutput = {
|
|
37
|
-
fileHash: string | undefined;
|
|
38
|
-
numberedLines?: NumberedReadLine[];
|
|
39
|
-
body: string;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
function parseReadOutput(text: string): ParsedReadOutput {
|
|
43
|
-
const fileHashMatch = text.match(/^fileHash: ([^\n]+)\n\n/);
|
|
44
|
-
const body = fileHashMatch ? text.slice(fileHashMatch[0].length) : text;
|
|
45
|
-
const rawLines = body ? body.split("\n") : [];
|
|
46
|
-
const numberedLines = rawLines.map((line) => line.match(/^\s*(\d+)\| ?(.*)$/));
|
|
47
|
-
|
|
48
|
-
if (numberedLines.length > 0 && numberedLines.every(Boolean)) {
|
|
49
|
-
return {
|
|
50
|
-
fileHash: fileHashMatch?.[1],
|
|
51
|
-
body: numberedLines.map((match) => match?.[2] ?? "").join("\n"),
|
|
52
|
-
numberedLines: numberedLines.map((match) => ({
|
|
53
|
-
lineNumber: match?.[1] ?? "",
|
|
54
|
-
content: match?.[2] ?? "",
|
|
55
|
-
})),
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return { fileHash: fileHashMatch?.[1], body };
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function renderReadBody(
|
|
63
|
-
theme: BoxTheme,
|
|
64
|
-
options: { expanded: boolean },
|
|
65
|
-
parsed: ParsedReadOutput,
|
|
66
|
-
output: string,
|
|
67
|
-
truncationNotice: string | null,
|
|
68
|
-
): { render(width: number): string[]; invalidate(): void } {
|
|
69
|
-
let cacheKey = "";
|
|
70
|
-
let cacheLines: string[] | null = null;
|
|
71
|
-
return {
|
|
72
|
-
invalidate() {
|
|
73
|
-
cacheKey = "";
|
|
74
|
-
cacheLines = null;
|
|
75
|
-
},
|
|
76
|
-
render(width: number): string[] {
|
|
77
|
-
const renderWidth = Math.max(1, width);
|
|
78
|
-
const cfg = getToolsRenderConfig();
|
|
79
|
-
const maxLines = cfg.maxExpandedLines;
|
|
80
|
-
const expanded = Boolean(options.expanded);
|
|
81
|
-
const cacheId = `${renderWidth}|${expanded ? 1 : 0}|${maxLines}|${cfg.dimOutput ? 1 : 0}`;
|
|
82
|
-
if (cacheLines && cacheKey === cacheId) return cacheLines;
|
|
83
|
-
|
|
84
|
-
const linesRead = parsed.numberedLines?.length ?? countLines(parsed.body);
|
|
85
|
-
const summary = theme.fg("dim", `↳ Read ${linesRead} ${linesRead === 1 ? "line" : "lines"}.`);
|
|
86
|
-
const footer: string[] = [];
|
|
87
|
-
if (truncationNotice) footer.push(theme.fg("warning", truncationNotice));
|
|
88
|
-
footer.push("", summary);
|
|
89
|
-
const budget = maxLines > 0 ? maxLines - footer.length : 0;
|
|
90
|
-
const renderPlain = (text: string): string[] => {
|
|
91
|
-
const out: string[] = [];
|
|
92
|
-
for (const line of text.split("\n")) {
|
|
93
|
-
out.push(safeTruncateToWidth(theme.fg("toolOutput", line), renderWidth, "…"));
|
|
94
|
-
}
|
|
95
|
-
return out;
|
|
96
|
-
};
|
|
97
|
-
const lineCount = parsed.numberedLines?.length ?? countLines(parsed.body);
|
|
98
|
-
const shouldHighlight =
|
|
99
|
-
expanded &&
|
|
100
|
-
Boolean(getLanguageFromPath(output)) &&
|
|
101
|
-
parsed.body.length <= MAX_HIGHLIGHT_OUTPUT_CHARS &&
|
|
102
|
-
lineCount <= MAX_HIGHLIGHT_OUTPUT_LINES;
|
|
103
|
-
|
|
104
|
-
const renderBody = (): string[] => {
|
|
105
|
-
if (!parsed.numberedLines)
|
|
106
|
-
return renderPlain(parsed.fileHash ? `fileHash: ${parsed.fileHash}\n\n${parsed.body}` : parsed.body);
|
|
107
|
-
|
|
108
|
-
let bodyLines = parsed.body.split("\n").map((line) => theme.fg("toolOutput", line));
|
|
109
|
-
const lang = getLanguageFromPath(output);
|
|
110
|
-
if (shouldHighlight && lang) {
|
|
111
|
-
try {
|
|
112
|
-
bodyLines = highlightCode(parsed.body, lang);
|
|
113
|
-
} catch {
|
|
114
|
-
bodyLines = parsed.body.split("\n").map((line) => theme.fg("toolOutput", line));
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const out: string[] = [];
|
|
119
|
-
if (parsed.fileHash) out.push(theme.fg("muted", `fileHash: ${parsed.fileHash}`), "");
|
|
120
|
-
|
|
121
|
-
const numberWidth = Math.max(...parsed.numberedLines.map((line) => line.lineNumber.length));
|
|
122
|
-
const gutterWidth = numberWidth + 3;
|
|
123
|
-
const contentWidth = Math.max(1, renderWidth - gutterWidth);
|
|
124
|
-
for (let i = 0; i < parsed.numberedLines.length; i++) {
|
|
125
|
-
const numberedLine = parsed.numberedLines[i] ?? { lineNumber: "", content: "" };
|
|
126
|
-
const bodyLine = safeTruncateToWidth(bodyLines[i] ?? "", contentWidth, "…");
|
|
127
|
-
const gutter = theme.fg("dim", `${numberedLine.lineNumber.padStart(numberWidth)} │ `);
|
|
128
|
-
out.push(`${gutter}${bodyLine}`);
|
|
129
|
-
}
|
|
130
|
-
return out;
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
const highlighted = renderBody();
|
|
134
|
-
if (maxLines > 0 && highlighted.length > budget) {
|
|
135
|
-
const truncated = highlighted.slice(0, budget);
|
|
136
|
-
const remaining = highlighted.length - budget;
|
|
137
|
-
truncated.push(theme.fg("dim", `… ${remaining} more lines`));
|
|
138
|
-
truncated.push(...footer);
|
|
139
|
-
cacheKey = cacheId;
|
|
140
|
-
cacheLines = truncated;
|
|
141
|
-
return cacheLines;
|
|
142
|
-
}
|
|
143
|
-
highlighted.push(...footer);
|
|
144
|
-
cacheKey = cacheId;
|
|
145
|
-
cacheLines = highlighted;
|
|
146
|
-
return cacheLines;
|
|
147
|
-
},
|
|
148
|
-
};
|
|
149
|
-
}
|
|
11
|
+
type BatchToolMeta,
|
|
12
|
+
EMPTY_BATCH_COMPONENT,
|
|
13
|
+
emptyBatchResult,
|
|
14
|
+
registerBatchCall,
|
|
15
|
+
registerBatchResult,
|
|
16
|
+
renderBatchAwareCall,
|
|
17
|
+
} from "./batch.js";
|
|
18
|
+
import { type BoxedToolDefinition, noteExecutionStart, pathRangeDetail } from "./shared.js";
|
|
19
|
+
|
|
20
|
+
const READ_META: BatchToolMeta = Object.freeze({
|
|
21
|
+
toolName: "read",
|
|
22
|
+
label: "Read",
|
|
23
|
+
});
|
|
150
24
|
|
|
151
25
|
export const readTool: BoxedToolDefinition = {
|
|
152
26
|
call(args, theme, context) {
|
|
153
27
|
noteExecutionStart(context);
|
|
154
28
|
const rawPath = String(args?.path ?? args?.file_path ?? "");
|
|
155
29
|
const detail = pathRangeDetail(rawPath, args?.offset, args?.limit, context);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
});
|
|
30
|
+
const { isLeader, batch } = registerBatchCall(READ_META, detail, context);
|
|
31
|
+
if (!isLeader) return EMPTY_BATCH_COMPONENT;
|
|
32
|
+
return renderBatchAwareCall(theme, batch);
|
|
160
33
|
},
|
|
161
|
-
result(result, options,
|
|
162
|
-
clearFooterState(context);
|
|
34
|
+
result(result, options, _theme, context) {
|
|
163
35
|
const output = stripAnsi(getTextOutput(result)).trimEnd();
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const imageCount = Array.isArray(result.content)
|
|
177
|
-
? result.content.filter((contentBlock) => {
|
|
178
|
-
if (!contentBlock || typeof contentBlock !== "object") return false;
|
|
179
|
-
return (contentBlock as { type?: unknown }).type === "image";
|
|
180
|
-
}).length
|
|
181
|
-
: 0;
|
|
182
|
-
if (imageCount > 0) {
|
|
183
|
-
if (!options.expanded) return compactFooterWithState(theme, result, context);
|
|
184
|
-
const summary = `↳ Read ${imageCount} ${imageCount === 1 ? "image" : "images"}.`;
|
|
185
|
-
return renderBoxedToolResult(theme, () => [theme.fg("dim", summary)], {
|
|
186
|
-
widthKey,
|
|
187
|
-
footerLines: resultFooterLines(theme, result, context),
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const stripped = stripTrailingNotice(output);
|
|
192
|
-
const parsed = parseReadOutput(stripped);
|
|
193
|
-
const truncationNotice = extractTrailingNotice(output);
|
|
194
|
-
const _linesRead = truncationOutputLines(result) ?? parsed.numberedLines?.length ?? countLines(parsed.body);
|
|
195
|
-
|
|
196
|
-
if (!options.expanded) return compactFooterWithState(theme, result, context);
|
|
197
|
-
|
|
198
|
-
const body = renderReadBody(theme, options, parsed, output, truncationNotice);
|
|
199
|
-
return renderBoxedToolResult(theme, body, {
|
|
200
|
-
widthKey,
|
|
201
|
-
footerLines: resultFooterLines(theme, result, context),
|
|
202
|
-
});
|
|
36
|
+
registerBatchResult(
|
|
37
|
+
READ_META,
|
|
38
|
+
{
|
|
39
|
+
isPartial: Boolean(options.isPartial),
|
|
40
|
+
isError: Boolean(context.isError),
|
|
41
|
+
errorText: context.isError ? output || undefined : undefined,
|
|
42
|
+
},
|
|
43
|
+
context,
|
|
44
|
+
);
|
|
45
|
+
return emptyBatchResult();
|
|
203
46
|
},
|
|
204
47
|
};
|