@quandev104/pi-style 0.1.1 → 0.1.3
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 +25 -0
- package/README.md +1 -2
- package/dist/extensions/pi-style.js +5881 -4929
- 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/theme.ts +3 -0
- package/extension-src/pi-style/features/messages/boxed-block.ts +16 -20
- package/extension-src/pi-style/features/messages/index.ts +97 -40
- package/extension-src/pi-style/features/messages/special-blocks.ts +3 -3
- package/extension-src/pi-style/features/startup/index.ts +4 -4
- package/extension-src/pi-style/features/tools/boxed/bash.ts +395 -19
- package/extension-src/pi-style/features/tools/boxed/batch.ts +459 -0
- package/extension-src/pi-style/features/tools/boxed/edit.ts +39 -23
- package/extension-src/pi-style/features/tools/boxed/fallback.ts +10 -8
- 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 +45 -24
- package/extension-src/pi-style/features/tools/boxed/read.ts +32 -189
- package/extension-src/pi-style/features/tools/boxed/session-config.ts +6 -0
- package/extension-src/pi-style/features/tools/boxed/write.ts +91 -49
- 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 +48 -4
- package/extension-src/pi-style/shared/ansi.ts +3 -0
- package/extension-src/pi-style/shared/box.ts +210 -69
- package/extension-src/pi-style/shared/split-diff.ts +395 -86
- 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
|
+
}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
// Boxed quick-edit / substitute-edit / target-edit renderer.
|
|
2
2
|
|
|
3
3
|
import { getLanguageFromPath } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
5
4
|
import { stripAnsi } from "../../../shared/ansi.js";
|
|
6
|
-
import type { BoxTheme } from "../../../shared/box.js";
|
|
7
5
|
import {
|
|
8
|
-
|
|
6
|
+
type BoxTheme,
|
|
7
|
+
boxInnerWidth,
|
|
9
8
|
getTextOutput,
|
|
10
9
|
renderBoxedToolCall,
|
|
11
10
|
renderBoxedToolResult,
|
|
12
11
|
} from "../../../shared/box.js";
|
|
13
|
-
import {
|
|
12
|
+
import { formatElapsedMs, getElapsedMs } from "../../../shared/elapsed.js";
|
|
13
|
+
import { AdaptiveDiffComponent, buildSplitRows, countDiffStats } from "../../../shared/split-diff.js";
|
|
14
14
|
import { getStateElapsedMs } from "./session-config.js";
|
|
15
15
|
import { type BoxedToolContext, type BoxedToolDefinition, displayPath, noteExecutionStart } from "./shared.js";
|
|
16
16
|
|
|
@@ -99,8 +99,27 @@ function extractQuickEditDiff(text: string): string | undefined {
|
|
|
99
99
|
return diffLines.length > 0 ? diffLines.join("\n") : undefined;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
/** `Diff · +3 -0` divider label. */
|
|
103
|
+
function quickEditDividerLabel(theme: BoxTheme, stats: { additions: number; removals: number }): string {
|
|
104
|
+
const plus = stats.additions > 0 ? theme.fg("toolDiffAdded", `+${stats.additions}`) : theme.fg("dim", "+0");
|
|
105
|
+
const minus = stats.removals > 0 ? theme.fg("toolDiffRemoved", `-${stats.removals}`) : theme.fg("dim", "-0");
|
|
106
|
+
return `Diff · ${plus} ${minus}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Quick-edit footer: `1 file · +3 -0`, prefixed with elapsed time when known. */
|
|
110
|
+
function quickEditDiffFooter(
|
|
111
|
+
theme: BoxTheme,
|
|
112
|
+
result: { content?: readonly unknown[]; details?: unknown },
|
|
113
|
+
context: BoxedToolContext,
|
|
114
|
+
stats: { additions: number; removals: number },
|
|
115
|
+
): string {
|
|
116
|
+
const elapsedMs = getElapsedMs(result) ?? getStateElapsedMs(context.state);
|
|
117
|
+
const parts: string[] = [];
|
|
118
|
+
if (elapsedMs !== undefined) parts.push(theme.fg("text", formatElapsedMs(elapsedMs)));
|
|
119
|
+
const plus = stats.additions > 0 ? theme.fg("toolDiffAdded", `+${stats.additions}`) : theme.fg("dim", "+0");
|
|
120
|
+
const minus = stats.removals > 0 ? theme.fg("toolDiffRemoved", `-${stats.removals}`) : theme.fg("dim", "-0");
|
|
121
|
+
parts.push(theme.fg("dim", "1 file"), `${plus} ${minus}`);
|
|
122
|
+
return parts.join(theme.fg("dim", " · "));
|
|
104
123
|
}
|
|
105
124
|
|
|
106
125
|
function renderQuickEditResult(
|
|
@@ -122,7 +141,7 @@ function renderQuickEditResult(
|
|
|
122
141
|
const output = getTextOutput(result);
|
|
123
142
|
if (context.isError) {
|
|
124
143
|
return renderBoxedToolResult(theme, () => [theme.fg("error", stripAnsi(output).trim() || "Error")], {
|
|
125
|
-
footerLines: [quickEditFooter(theme, context
|
|
144
|
+
footerLines: [quickEditFooter(theme, context)],
|
|
126
145
|
isError: true,
|
|
127
146
|
});
|
|
128
147
|
}
|
|
@@ -131,7 +150,7 @@ function renderQuickEditResult(
|
|
|
131
150
|
if (!diff) {
|
|
132
151
|
const fallback = stripAnsi(output).trim() || config.fallbackLabel;
|
|
133
152
|
return renderBoxedToolResult(theme, () => [`${theme.fg("dim", "↳")} ${theme.fg("muted", fallback)}`], {
|
|
134
|
-
footerLines: [quickEditFooter(theme, context
|
|
153
|
+
footerLines: [quickEditFooter(theme, context)],
|
|
135
154
|
});
|
|
136
155
|
}
|
|
137
156
|
|
|
@@ -141,43 +160,45 @@ function renderQuickEditResult(
|
|
|
141
160
|
const language = argPath ? getLanguageFromPath(argPath) : undefined;
|
|
142
161
|
const shouldHighlight =
|
|
143
162
|
Boolean(language) && diff.length <= MAX_HIGHLIGHT_DIFF_CHARS && rows.length <= MAX_HIGHLIGHT_DIFF_ROWS;
|
|
144
|
-
|
|
145
|
-
const { additions, removals } = countDiffStats(diff);
|
|
146
|
-
const meter = renderDiffMeter(theme, additions, removals);
|
|
147
|
-
const summary =
|
|
148
|
-
`${theme.fg("dim", "↳")} ${theme.fg("muted", "diff")}` +
|
|
149
|
-
` ${theme.fg("toolDiffAdded", `+${additions}`)}` +
|
|
150
|
-
` ${theme.fg("toolDiffRemoved", `-${removals}`)}` +
|
|
151
|
-
` ${theme.fg("muted", "split")}` +
|
|
152
|
-
(meter ? ` ${meter}` : "");
|
|
163
|
+
const stats = countDiffStats(diff);
|
|
153
164
|
|
|
154
165
|
const maxRows = expanded ? 160 : 36;
|
|
155
|
-
const
|
|
166
|
+
const diffView = new AdaptiveDiffComponent(theme, rows, maxRows, shouldHighlight ? language : undefined);
|
|
167
|
+
const expandHint = !expanded && diffView.hasCollapsed() ? "Ctrl+O more" : undefined;
|
|
156
168
|
|
|
157
169
|
return renderBoxedToolResult(
|
|
158
170
|
theme,
|
|
159
171
|
{
|
|
160
172
|
render(width: number): string[] {
|
|
161
|
-
|
|
162
|
-
const headerLines = new Text(summary, 0, 0).render(safeWidth);
|
|
163
|
-
return [...headerLines, ...split.render(safeWidth)];
|
|
173
|
+
return diffView.render(width);
|
|
164
174
|
},
|
|
165
175
|
invalidate(): void {
|
|
166
|
-
|
|
176
|
+
diffView.invalidate();
|
|
167
177
|
},
|
|
168
178
|
},
|
|
169
179
|
{
|
|
170
|
-
|
|
180
|
+
dividerLabel: quickEditDividerLabel(theme, stats),
|
|
181
|
+
...(expandHint ? { dividerRightLabel: expandHint } : {}),
|
|
182
|
+
footerLines: [quickEditDiffFooter(theme, result, context, stats)],
|
|
171
183
|
},
|
|
172
184
|
);
|
|
173
185
|
}
|
|
174
186
|
|
|
187
|
+
function quickEditFooter(theme: BoxTheme, context: BoxedToolContext): string {
|
|
188
|
+
const elapsedMs = getStateElapsedMs(context.state);
|
|
189
|
+
const parts: string[] = [];
|
|
190
|
+
if (elapsedMs !== undefined) parts.push(theme.fg("text", formatElapsedMs(elapsedMs)));
|
|
191
|
+
parts.push(theme.fg("dim", "1 file"));
|
|
192
|
+
return parts.join(theme.fg("dim", " · "));
|
|
193
|
+
}
|
|
194
|
+
|
|
175
195
|
export function quickEditTool(config: QuickEditToolConfig): BoxedToolDefinition {
|
|
176
196
|
return {
|
|
177
197
|
call(args, theme, context) {
|
|
178
198
|
noteExecutionStart(context);
|
|
179
199
|
const detail = displayPath(String(args?.path ?? ""), context);
|
|
180
|
-
return renderBoxedToolCall(theme, config.toolLabel, [
|
|
200
|
+
return renderBoxedToolCall(theme, config.toolLabel, [], {
|
|
201
|
+
headerDetail: detail,
|
|
181
202
|
isError: Boolean(context.isError),
|
|
182
203
|
isPartial: Boolean(context.isPartial),
|
|
183
204
|
isPending: Boolean(context.isPartial),
|