@heyhuynhgiabuu/pi-pretty 0.5.2 → 0.6.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/README.md +21 -0
- package/bun.lock +598 -0
- package/package.json +6 -8
- package/pi-pretty.example.json +6 -0
- package/release-notes/v0.5.3.md +29 -0
- package/src/config.ts +250 -0
- package/src/fff.ts +147 -0
- package/src/helpers.ts +124 -0
- package/src/image.ts +129 -0
- package/src/index.ts +157 -1980
- package/src/render.ts +402 -0
- package/src/tools/bash.ts +115 -0
- package/src/tools/find.ts +87 -0
- package/src/tools/grep.ts +99 -0
- package/src/tools/ls.ts +66 -0
- package/src/tools/metrics.ts +40 -0
- package/src/tools/multi-grep.ts +171 -0
- package/src/tools/read.ts +112 -0
- package/src/types.ts +227 -0
- package/test/bash-rendering.test.ts +104 -1
package/src/render.ts
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-pretty: rendering functions for all tools.
|
|
3
|
+
*
|
|
4
|
+
* These produce ANSI-colored terminal output strings.
|
|
5
|
+
* They are async only when Shiki syntax highlighting is involved.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { BundledLanguage } from "shiki";
|
|
9
|
+
import { codeToANSI } from "@shikijs/cli";
|
|
10
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
11
|
+
import { basename, dirname } from "node:path";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
RST, FG_LNUM, FG_DIM, FG_RULE, FG_GREEN, FG_RED, FG_YELLOW, FG_BLUE,
|
|
15
|
+
BG_BASE, BG_ERROR,
|
|
16
|
+
dirIcon, detectLang, termWidth, MAX_PREVIEW_LINES, MAX_HL_CHARS, CACHE_LIMIT,
|
|
17
|
+
resolveBaseBackground,
|
|
18
|
+
} from "./config.js";
|
|
19
|
+
import {
|
|
20
|
+
normalizeLineEndings, humanSize, formatElapsedMs, formatCharCount,
|
|
21
|
+
ELAPSED_KEY, CHARS_KEY, compactErrorLines,
|
|
22
|
+
} from "./helpers.js";
|
|
23
|
+
import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import type { ThemeLike, RenderCtxLike as RenderContext } from "./types.js";
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Shiki ANSI cache
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
import type { BundledTheme } from "shiki";
|
|
31
|
+
|
|
32
|
+
const DEFAULT_THEME: BundledTheme = "github-dark";
|
|
33
|
+
|
|
34
|
+
function resolveTheme(): BundledTheme {
|
|
35
|
+
const env = process.env.PRETTY_THEME as BundledTheme | undefined;
|
|
36
|
+
if (env) return env;
|
|
37
|
+
try {
|
|
38
|
+
const home = process.env.HOME;
|
|
39
|
+
if (!home) return DEFAULT_THEME;
|
|
40
|
+
const settings = JSON.parse(
|
|
41
|
+
require("node:fs").readFileSync(require("node:path").join(home, ".pi/agent/settings.json"), "utf8"),
|
|
42
|
+
);
|
|
43
|
+
return (settings.theme as BundledTheme) ?? DEFAULT_THEME;
|
|
44
|
+
} catch {
|
|
45
|
+
return DEFAULT_THEME;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let THEME: BundledTheme = resolveTheme();
|
|
50
|
+
const _cache = new Map<string, string[]>();
|
|
51
|
+
|
|
52
|
+
function _touch(k: string, v: string[]): string[] {
|
|
53
|
+
_cache.delete(k);
|
|
54
|
+
_cache.set(k, v);
|
|
55
|
+
while (_cache.size > CACHE_LIMIT) {
|
|
56
|
+
const first = _cache.keys().next().value;
|
|
57
|
+
if (first === undefined) break;
|
|
58
|
+
_cache.delete(first);
|
|
59
|
+
}
|
|
60
|
+
return v;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function hlBlock(code: string, language: BundledLanguage | undefined): Promise<string[]> {
|
|
64
|
+
if (!code) return [""];
|
|
65
|
+
if (!language || code.length > MAX_HL_CHARS) return code.split("\n");
|
|
66
|
+
|
|
67
|
+
const k = `${THEME}\0${language}\0${code}`;
|
|
68
|
+
const hit = _cache.get(k);
|
|
69
|
+
if (hit) return _touch(k, hit);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const ansi = normalizeShikiContrast(await codeToANSI(code, language, THEME));
|
|
73
|
+
const out = (ansi.endsWith("\n") ? ansi.slice(0, -1) : ansi).split("\n");
|
|
74
|
+
return _touch(k, out);
|
|
75
|
+
} catch {
|
|
76
|
+
return code.split("\n");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ESC_RE = "\u001b";
|
|
81
|
+
const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([0-9;]*)m`, "g");
|
|
82
|
+
const FG_MUTED = "\x1b[38;2;139;148;158m";
|
|
83
|
+
|
|
84
|
+
export function isLowContrastShikiFg(params: string): boolean {
|
|
85
|
+
if (params === "30" || params === "90") return true;
|
|
86
|
+
if (params === "38;5;0" || params === "38;5;8") return true;
|
|
87
|
+
if (!params.startsWith("38;2;")) return false;
|
|
88
|
+
const parts = params.split(";").map(Number);
|
|
89
|
+
if (parts.length !== 5 || parts.some((n) => !Number.isFinite(n))) return false;
|
|
90
|
+
const [, , r, g, b] = parts;
|
|
91
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
92
|
+
return luminance < 72;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeShikiContrast(ansi: string): string {
|
|
96
|
+
return ansi.replace(ANSI_CAPTURE_RE, (seq, params: string) => (isLowContrastShikiFg(params) ? FG_MUTED : seq));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Box background helpers
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
export const RESET_WITHOUT_BG = "\x1b[22;23;24;25;27;28;29;39m";
|
|
104
|
+
|
|
105
|
+
export function preserveBoxBackground(ansi: string): string {
|
|
106
|
+
return ansi.replace(ANSI_CAPTURE_RE, (_seq, params: string) => {
|
|
107
|
+
if (!params || params === "0") return RESET_WITHOUT_BG;
|
|
108
|
+
const parts = params.split(";").filter(Boolean);
|
|
109
|
+
const kept: string[] = [];
|
|
110
|
+
let i = 0;
|
|
111
|
+
while (i < parts.length) {
|
|
112
|
+
const code = Number(parts[i]);
|
|
113
|
+
if (code === 38) {
|
|
114
|
+
// Foreground extended — keep entire sequence
|
|
115
|
+
kept.push(parts[i]);
|
|
116
|
+
if (parts[i + 1] === "5") { kept.push(parts[i + 1]); i += 2; }
|
|
117
|
+
else if (parts[i + 1] === "2") { kept.push(parts[i + 1], parts[i + 2], parts[i + 3], parts[i + 4]); i += 5; }
|
|
118
|
+
else { i++; }
|
|
119
|
+
} else if (code === 48) {
|
|
120
|
+
// Background extended — skip entirely
|
|
121
|
+
if (parts[i + 1] === "5") i += 3;
|
|
122
|
+
else if (parts[i + 1] === "2") i += 6;
|
|
123
|
+
else i++;
|
|
124
|
+
} else if (code === 49 || (code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
|
|
125
|
+
i++;
|
|
126
|
+
} else {
|
|
127
|
+
kept.push(parts[i]);
|
|
128
|
+
i++;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return kept.length ? `\x1b[${kept.join(";")}m` : "";
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function fillToolBackground(text: string, bg = BG_BASE, width?: number): string {
|
|
136
|
+
return text
|
|
137
|
+
.split("\n")
|
|
138
|
+
.map((line) => {
|
|
139
|
+
const fitted = width ? truncateToWidth(line, width, "") : line;
|
|
140
|
+
const stripped = preserveBoxBackground(fitted);
|
|
141
|
+
// Apply background to the entire line
|
|
142
|
+
return bg ? bg + stripped : stripped;
|
|
143
|
+
})
|
|
144
|
+
.join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function rule(w: number): string {
|
|
148
|
+
return `${FG_RULE}${"─".repeat(w)}${RST}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function lnum(n: number, w: number): string {
|
|
152
|
+
const v = String(n);
|
|
153
|
+
return `${FG_LNUM}${" ".repeat(Math.max(0, w - v.length))}${v}${RST}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Tool metrics line
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
export function renderToolMetrics(result: AgentToolResult<Record<string, unknown>>): string {
|
|
161
|
+
const details = result.details as Record<string, unknown> | undefined;
|
|
162
|
+
if (!details) return "";
|
|
163
|
+
const elapsed = formatElapsedMs(details[ELAPSED_KEY] as number | undefined);
|
|
164
|
+
const chars = formatCharCount(details[CHARS_KEY] as number | undefined);
|
|
165
|
+
if (!elapsed && !chars) return "";
|
|
166
|
+
return `${FG_DIM}· ${[elapsed, chars].filter(Boolean).join(" · ")}${RST}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Error renderer
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
export function renderToolError(error: string, theme: ThemeLike): string {
|
|
174
|
+
const body = compactErrorLines(error)
|
|
175
|
+
.map((line) => ` ${line ? theme.fg("error", line) : ""}`)
|
|
176
|
+
.join("\n");
|
|
177
|
+
return fillToolBackground(body, BG_ERROR);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Read — syntax-highlighted file content
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
export async function renderFileContent(
|
|
185
|
+
content: string,
|
|
186
|
+
filePath: string,
|
|
187
|
+
offset = 1,
|
|
188
|
+
maxLines = MAX_PREVIEW_LINES,
|
|
189
|
+
width?: number,
|
|
190
|
+
): Promise<string> {
|
|
191
|
+
const normalizedContent = normalizeLineEndings(content);
|
|
192
|
+
const lines = normalizedContent.split("\n");
|
|
193
|
+
const total = lines.length;
|
|
194
|
+
const show = lines.slice(0, maxLines);
|
|
195
|
+
const lg = detectLang(filePath);
|
|
196
|
+
const hl = await hlBlock(show.join("\n"), lg);
|
|
197
|
+
|
|
198
|
+
const tw = width ?? termWidth();
|
|
199
|
+
const startLine = offset;
|
|
200
|
+
const endLine = startLine + show.length - 1;
|
|
201
|
+
const nw = Math.max(3, String(endLine).length);
|
|
202
|
+
const gw = nw + 3;
|
|
203
|
+
const cw = Math.max(1, tw - gw);
|
|
204
|
+
|
|
205
|
+
const out: string[] = [];
|
|
206
|
+
out.push(rule(tw));
|
|
207
|
+
|
|
208
|
+
for (let i = 0; i < hl.length; i++) {
|
|
209
|
+
const ln = startLine + i;
|
|
210
|
+
const code = hl[i] ?? show[i] ?? "";
|
|
211
|
+
const display = truncateToWidth(code, cw, `${FG_DIM}›`);
|
|
212
|
+
out.push(`${lnum(ln, nw)} ${FG_RULE}│${RST} ${display}${RST}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
out.push(rule(tw));
|
|
216
|
+
if (total > maxLines) {
|
|
217
|
+
out.push(`${FG_DIM} … ${total - maxLines} more lines (${total} total)${RST}`);
|
|
218
|
+
}
|
|
219
|
+
return out.join("\n");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Bash — colored exit status
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
export function renderBashOutput(text: string, exitCode: number | null): { summary: string; body: string } {
|
|
227
|
+
const isOk = exitCode === 0;
|
|
228
|
+
const statusIcon = isOk ? "✓" : "✗";
|
|
229
|
+
const codeStr = exitCode !== null
|
|
230
|
+
? `${isOk ? FG_GREEN : FG_RED}${statusIcon} exit ${exitCode}${RST}`
|
|
231
|
+
: `${FG_YELLOW}⚡ killed${RST}`;
|
|
232
|
+
|
|
233
|
+
const lines = text.split("\n");
|
|
234
|
+
const maxShow = MAX_PREVIEW_LINES;
|
|
235
|
+
const show = lines.slice(0, maxShow);
|
|
236
|
+
const remaining = lines.length - maxShow;
|
|
237
|
+
|
|
238
|
+
let body = show.join("\n");
|
|
239
|
+
if (remaining > 0) body += `\n${FG_DIM} … ${remaining} more lines${RST}`;
|
|
240
|
+
|
|
241
|
+
return { summary: codeStr, body };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// Ls — tree view with icons
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
export function renderTree(text: string, _basePath: string): string {
|
|
249
|
+
const lines = text.trim().split("\n").filter(Boolean);
|
|
250
|
+
if (!lines.length) return `${FG_DIM}(empty directory)${RST}`;
|
|
251
|
+
|
|
252
|
+
const out: string[] = [];
|
|
253
|
+
const total = lines.length;
|
|
254
|
+
const show = lines.slice(0, MAX_PREVIEW_LINES);
|
|
255
|
+
|
|
256
|
+
for (let i = 0; i < show.length; i++) {
|
|
257
|
+
const entry = show[i].trim();
|
|
258
|
+
const isLast = i === show.length - 1 && total <= MAX_PREVIEW_LINES;
|
|
259
|
+
const prefix = isLast ? "└── " : "├── ";
|
|
260
|
+
const connector = `${FG_RULE}${prefix}${RST}`;
|
|
261
|
+
const isDir = entry.endsWith("/");
|
|
262
|
+
const name = isDir ? entry.slice(0, -1) : entry;
|
|
263
|
+
const icon = isDir ? dirIcon() : "";
|
|
264
|
+
out.push(`${connector}${icon}${isDir ? `${FG_BLUE}\x1b[1m${name}${RST}` : name}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (total > MAX_PREVIEW_LINES) {
|
|
268
|
+
out.push(`${FG_RULE}└── ${RST}${FG_DIM}… ${total - MAX_PREVIEW_LINES} more entries${RST}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return out.join("\n");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// Find — grouped file list with icons
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
export function renderFindResults(text: string): string {
|
|
279
|
+
const lines = text.trim().split("\n").filter(Boolean);
|
|
280
|
+
if (!lines.length) return `${FG_DIM}(no matches)${RST}`;
|
|
281
|
+
|
|
282
|
+
const groups = new Map<string, string[]>();
|
|
283
|
+
for (const line of lines) {
|
|
284
|
+
const trimmed = line.trim();
|
|
285
|
+
const dir = dirname(trimmed) || ".";
|
|
286
|
+
const file = basename(trimmed);
|
|
287
|
+
if (!groups.has(dir)) groups.set(dir, []);
|
|
288
|
+
const bucket = groups.get(dir);
|
|
289
|
+
if (bucket) bucket.push(file);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const out: string[] = [];
|
|
293
|
+
let count = 0;
|
|
294
|
+
|
|
295
|
+
for (const [dir, files] of groups) {
|
|
296
|
+
if (count > 0) out.push("");
|
|
297
|
+
out.push(`${dirIcon()}${FG_BLUE}\x1b[1m${dir}/${RST}`);
|
|
298
|
+
for (let i = 0; i < files.length; i++) {
|
|
299
|
+
if (count >= MAX_PREVIEW_LINES) {
|
|
300
|
+
out.push(` ${FG_DIM}… ${lines.length - count} more files${RST}`);
|
|
301
|
+
return out.join("\n");
|
|
302
|
+
}
|
|
303
|
+
const isLast = i === files.length - 1;
|
|
304
|
+
const prefix = isLast ? "└── " : "├── ";
|
|
305
|
+
out.push(` ${FG_RULE}${prefix}${RST}${files[i]}`);
|
|
306
|
+
count++;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return out.join("\n");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// Grep — highlighted matches with line numbers
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
export async function renderGrepResults(text: string, pattern: string): Promise<string> {
|
|
318
|
+
const lines = normalizeLineEndings(text).split("\n");
|
|
319
|
+
if (!lines.length || (lines.length === 1 && !lines[0].trim())) return `${FG_DIM}(no matches)${RST}`;
|
|
320
|
+
|
|
321
|
+
const out: string[] = [];
|
|
322
|
+
let currentFile = "";
|
|
323
|
+
let count = 0;
|
|
324
|
+
|
|
325
|
+
let re: RegExp | null = null;
|
|
326
|
+
try {
|
|
327
|
+
re = new RegExp(`(${pattern})`, "gi");
|
|
328
|
+
} catch { /* skip highlighting */ }
|
|
329
|
+
|
|
330
|
+
for (const line of lines) {
|
|
331
|
+
if (count >= MAX_PREVIEW_LINES) {
|
|
332
|
+
out.push(`${FG_DIM} … more matches${RST}`);
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
const fileMatch = line.match(/^(.+?)[:-](\d+)[:-](.*)$/);
|
|
336
|
+
if (fileMatch) {
|
|
337
|
+
const [, file, lineNo, content] = fileMatch;
|
|
338
|
+
if (file !== currentFile) {
|
|
339
|
+
if (currentFile) out.push("");
|
|
340
|
+
out.push(`${FG_BLUE}\x1b[1m${file}${RST}`);
|
|
341
|
+
currentFile = file;
|
|
342
|
+
}
|
|
343
|
+
const nw = Math.max(3, lineNo.length);
|
|
344
|
+
let display = content;
|
|
345
|
+
if (re) display = content.replace(re, `${RST}${FG_YELLOW}\x1b[1m$1${RST}`);
|
|
346
|
+
out.push(` ${lnum(Number(lineNo), nw)} ${FG_RULE}│${RST} ${display}${RST}`);
|
|
347
|
+
count++;
|
|
348
|
+
} else if (line.trim() === "--") {
|
|
349
|
+
out.push(` ${FG_DIM} ···${RST}`);
|
|
350
|
+
} else if (line.trim()) {
|
|
351
|
+
out.push(line);
|
|
352
|
+
count++;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return out.join("\n");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// Generic renderCall / renderResult for custom tools
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
export function makeRenderCall(toolName: string) {
|
|
364
|
+
return (args: Record<string, unknown>, theme: ThemeLike, ctx: RenderContext) => {
|
|
365
|
+
resolveBaseBackground(theme);
|
|
366
|
+
const text = ctx.lastComponent ?? new (require("@earendil-works/pi-tui").Text)("", 0, 0);
|
|
367
|
+
const bg = ctx.isError ? BG_ERROR : undefined;
|
|
368
|
+
text.setText(fillToolBackground(`${theme.fg("toolTitle", theme.bold(toolName))}`, bg));
|
|
369
|
+
return text;
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function makeRenderResult() {
|
|
374
|
+
return (result: AgentToolResult<Record<string, unknown>>, _opt: unknown, theme: ThemeLike, ctx: RenderContext) => {
|
|
375
|
+
resolveBaseBackground(theme);
|
|
376
|
+
const text = ctx.lastComponent ?? new (require("@earendil-works/pi-tui").Text)("", 0, 0);
|
|
377
|
+
if (ctx.isError) {
|
|
378
|
+
text.setText(renderToolError(getTextContent(result) || "Error", theme));
|
|
379
|
+
return text;
|
|
380
|
+
}
|
|
381
|
+
const content = getTextContent(result);
|
|
382
|
+
if (content) {
|
|
383
|
+
const renderWidth = termWidth();
|
|
384
|
+
const lines = content.split("\n");
|
|
385
|
+
const maxShow = ctx.expanded ? lines.length : Math.min(lines.length, MAX_PREVIEW_LINES);
|
|
386
|
+
const preview = lines.slice(0, maxShow).join("\n");
|
|
387
|
+
const more = lines.length > maxShow ? `\n${FG_DIM}... ${lines.length - maxShow} more lines${RST}` : "";
|
|
388
|
+
const metrics = renderToolMetrics(result);
|
|
389
|
+
text.setText(fillToolBackground(` ${preview}${more}${metrics ? `\n ${metrics}` : ""}`, undefined, renderWidth));
|
|
390
|
+
} else {
|
|
391
|
+
text.setText(fillToolBackground(` ${theme.fg("dim", "(no text output)")}`));
|
|
392
|
+
}
|
|
393
|
+
return text;
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function getTextContent(result: AgentToolResult<Record<string, unknown>>): string {
|
|
398
|
+
return (result.content ?? [])
|
|
399
|
+
.filter((c: any) => c.type === "text")
|
|
400
|
+
.map((c: any) => c.text ?? "")
|
|
401
|
+
.join("\n");
|
|
402
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/* pi-pretty: bash tool -- command execution with styled output. */
|
|
2
|
+
|
|
3
|
+
import { type ToolDefinition, type ExtensionAPI, type ExtensionContext, type AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { SdkToolDef, BashDetails, TextContent, ComponentLike, ThemeLike, RenderCtxLike } from "../types.js";
|
|
5
|
+
import { resolveBaseBackground, termWidth, MAX_PREVIEW_LINES, BG_BASE, BG_ERROR, FG_DIM, FG_RULE, RST } from "../config.js";
|
|
6
|
+
import { wrapExecuteWithMetrics } from "./metrics.js";
|
|
7
|
+
import { renderBashOutput, renderToolError, renderToolMetrics, fillToolBackground } from "../render.js";
|
|
8
|
+
import { stripBashExitStatusLine, inferBashExitCode, compactErrorLines } from "../helpers.js";
|
|
9
|
+
|
|
10
|
+
type Result = AgentToolResult<Record<string, unknown>>;
|
|
11
|
+
|
|
12
|
+
export function registerBashTool(
|
|
13
|
+
pi: ExtensionAPI,
|
|
14
|
+
_cwd: string,
|
|
15
|
+
_fffService: unknown,
|
|
16
|
+
sdkTool: SdkToolDef,
|
|
17
|
+
TextComp?: new (t?: string, x?: number, y?: number) => { setText(v: string): void },
|
|
18
|
+
): void {
|
|
19
|
+
const TC = TextComp ?? (() => {
|
|
20
|
+
const { Text } = require("@earendil-works/pi-tui") as { Text: new (t?: string, x?: number, y?: number) => { setText(v: string): void } };
|
|
21
|
+
return Text;
|
|
22
|
+
})();
|
|
23
|
+
|
|
24
|
+
pi.registerTool({
|
|
25
|
+
name: "bash",
|
|
26
|
+
label: "Bash",
|
|
27
|
+
description: sdkTool.description ?? "Execute shell commands",
|
|
28
|
+
parameters: sdkTool.parameters,
|
|
29
|
+
renderShell: "self",
|
|
30
|
+
|
|
31
|
+
execute: wrapExecuteWithMetrics(async (tid, params, sig, _upd, ctx: ExtensionContext) => {
|
|
32
|
+
try {
|
|
33
|
+
return await sdkTool.execute(tid, params, sig, undefined, ctx) as Result;
|
|
34
|
+
} catch (error: unknown) {
|
|
35
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
36
|
+
return { content: [{ type: "text" as const, text: msg }], isError: true, details: { _type: "bashResult", text: msg, exitCode: 1, command: String((params as any).command ?? "") } as BashDetails };
|
|
37
|
+
}
|
|
38
|
+
}),
|
|
39
|
+
|
|
40
|
+
renderCall(args: any, theme: ThemeLike, ctx: RenderCtxLike) {
|
|
41
|
+
resolveBaseBackground(theme);
|
|
42
|
+
const text = ctx.lastComponent ?? new TC("", 0, 0);
|
|
43
|
+
const t = typeof args.timeout === "number" ? ` ${theme.fg("muted", `(${args.timeout}s timeout)`)}` : "";
|
|
44
|
+
const tw = termWidth() || 80;
|
|
45
|
+
const rawCmd = String(args.command ?? "");
|
|
46
|
+
const cmd = (() => {
|
|
47
|
+
const ml = Math.max(1, tw - 20);
|
|
48
|
+
return rawCmd.length > ml ? rawCmd.slice(0, ml) + "\u2026" : rawCmd;
|
|
49
|
+
})();
|
|
50
|
+
const toolWidth = ctx.expanded ? undefined : tw;
|
|
51
|
+
text.setText(fillToolBackground(`\n ${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", cmd)}${t}`, ctx.isError ? BG_ERROR : undefined, toolWidth));
|
|
52
|
+
return text;
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
renderResult(result: Result, _opt: unknown, theme: ThemeLike, ctx: RenderCtxLike) {
|
|
56
|
+
resolveBaseBackground(theme);
|
|
57
|
+
|
|
58
|
+
const text = ctx.lastComponent ?? new TC("", 0, 0);
|
|
59
|
+
|
|
60
|
+
const details = result.details;
|
|
61
|
+
const tc = getText(result);
|
|
62
|
+
const d: BashDetails | undefined =
|
|
63
|
+
(details as BashDetails)?._type === "bashResult" ? details as BashDetails
|
|
64
|
+
: tc || ctx.isError
|
|
65
|
+
? { _type: "bashResult", text: tc || "Error", exitCode: inferBashExitCode(tc, ctx.isError ? 1 : 0), command: "" }
|
|
66
|
+
: undefined;
|
|
67
|
+
|
|
68
|
+
if (d?._type === "bashResult") {
|
|
69
|
+
const isErr = ctx.isError || (d.exitCode !== null && d.exitCode !== 0);
|
|
70
|
+
const bg = isErr ? BG_ERROR : undefined;
|
|
71
|
+
const cleaned = stripBashExitStatusLine(d.text);
|
|
72
|
+
const output = isErr ? compactErrorLines(cleaned).join("\n") : cleaned;
|
|
73
|
+
const { summary } = renderBashOutput(output, d.exitCode);
|
|
74
|
+
const lineCount = output.split("\n").length;
|
|
75
|
+
const info = lineCount > 1 ? ` ${FG_DIM}(${lineCount} lines)${RST} ${renderToolMetrics(result)}` : ` ${renderToolMetrics(result)}`;
|
|
76
|
+
const header = ` ${summary}${info}`;
|
|
77
|
+
const rw = termWidth();
|
|
78
|
+
|
|
79
|
+
const renderFn = (w: number) => {
|
|
80
|
+
if (!output.trim()) return fillToolBackground(header, bg, w);
|
|
81
|
+
const max = ctx.expanded ? lineCount : MAX_PREVIEW_LINES;
|
|
82
|
+
const show = output.split("\n").slice(0, max);
|
|
83
|
+
const out = [header, rule(w), ...show.map((l: string) => ` ${l}`), rule(w)];
|
|
84
|
+
if (lineCount > max) out.push(`${FG_DIM} \u2026 ${lineCount - max} more lines${RST}`);
|
|
85
|
+
return fillToolBackground(out.join("\n"), bg, w);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
text.setText(renderFn(rw));
|
|
89
|
+
const baseRender = typeof (text as ComponentLike).render === "function"
|
|
90
|
+
? (text as ComponentLike).render.bind(text)
|
|
91
|
+
: null;
|
|
92
|
+
if (baseRender) {
|
|
93
|
+
let key: string | undefined;
|
|
94
|
+
(text as unknown as Record<string, unknown>).render = (w: number) => {
|
|
95
|
+
const width = Math.max(1, Math.floor(w || termWidth()));
|
|
96
|
+
const k = `bash:${ctx.expanded ? "1" : "0"}:${width}:${d.exitCode ?? "killed"}:${output.length}:${renderToolMetrics(result)}`;
|
|
97
|
+
if (key !== k) { text.setText(renderFn(width)); key = k; }
|
|
98
|
+
return baseRender(width);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return text;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (ctx.isError) { text.setText(renderToolError(tc || "Error", theme)); return text; }
|
|
105
|
+
const fc = result.content?.[0];
|
|
106
|
+
text.setText(fillToolBackground(` ${theme.fg("dim", fc && "text" in fc ? String(fc.text).slice(0, 120) : "done")}`));
|
|
107
|
+
return text;
|
|
108
|
+
},
|
|
109
|
+
} as unknown as ToolDefinition<any, any, any>);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function rule(w: number): string { return `${FG_RULE}${"\u2500".repeat(w)}${RST}`; }
|
|
113
|
+
function getText(result: Result): string {
|
|
114
|
+
return ((result.content ?? []) as TextContent[]).filter((c) => c.type === "text").map((c) => c.text).join("\n") ?? "";
|
|
115
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/* pi-pretty: find tool -- FFF-backed file search with SDK fallback. */
|
|
2
|
+
|
|
3
|
+
import { type ToolDefinition, type ExtensionAPI, type ExtensionContext, type AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { SdkToolDef, FindDetails, FffServiceLike, FileItem, TextContent, ThemeLike, RenderCtxLike, ComponentLike } from "../types.js";
|
|
5
|
+
import { resolveBaseBackground, BG_ERROR, FG_DIM, RST } from "../config.js";
|
|
6
|
+
import { shortPath } from "../helpers.js";
|
|
7
|
+
import { wrapExecuteWithMetrics } from "./metrics.js";
|
|
8
|
+
import { renderFindResults, renderToolError, renderToolMetrics, fillToolBackground } from "../render.js";
|
|
9
|
+
|
|
10
|
+
type Result = AgentToolResult<Record<string, unknown>>;
|
|
11
|
+
|
|
12
|
+
export function registerFindTool(
|
|
13
|
+
pi: ExtensionAPI,
|
|
14
|
+
cwd: string,
|
|
15
|
+
fffService: FffServiceLike | null | undefined,
|
|
16
|
+
sdkTool: SdkToolDef,
|
|
17
|
+
TextComp?: new (t?: string, x?: number, y?: number) => { setText(v: string): void },
|
|
18
|
+
): void {
|
|
19
|
+
if (!TextComp) {
|
|
20
|
+
const { Text } = require("@earendil-works/pi-tui") as { Text: new (t?: string, x?: number, y?: number) => { setText(v: string): void; render(w: number): string[]; invalidate(): void } };
|
|
21
|
+
TextComp = Text;
|
|
22
|
+
}
|
|
23
|
+
const home = process.env.HOME ?? "";
|
|
24
|
+
|
|
25
|
+
pi.registerTool({
|
|
26
|
+
name: "find",
|
|
27
|
+
label: "Find",
|
|
28
|
+
description: sdkTool.description ?? "Find files matching a glob pattern",
|
|
29
|
+
parameters: sdkTool.parameters,
|
|
30
|
+
renderShell: "self",
|
|
31
|
+
|
|
32
|
+
execute: wrapExecuteWithMetrics(async (tid, params, sig, _upd, ctx: ExtensionContext) => {
|
|
33
|
+
const pattern = String((params as any).pattern ?? "");
|
|
34
|
+
const path = (params as any).path ? String((params as any).path) : undefined;
|
|
35
|
+
const limit = typeof (params as any).limit === "number" ? (params as any).limit : 200;
|
|
36
|
+
|
|
37
|
+
if (fffService?.isAvailable) {
|
|
38
|
+
try {
|
|
39
|
+
const fff = fffService.getFinder();
|
|
40
|
+
if (!fff) throw new Error("FFF finder not available");
|
|
41
|
+
const effectiveLimit = Math.max(1, limit);
|
|
42
|
+
const searchResult = fff.fileSearch(path ? `${path} ${pattern}` : pattern, { pageSize: effectiveLimit });
|
|
43
|
+
if (searchResult.ok) {
|
|
44
|
+
const items: FileItem[] = searchResult.value.items.slice(0, effectiveLimit);
|
|
45
|
+
const notices: string[] = [];
|
|
46
|
+
if (fffService.partialIndex) notices.push("Warning: partial file index");
|
|
47
|
+
if (items.length >= effectiveLimit) notices.push(`${effectiveLimit} limit reached`);
|
|
48
|
+
if (searchResult.value.totalMatched > items.length) notices.push(`${searchResult.value.totalMatched} total matches`);
|
|
49
|
+
const text = appendNotices(items.map((i) => i.relativePath).join("\n"), notices);
|
|
50
|
+
return { content: [{ type: "text" as const, text }], details: { _type: "findResult", text, pattern, matchCount: items.length } as FindDetails };
|
|
51
|
+
}
|
|
52
|
+
} catch { /* fall through */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const result = await sdkTool.execute(tid, params, sig, undefined, ctx) as Result;
|
|
56
|
+
const tc = getText(result);
|
|
57
|
+
result.details = { _type: "findResult", text: tc, pattern, matchCount: tc ? tc.trim().split("\n").filter(Boolean).length : 0 } as FindDetails;
|
|
58
|
+
return result;
|
|
59
|
+
}),
|
|
60
|
+
|
|
61
|
+
renderCall(args: any, theme: ThemeLike, ctx: RenderCtxLike) {
|
|
62
|
+
resolveBaseBackground(theme);
|
|
63
|
+
const text = ctx.lastComponent ?? new TextComp!("", 0, 0);
|
|
64
|
+
const p = args.path ? ` ${theme.fg("muted", `in ${shortPath(cwd, home, String(args.path))}`)}` : "";
|
|
65
|
+
text.setText(fillToolBackground(`\n ${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", String(args.pattern ?? ""))}${p}`, ctx.isError ? BG_ERROR : undefined));
|
|
66
|
+
return text;
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
renderResult(result: Result, _opt: unknown, theme: ThemeLike, ctx: RenderCtxLike) {
|
|
70
|
+
resolveBaseBackground(theme);
|
|
71
|
+
const text = ctx.lastComponent ?? new TextComp!("", 0, 0);
|
|
72
|
+
if (ctx.isError) { text.setText(renderToolError(getText(result) || "Error", theme)); return text; }
|
|
73
|
+
const d = result.details as FindDetails | undefined;
|
|
74
|
+
if (d?._type === "findResult" && d.text) {
|
|
75
|
+
const rendered = renderFindResults(d.text).split("\n").map(l => ` ${l}`).join("\n");
|
|
76
|
+
text.setText(fillToolBackground(` ${FG_DIM}${d.matchCount} files${RST}${renderToolMetrics(result)}\n${rendered}`));
|
|
77
|
+
return text;
|
|
78
|
+
}
|
|
79
|
+
const fc = result.content?.[0];
|
|
80
|
+
text.setText(fillToolBackground(` ${theme.fg("dim", fc && "text" in fc ? String(fc.text).slice(0, 120) : "found")}`));
|
|
81
|
+
return text;
|
|
82
|
+
},
|
|
83
|
+
} as unknown as ToolDefinition<any, any, any>);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function appendNotices(text: string, notices: string[]): string { return notices.length ? `${text}\n\n[${notices.join(". ")}]` : text; }
|
|
87
|
+
function getText(result: Result): string { return ((result.content ?? []) as TextContent[]).filter((c) => c.type === "text").map((c) => c.text ?? "").join("\n") ?? ""; }
|