@heyhuynhgiabuu/pi-pretty 0.5.1 → 0.5.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/README.md +21 -0
- package/package.json +1 -1
- package/pi-pretty.example.json +6 -0
- package/release-notes/v0.5.3.md +29 -0
- package/src/index.ts +410 -98
- package/test/bash-rendering.test.ts +106 -3
package/README.md
CHANGED
|
@@ -133,9 +133,30 @@ Use them when:
|
|
|
133
133
|
|
|
134
134
|
## Configuration
|
|
135
135
|
|
|
136
|
+
### Config file: `~/.pi/agent/pi-pretty.json`
|
|
137
|
+
|
|
138
|
+
Place a JSON file alongside Pi's `settings.json` to customize tool output backgrounds:
|
|
139
|
+
|
|
140
|
+
```json
|
|
141
|
+
{
|
|
142
|
+
"background": {
|
|
143
|
+
"tool": "#1e1e2e",
|
|
144
|
+
"error": "#2a1e1e"
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
- `background.tool` — background color for normal tool output boxes (default: terminal default).
|
|
150
|
+
- `background.error` — background color for error tool output (defaults to `tool` background).
|
|
151
|
+
|
|
152
|
+
Config values take priority over theme-provided backgrounds (`toolBg` / `toolErrorBg`). To override the config directory, set `PRETTY_CONFIG_DIR` env var.
|
|
153
|
+
|
|
154
|
+
### Environment variables
|
|
155
|
+
|
|
136
156
|
Optional environment variables:
|
|
137
157
|
|
|
138
158
|
- `PRETTY_THEME` (overrides `~/.pi/agent/settings.json` `theme`; otherwise pi-pretty falls back to that setting before `github-dark`)
|
|
159
|
+
- `PRETTY_CONFIG_DIR` — directory to read `pi-pretty.json` from (default: `~/.pi/agent/`)
|
|
139
160
|
- `PRETTY_MAX_HL_CHARS` (default: `80000`)
|
|
140
161
|
- `PRETTY_MAX_PREVIEW_LINES` (default: `80`)
|
|
141
162
|
- `PRETTY_CACHE_LIMIT` (default: `128`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyhuynhgiabuu/pi-pretty",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "Pretty terminal output for pi — syntax-highlighted file reads, colored bash output, tree-view directory listings, and more.",
|
|
5
5
|
"author": "huynhgiabuu",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# pi-pretty 0.5.3
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
This release adds user-configurable tool output backgrounds via a `pi-pretty.json` config file, replacing the previous hardcoded theme-only dependency. It also fixes error tool background misalignment where the padding area showed the wrong background color.
|
|
5
|
+
|
|
6
|
+
## What changed
|
|
7
|
+
- Add `pi-pretty.json` config file support (loaded from `~/.pi/agent/pi-pretty.json` alongside `settings.json`).
|
|
8
|
+
- Config fields: `background.tool` (normal tool output bg, hex) and `background.error` (error tool output bg, hex, defaults to tool bg).
|
|
9
|
+
- Config values take priority over theme-provided backgrounds (`toolBg` / `toolErrorBg`).
|
|
10
|
+
- Add `PRETTY_CONFIG_DIR` env var to override the config directory.
|
|
11
|
+
- Fix: `fillToolBackground()` now uses a per-line RST matching the line's background color, fixing a visible color seam where padding showed `BG_BASE` instead of `BG_ERROR` on error tool output.
|
|
12
|
+
- Fix: `renderToolError()` restores top spacing and applies two-space left padding to every line of multi-line error output.
|
|
13
|
+
- Fix: `multi_grep` error path now uses `renderToolError()` instead of raw text without background fill.
|
|
14
|
+
- Add `hexToAnsiBg()` converter and `PrettyConfig` exported interface.
|
|
15
|
+
- Add `pi-pretty.example.json` in the project root.
|
|
16
|
+
- Update README with configuration documentation.
|
|
17
|
+
|
|
18
|
+
## Files
|
|
19
|
+
- `src/index.ts`
|
|
20
|
+
- `pi-pretty.example.json`
|
|
21
|
+
- `README.md`
|
|
22
|
+
|
|
23
|
+
## Verification
|
|
24
|
+
- `npm run typecheck` ✅
|
|
25
|
+
- `npm run lint` ✅ (no new issues)
|
|
26
|
+
- `npm test` ✅ (74 tests)
|
|
27
|
+
|
|
28
|
+
## Upgrade notes
|
|
29
|
+
No configuration changes are required. Existing behavior is unchanged — config is optional. To customize backgrounds, create `~/.pi/agent/pi-pretty.json` with the `background` fields.
|
package/src/index.ts
CHANGED
|
@@ -41,7 +41,7 @@ import type {
|
|
|
41
41
|
ReadToolInput,
|
|
42
42
|
ToolRenderResultOptions,
|
|
43
43
|
} from "@earendil-works/pi-coding-agent";
|
|
44
|
-
import { truncateToWidth
|
|
44
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
45
45
|
import { codeToANSI } from "@shikijs/cli";
|
|
46
46
|
import type { BundledLanguage, BundledTheme } from "shiki";
|
|
47
47
|
|
|
@@ -79,7 +79,11 @@ function resolvePrettyTheme(agentDir?: string): BundledTheme {
|
|
|
79
79
|
|
|
80
80
|
let THEME: BundledTheme = resolvePrettyTheme();
|
|
81
81
|
|
|
82
|
+
/** Stored agent directory for config lookups during render (set during init). */
|
|
83
|
+
let _agentDir: string | undefined;
|
|
84
|
+
|
|
82
85
|
function setPrettyTheme(agentDir?: string): void {
|
|
86
|
+
_agentDir = agentDir;
|
|
83
87
|
const resolvedTheme = resolvePrettyTheme(agentDir);
|
|
84
88
|
if (resolvedTheme === THEME) return;
|
|
85
89
|
THEME = resolvedTheme;
|
|
@@ -96,6 +100,74 @@ const MAX_HL_CHARS = envInt("PRETTY_MAX_HL_CHARS", 80_000);
|
|
|
96
100
|
const MAX_PREVIEW_LINES = envInt("PRETTY_MAX_PREVIEW_LINES", 80);
|
|
97
101
|
const CACHE_LIMIT = envInt("PRETTY_CACHE_LIMIT", 128);
|
|
98
102
|
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// pi-pretty.json config
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/** Schema for pi-pretty.json — user config file placed adjacent to settings.json. */
|
|
108
|
+
export interface PrettyConfig {
|
|
109
|
+
/** Background color overrides for tool output boxes. */
|
|
110
|
+
background?: {
|
|
111
|
+
/** Background color for normal tool output (hex, e.g. "#1e1e2e"). */
|
|
112
|
+
tool?: string;
|
|
113
|
+
/** Background color for error tool output (hex, e.g. "#2a1e1e"). Defaults to tool bg. */
|
|
114
|
+
error?: string;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Convert a hex color string (e.g. "#1e1e2e") to an ANSI 24-bit background escape. */
|
|
119
|
+
function hexToAnsiBg(hex: string): string | null {
|
|
120
|
+
const m = hex.match(/^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);
|
|
121
|
+
if (!m) return null;
|
|
122
|
+
const r = Number.parseInt(m[1], 16);
|
|
123
|
+
const g = Number.parseInt(m[2], 16);
|
|
124
|
+
const b = Number.parseInt(m[3], 16);
|
|
125
|
+
return `\x1b[48;2;${r};${g};${b}m`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Read pi-pretty.json from the agent directory. Returns empty object on any error. */
|
|
129
|
+
function readPrettyConfig(agentDir?: string): PrettyConfig {
|
|
130
|
+
const resolvedDir = agentDir ?? getDefaultAgentDir();
|
|
131
|
+
if (!resolvedDir) return {};
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const raw = readFileSync(join(resolvedDir, "pi-pretty.json"), "utf8");
|
|
135
|
+
const parsed = JSON.parse(raw) as PrettyConfig;
|
|
136
|
+
|
|
137
|
+
// Validate: background fields must be valid hex strings if present
|
|
138
|
+
if (parsed.background) {
|
|
139
|
+
if (parsed.background.tool && !hexToAnsiBg(parsed.background.tool)) {
|
|
140
|
+
parsed.background.tool = undefined;
|
|
141
|
+
}
|
|
142
|
+
if (parsed.background.error && !hexToAnsiBg(parsed.background.error)) {
|
|
143
|
+
parsed.background.error = undefined;
|
|
144
|
+
}
|
|
145
|
+
// Drop empty background block
|
|
146
|
+
if (!parsed.background.tool && !parsed.background.error) {
|
|
147
|
+
parsed.background = undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return parsed;
|
|
152
|
+
} catch {
|
|
153
|
+
return {};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Apply backgrounds from pi-pretty.json config. Returns true if config was applied. */
|
|
158
|
+
function applyPrettyConfigBg(agentDir?: string): boolean {
|
|
159
|
+
const config = readPrettyConfig(agentDir);
|
|
160
|
+
if (!config.background?.tool) return false;
|
|
161
|
+
|
|
162
|
+
const toolBg = hexToAnsiBg(config.background.tool);
|
|
163
|
+
if (!toolBg) return false;
|
|
164
|
+
|
|
165
|
+
BG_BASE = toolBg;
|
|
166
|
+
BG_ERROR = config.background.error ? (hexToAnsiBg(config.background.error) ?? toolBg) : toolBg;
|
|
167
|
+
RST = "\x1b[0m";
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
99
171
|
// ---------------------------------------------------------------------------
|
|
100
172
|
// ANSI
|
|
101
173
|
// ---------------------------------------------------------------------------
|
|
@@ -113,8 +185,8 @@ const FG_BLUE = "\x1b[38;2;100;140;220m";
|
|
|
113
185
|
const FG_MUTED = "\x1b[38;2;139;148;158m";
|
|
114
186
|
|
|
115
187
|
const BG_DEFAULT = "\x1b[49m";
|
|
116
|
-
let BG_BASE = BG_DEFAULT; // tool box success/base bg — updated from theme's toolSuccessBg
|
|
117
|
-
let BG_ERROR = BG_DEFAULT; // tool box error bg — updated from theme's toolErrorBg
|
|
188
|
+
let BG_BASE = BG_DEFAULT; // tool box success/base bg — updated from theme's toolSuccessBg or pi-pretty.json
|
|
189
|
+
let BG_ERROR = BG_DEFAULT; // tool box error bg — updated from theme's toolErrorBg or pi-pretty.json
|
|
118
190
|
|
|
119
191
|
type BgTheme = { getBgAnsi?: (key: string) => string };
|
|
120
192
|
type FgTheme = { fg: (key: string, text: string) => string };
|
|
@@ -135,17 +207,46 @@ function getThemeBgAnsi(theme: BgTheme, key: string): string | null {
|
|
|
135
207
|
}
|
|
136
208
|
|
|
137
209
|
/** Read themed tool backgrounds and update BG_BASE / BG_ERROR + RST.
|
|
138
|
-
* Recompute on each render so runtime theme changes are respected.
|
|
210
|
+
* Recompute on each render so runtime theme changes are respected.
|
|
211
|
+
* Priority: pi-pretty.json config > theme > terminal default. */
|
|
139
212
|
function resolveBaseBackground(theme: BgTheme | null | undefined): void {
|
|
213
|
+
// Config takes highest priority: PRETTY_CONFIG_DIR env > agent dir
|
|
214
|
+
const configDir = process.env.PRETTY_CONFIG_DIR ?? _agentDir ?? getDefaultAgentDir();
|
|
215
|
+
|
|
216
|
+
if (applyPrettyConfigBg(configDir)) return;
|
|
217
|
+
|
|
218
|
+
// Fall back to theme
|
|
140
219
|
if (!theme?.getBgAnsi) return;
|
|
141
220
|
|
|
142
|
-
BG_BASE = getThemeBgAnsi(theme, "toolBg") ?? getThemeBgAnsi(theme, "background") ?? BG_DEFAULT;
|
|
221
|
+
BG_BASE = getThemeBgAnsi(theme, "toolSuccessBg") ?? getThemeBgAnsi(theme, "toolBg") ?? getThemeBgAnsi(theme, "background") ?? BG_DEFAULT;
|
|
143
222
|
BG_ERROR = getThemeBgAnsi(theme, "toolErrorBg") ?? BG_BASE;
|
|
144
|
-
RST =
|
|
223
|
+
RST = "\x1b[0m";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function compactErrorLines(error: string): string[] {
|
|
227
|
+
const compactedLines: string[] = [];
|
|
228
|
+
let previousBlank = false;
|
|
229
|
+
for (const line of normalizeLineEndings(error).trim().split("\n")) {
|
|
230
|
+
const isBlank = line.trim() === "";
|
|
231
|
+
if (isBlank && previousBlank) continue;
|
|
232
|
+
compactedLines.push(line);
|
|
233
|
+
previousBlank = isBlank;
|
|
234
|
+
}
|
|
235
|
+
return compactedLines;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function stripBashExitStatusLine(text: string): string {
|
|
239
|
+
return normalizeLineEndings(text)
|
|
240
|
+
.split("\n")
|
|
241
|
+
.filter((line) => !/^Command exited with code \d+$/i.test(line.trim()))
|
|
242
|
+
.join("\n");
|
|
145
243
|
}
|
|
146
244
|
|
|
147
245
|
function renderToolError(error: string, theme: FgTheme): string {
|
|
148
|
-
|
|
246
|
+
const body = compactErrorLines(error)
|
|
247
|
+
.map((line) => ` ${line ? theme.fg("error", line) : ""}`)
|
|
248
|
+
.join("\n");
|
|
249
|
+
return fillToolBackground(body, BG_ERROR);
|
|
149
250
|
}
|
|
150
251
|
|
|
151
252
|
const ESC_RE = "\u001b";
|
|
@@ -178,30 +279,52 @@ function normalizeLineEndings(text: string): string {
|
|
|
178
279
|
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
179
280
|
}
|
|
180
281
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
282
|
+
const RESET_WITHOUT_BG = "\x1b[22;23;24;25;27;28;29;39m";
|
|
283
|
+
|
|
284
|
+
function preserveBoxBackground(ansi: string): string {
|
|
285
|
+
return ansi.replace(ANSI_CAPTURE_RE, (_seq, params: string) => {
|
|
286
|
+
if (!params || params === "0") return RESET_WITHOUT_BG;
|
|
287
|
+
|
|
288
|
+
const parts = params.split(";").filter(Boolean);
|
|
289
|
+
const kept: string[] = [];
|
|
290
|
+
for (let i = 0; i < parts.length; i++) {
|
|
291
|
+
const code = Number(parts[i]);
|
|
292
|
+
if (code === 49 || (code >= 40 && code <= 47) || (code >= 100 && code <= 107)) continue;
|
|
293
|
+
if (code === 48) {
|
|
294
|
+
if (parts[i + 1] === "5") i += 2;
|
|
295
|
+
else if (parts[i + 1] === "2") i += 4;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
kept.push(parts[i]);
|
|
299
|
+
}
|
|
300
|
+
return kept.length ? `\x1b[${kept.join(";")}m` : "";
|
|
185
301
|
});
|
|
186
302
|
}
|
|
187
303
|
|
|
188
|
-
function fillToolBackground(text: string,
|
|
189
|
-
const width = termW();
|
|
304
|
+
function fillToolBackground(text: string, _bg = BG_BASE, width?: number): string {
|
|
190
305
|
return text
|
|
191
306
|
.split("\n")
|
|
192
307
|
.map((line) => {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
308
|
+
// The TUI Box owns full-width success/error backgrounds. Remove
|
|
309
|
+
// background-affecting ANSI from text so inline resets don't punch
|
|
310
|
+
// holes in the Box background after status labels like "✗ exit 1".
|
|
311
|
+
const fitted = width ? truncateToWidth(line, width, "") : line;
|
|
312
|
+
return preserveBoxBackground(fitted);
|
|
197
313
|
})
|
|
198
314
|
.join("\n");
|
|
199
315
|
}
|
|
200
316
|
|
|
201
317
|
function termW(): number {
|
|
202
|
-
|
|
318
|
+
// When process.stdout.columns is available (real terminal or compositor override),
|
|
319
|
+
// use it directly — the TUI/compositor already provides the exact content width.
|
|
320
|
+
// The -4 safety margin only applies to fallback values (stderr.columns, env.COLUMNS, default).
|
|
321
|
+
if (process.stdout.columns) {
|
|
322
|
+
return Math.max(1, Math.min(process.stdout.columns, 210));
|
|
323
|
+
}
|
|
203
324
|
const raw =
|
|
204
|
-
process.
|
|
325
|
+
(process.stderr as NodeJS.WriteStream & { columns?: number }).columns ||
|
|
326
|
+
Number.parseInt(process.env.COLUMNS ?? "", 10) ||
|
|
327
|
+
200;
|
|
205
328
|
return Math.max(1, Math.min(raw - 4, 210));
|
|
206
329
|
}
|
|
207
330
|
|
|
@@ -605,6 +728,7 @@ async function renderFileContent(
|
|
|
605
728
|
filePath: string,
|
|
606
729
|
offset = 1,
|
|
607
730
|
maxLines = MAX_PREVIEW_LINES,
|
|
731
|
+
width?: number,
|
|
608
732
|
): Promise<string> {
|
|
609
733
|
const normalizedContent = normalizeLineEndings(content);
|
|
610
734
|
const lines = normalizedContent.split("\n");
|
|
@@ -613,7 +737,7 @@ async function renderFileContent(
|
|
|
613
737
|
const lg = lang(filePath);
|
|
614
738
|
const hl = await hlBlock(show.join("\n"), lg);
|
|
615
739
|
|
|
616
|
-
const tw = termW();
|
|
740
|
+
const tw = width ?? termW();
|
|
617
741
|
const startLine = offset;
|
|
618
742
|
const endLine = startLine + show.length - 1;
|
|
619
743
|
const nw = Math.max(3, String(endLine).length);
|
|
@@ -637,6 +761,13 @@ async function renderFileContent(
|
|
|
637
761
|
return out.join("\n");
|
|
638
762
|
}
|
|
639
763
|
|
|
764
|
+
function inferBashExitCode(text: string, fallback: number | null): number | null {
|
|
765
|
+
const exitMatch = text.match(/(?:exit code|exited with(?: code)?|exit status)[:\s]*(\d+)/i);
|
|
766
|
+
if (exitMatch) return Number(exitMatch[1]);
|
|
767
|
+
if (text.includes("command not found") || text.includes("No such file")) return 1;
|
|
768
|
+
return fallback;
|
|
769
|
+
}
|
|
770
|
+
|
|
640
771
|
/** Render bash output with colored exit code and stderr highlighting. */
|
|
641
772
|
function renderBashOutput(text: string, exitCode: number | null): { summary: string; body: string } {
|
|
642
773
|
const isOk = exitCode === 0;
|
|
@@ -780,6 +911,77 @@ async function renderGrepResults(text: string, pattern: string): Promise<string>
|
|
|
780
911
|
return out.join("\n");
|
|
781
912
|
}
|
|
782
913
|
|
|
914
|
+
// ---------------------------------------------------------------------------
|
|
915
|
+
// Tool metrics — elapsed time + output size
|
|
916
|
+
// pi-droid-styling-inspired: wrap execute to record performance, display in footer.
|
|
917
|
+
// ---------------------------------------------------------------------------
|
|
918
|
+
|
|
919
|
+
const ELAPSED_KEY = "__prettyElapsedMs";
|
|
920
|
+
const CHARS_KEY = "__prettyOutputChars";
|
|
921
|
+
|
|
922
|
+
/** Format milliseconds for display. */
|
|
923
|
+
function formatElapsedMs(ms: number | undefined): string {
|
|
924
|
+
if (typeof ms !== "number" || !Number.isFinite(ms)) return "";
|
|
925
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
926
|
+
const s = ms / 1000;
|
|
927
|
+
return s < 10 ? `${s.toFixed(1)}s` : `${Math.round(s)}s`;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** Format character count for display. */
|
|
931
|
+
function formatCharCount(chars: number | undefined): string {
|
|
932
|
+
if (typeof chars !== "number" || !Number.isFinite(chars) || chars <= 0) return "";
|
|
933
|
+
if (chars < 1000) return `${chars} chars`;
|
|
934
|
+
if (chars < 10_000) return `${(chars / 1000).toFixed(1)}k chars`;
|
|
935
|
+
return `${Math.round(chars / 1000)}k chars`;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** Compute text output length from a tool result. */
|
|
939
|
+
function getOutputCharCount(result: ToolResultLike): number {
|
|
940
|
+
const content = result.content;
|
|
941
|
+
if (!Array.isArray(content)) return 0;
|
|
942
|
+
let length = 0;
|
|
943
|
+
for (const block of content) {
|
|
944
|
+
if (block.type !== "text") continue;
|
|
945
|
+
length += String(block.text ?? "").replace(/\r/g, "").length;
|
|
946
|
+
}
|
|
947
|
+
return length;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Wrap a tool's execute function to measure elapsed time and output size.
|
|
952
|
+
* Annotates result.details with __prettyElapsedMs and __prettyOutputChars.
|
|
953
|
+
*/
|
|
954
|
+
function wrapExecuteWithMetrics<TParams, TDetails>(
|
|
955
|
+
execute: (...args: any[]) => Promise<ToolResultLike<TDetails>>,
|
|
956
|
+
): ToolExecutor<TParams, TDetails> {
|
|
957
|
+
return async (
|
|
958
|
+
tid: string,
|
|
959
|
+
params: TParams,
|
|
960
|
+
sig?: AbortSignal,
|
|
961
|
+
onUpdate?: AgentToolUpdateCallback<TDetails | undefined>,
|
|
962
|
+
ctx?: ExtensionContext,
|
|
963
|
+
) => {
|
|
964
|
+
const start = performance.now();
|
|
965
|
+
const result = await execute(tid, params, sig, onUpdate, ctx);
|
|
966
|
+
const elapsedMs = performance.now() - start;
|
|
967
|
+
const details = (result.details ?? {}) as Record<string, unknown>;
|
|
968
|
+
details[ELAPSED_KEY] = elapsedMs;
|
|
969
|
+
details[CHARS_KEY] = getOutputCharCount(result);
|
|
970
|
+
(result as { details: Record<string, unknown> }).details = details;
|
|
971
|
+
return result;
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** Render a tool metrics line: "3.2s · 14.2k chars" */
|
|
976
|
+
function renderToolMetrics(result: ToolResultLike): string {
|
|
977
|
+
const details = result.details as Record<string, unknown> | undefined;
|
|
978
|
+
if (!details) return "";
|
|
979
|
+
const elapsed = formatElapsedMs(details[ELAPSED_KEY] as number | undefined);
|
|
980
|
+
const chars = formatCharCount(details[CHARS_KEY] as number | undefined);
|
|
981
|
+
if (!elapsed && !chars) return "";
|
|
982
|
+
return `${FG_DIM}· ${[elapsed, chars].filter(Boolean).join(" · ")}${RST}`;
|
|
983
|
+
}
|
|
984
|
+
|
|
783
985
|
// ---------------------------------------------------------------------------
|
|
784
986
|
// FFF integration (optional) — Fast File Finder with frecency & SIMD search
|
|
785
987
|
//
|
|
@@ -791,8 +993,17 @@ type ToolTextContent = TextContent;
|
|
|
791
993
|
type ToolImageContent = ImageContent;
|
|
792
994
|
type ToolContent = TextContent | ImageContent;
|
|
793
995
|
type ToolResultLike<TDetails = unknown> = AgentToolResult<TDetails | undefined>;
|
|
794
|
-
type TextComponentLike = { setText(value: string): void; getText?: () => string };
|
|
996
|
+
type TextComponentLike = { setText(value: string): void; getText?: () => string; render?: (width: number) => string[] };
|
|
795
997
|
type TextComponentCtor = new (text?: string, x?: number, y?: number) => TextComponentLike;
|
|
998
|
+
type WidthAwareTextComponent = TextComponentLike & {
|
|
999
|
+
__piPrettyWidthAware?: boolean;
|
|
1000
|
+
__piPrettyRender?: (width: number) => string[];
|
|
1001
|
+
__piPrettyRenderedKey?: string;
|
|
1002
|
+
__piPrettyTask?: {
|
|
1003
|
+
key: (width: number) => string;
|
|
1004
|
+
render: (width: number) => string;
|
|
1005
|
+
};
|
|
1006
|
+
};
|
|
796
1007
|
type ThemeLike = BgTheme & FgTheme & { bold: (text: string) => string };
|
|
797
1008
|
type RenderContextLike<TState extends Record<string, string | undefined> = Record<string, string | undefined>> = {
|
|
798
1009
|
lastComponent?: TextComponentLike;
|
|
@@ -855,6 +1066,7 @@ type MultiGrepParams = {
|
|
|
855
1066
|
context?: number;
|
|
856
1067
|
limit?: number;
|
|
857
1068
|
};
|
|
1069
|
+
type BashRenderState = Record<string, string | undefined>;
|
|
858
1070
|
type GrepRenderState = { _gk?: string; _gt?: string };
|
|
859
1071
|
type MultiGrepRenderState = { _mgk?: string; _mgt?: string };
|
|
860
1072
|
type FindResultDetails = { _type: "findResult"; text: string; pattern: string; matchCount: number };
|
|
@@ -1046,6 +1258,76 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1046
1258
|
return !disabledTools.has(name.toLowerCase());
|
|
1047
1259
|
}
|
|
1048
1260
|
|
|
1261
|
+
function getWidthAwareText(lastComponent: TextComponentLike | undefined): WidthAwareTextComponent {
|
|
1262
|
+
const text = (lastComponent ?? new TextComponent("", 0, 0)) as WidthAwareTextComponent;
|
|
1263
|
+
if (text.__piPrettyWidthAware) return text;
|
|
1264
|
+
const baseRender = typeof text.render === "function" ? text.render.bind(text) : null;
|
|
1265
|
+
if (!baseRender) return text;
|
|
1266
|
+
text.__piPrettyWidthAware = true;
|
|
1267
|
+
text.__piPrettyRender = baseRender;
|
|
1268
|
+
text.render = (width: number) => {
|
|
1269
|
+
const task = text.__piPrettyTask;
|
|
1270
|
+
if (task) {
|
|
1271
|
+
const renderWidth = Math.max(1, Math.floor(width || termW()));
|
|
1272
|
+
const key = task.key(renderWidth);
|
|
1273
|
+
if (text.__piPrettyRenderedKey !== key) {
|
|
1274
|
+
text.__piPrettyRenderedKey = key;
|
|
1275
|
+
text.setText(task.render(renderWidth));
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
return text.__piPrettyRender?.(width) ?? [];
|
|
1279
|
+
};
|
|
1280
|
+
return text;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// ===================================================================
|
|
1284
|
+
// Generic renderResult for custom tools (no custom renderer)
|
|
1285
|
+
// ===================================================================
|
|
1286
|
+
|
|
1287
|
+
const origRegisterTool = pi.registerTool.bind(pi);
|
|
1288
|
+
pi.registerTool = (tool: any) => {
|
|
1289
|
+
if (!tool.renderResult && !tool.renderCall) {
|
|
1290
|
+
const toolName = tool.label ?? tool.name ?? "tool";
|
|
1291
|
+
tool.renderResult = (result: any, _opt: unknown, theme: ThemeLike, ctx: RenderContextLike) => {
|
|
1292
|
+
resolveBaseBackground(theme);
|
|
1293
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1294
|
+
|
|
1295
|
+
if (ctx.isError) {
|
|
1296
|
+
text.setText(renderToolError(getTextContent(result) || "Error", theme));
|
|
1297
|
+
return text;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
const content = getTextContent(result);
|
|
1301
|
+
if (content) {
|
|
1302
|
+
const renderWidth = termW();
|
|
1303
|
+
const lines = content.split("\n");
|
|
1304
|
+
const maxShow = ctx.expanded ? lines.length : Math.min(lines.length, MAX_PREVIEW_LINES);
|
|
1305
|
+
const preview = lines.slice(0, maxShow).join("\n");
|
|
1306
|
+
const more = lines.length > maxShow ? `\n${FG_DIM}... ${lines.length - maxShow} more lines${RST}` : "";
|
|
1307
|
+
const metrics = renderToolMetrics(result);
|
|
1308
|
+
text.setText(
|
|
1309
|
+
fillToolBackground(` ${preview}${more}${metrics ? `\n ${metrics}` : ""}`, undefined, renderWidth),
|
|
1310
|
+
);
|
|
1311
|
+
} else {
|
|
1312
|
+
text.setText(fillToolBackground(` ${theme.fg("dim", "(no text output)")}`));
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
return text;
|
|
1316
|
+
};
|
|
1317
|
+
|
|
1318
|
+
tool.renderCall = (args: any, theme: ThemeLike, ctx: RenderContextLike) => {
|
|
1319
|
+
resolveBaseBackground(theme);
|
|
1320
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1321
|
+
const bg = ctx.isError ? BG_ERROR : undefined;
|
|
1322
|
+
text.setText(
|
|
1323
|
+
fillToolBackground(`${theme.fg("toolTitle", theme.bold(toolName))}`, bg),
|
|
1324
|
+
);
|
|
1325
|
+
return text;
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
origRegisterTool(tool);
|
|
1329
|
+
};
|
|
1330
|
+
|
|
1049
1331
|
// ===================================================================
|
|
1050
1332
|
// FFF initialization (optional — graceful fallback to SDK)
|
|
1051
1333
|
// ===================================================================
|
|
@@ -1127,13 +1409,13 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1127
1409
|
...origRead,
|
|
1128
1410
|
name: "read",
|
|
1129
1411
|
|
|
1130
|
-
async
|
|
1412
|
+
execute: wrapExecuteWithMetrics(async (
|
|
1131
1413
|
tid: string,
|
|
1132
1414
|
params: ReadParams,
|
|
1133
1415
|
sig: AbortSignal | undefined,
|
|
1134
1416
|
upd: AgentToolUpdateCallback<unknown> | undefined,
|
|
1135
1417
|
ctx: ExtensionContext,
|
|
1136
|
-
) {
|
|
1418
|
+
) => {
|
|
1137
1419
|
const result = (await origRead.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
|
|
1138
1420
|
|
|
1139
1421
|
const fp = params.path ?? "";
|
|
@@ -1164,7 +1446,7 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1164
1446
|
}
|
|
1165
1447
|
|
|
1166
1448
|
return result;
|
|
1167
|
-
},
|
|
1449
|
+
}),
|
|
1168
1450
|
|
|
1169
1451
|
renderCall(args: ReadParams, theme: ThemeLike, ctx: RenderContextLike) {
|
|
1170
1452
|
resolveBaseBackground(theme);
|
|
@@ -1172,12 +1454,14 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1172
1454
|
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1173
1455
|
const offset = args.offset ? ` ${theme.fg("muted", `from line ${args.offset}`)}` : "";
|
|
1174
1456
|
const limit = args.limit ? ` ${theme.fg("muted", `(${args.limit} lines)`)}` : "";
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
),
|
|
1179
|
-
|
|
1180
|
-
|
|
1457
|
+
const bg = ctx.isError ? BG_ERROR : undefined;
|
|
1458
|
+
text.setText(
|
|
1459
|
+
fillToolBackground(
|
|
1460
|
+
`${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", sp(fp))}${offset}${limit}`,
|
|
1461
|
+
bg,
|
|
1462
|
+
),
|
|
1463
|
+
);
|
|
1464
|
+
return text;
|
|
1181
1465
|
},
|
|
1182
1466
|
|
|
1183
1467
|
renderResult(result: ToolResultLike, _opt: unknown, theme: ThemeLike, ctx: RenderContextLike) {
|
|
@@ -1204,22 +1488,24 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1204
1488
|
}
|
|
1205
1489
|
|
|
1206
1490
|
if (d?._type === "readFile" && d.content) {
|
|
1207
|
-
const
|
|
1491
|
+
const renderWidth = termW();
|
|
1492
|
+
const key = `read:${d.filePath}:${d.offset}:${d.lineCount}:${renderWidth}`;
|
|
1208
1493
|
if (ctx.state._rk !== key) {
|
|
1209
1494
|
ctx.state._rk = key;
|
|
1210
|
-
const
|
|
1211
|
-
|
|
1495
|
+
const metrics = renderToolMetrics(result);
|
|
1496
|
+
const info = `${FG_DIM}${d.lineCount} lines${RST}${metrics}`;
|
|
1497
|
+
ctx.state._rt = fillToolBackground(` ${info}`, undefined, renderWidth);
|
|
1212
1498
|
|
|
1213
1499
|
const maxShow = ctx.expanded ? d.lineCount : MAX_PREVIEW_LINES;
|
|
1214
|
-
renderFileContent(d.content, d.filePath, d.offset, maxShow)
|
|
1500
|
+
renderFileContent(d.content, d.filePath, d.offset, maxShow, renderWidth)
|
|
1215
1501
|
.then((rendered: string) => {
|
|
1216
1502
|
if (ctx.state._rk !== key) return;
|
|
1217
|
-
ctx.state._rt = fillToolBackground(` ${info}\n${rendered}
|
|
1503
|
+
ctx.state._rt = fillToolBackground(` ${info}\n${rendered}`, undefined, renderWidth);
|
|
1218
1504
|
ctx.invalidate();
|
|
1219
1505
|
})
|
|
1220
1506
|
.catch(() => {});
|
|
1221
1507
|
}
|
|
1222
|
-
text.setText(ctx.state._rt ?? fillToolBackground(` ${FG_DIM}${d.lineCount} lines${RST}
|
|
1508
|
+
text.setText(ctx.state._rt ?? fillToolBackground(` ${FG_DIM}${d.lineCount} lines${RST}${renderToolMetrics(result)}`, undefined, renderWidth));
|
|
1223
1509
|
return text;
|
|
1224
1510
|
}
|
|
1225
1511
|
|
|
@@ -1243,24 +1529,17 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1243
1529
|
...origBash,
|
|
1244
1530
|
name: "bash",
|
|
1245
1531
|
|
|
1246
|
-
async
|
|
1532
|
+
execute: wrapExecuteWithMetrics(async (
|
|
1247
1533
|
tid: string,
|
|
1248
1534
|
params: BashParams,
|
|
1249
1535
|
sig: AbortSignal | undefined,
|
|
1250
1536
|
upd: AgentToolUpdateCallback<unknown> | undefined,
|
|
1251
1537
|
ctx: ExtensionContext,
|
|
1252
|
-
) {
|
|
1538
|
+
) => {
|
|
1253
1539
|
const result = (await origBash.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
|
|
1254
1540
|
const textContent = getTextContent(result);
|
|
1255
1541
|
|
|
1256
|
-
|
|
1257
|
-
if (textContent) {
|
|
1258
|
-
const exitMatch = textContent.match(/(?:exit code|exited with|exit status)[:\s]*(\d+)/i);
|
|
1259
|
-
if (exitMatch) exitCode = Number(exitMatch[1]);
|
|
1260
|
-
if (textContent.includes("command not found") || textContent.includes("No such file")) {
|
|
1261
|
-
exitCode = 1;
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1542
|
+
const exitCode = textContent ? inferBashExitCode(textContent, 0) : 0;
|
|
1264
1543
|
|
|
1265
1544
|
setResultDetails(result, {
|
|
1266
1545
|
_type: "bashResult",
|
|
@@ -1269,56 +1548,81 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1269
1548
|
command: params.command ?? "",
|
|
1270
1549
|
});
|
|
1271
1550
|
|
|
1551
|
+
// Propagate error state to result so the TUI Box picks up
|
|
1552
|
+
// toolErrorBg instead of toolSuccessBg for the background.
|
|
1553
|
+
// Cast to any since AgentToolResult doesn't expose isError but
|
|
1554
|
+
// the TUI runtime checks for it when selecting the background color.
|
|
1555
|
+
if (exitCode !== null && exitCode !== 0) {
|
|
1556
|
+
(result as any).isError = true;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1272
1559
|
return result;
|
|
1273
|
-
},
|
|
1560
|
+
}),
|
|
1274
1561
|
|
|
1275
|
-
renderCall(args: BashParams, theme: ThemeLike, ctx: RenderContextLike) {
|
|
1562
|
+
renderCall(args: BashParams, theme: ThemeLike, ctx: RenderContextLike<BashRenderState>) {
|
|
1276
1563
|
resolveBaseBackground(theme);
|
|
1277
1564
|
const cmd = args.command ?? "";
|
|
1278
1565
|
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1279
1566
|
const timeout = args.timeout ? ` ${theme.fg("muted", `(${args.timeout}s timeout)`)}` : "";
|
|
1280
1567
|
const displayCmd = ctx.expanded || cmd.length <= 80 ? cmd : `${cmd.slice(0, 77)}…`;
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
),
|
|
1285
|
-
);
|
|
1568
|
+
const bg = ctx.isError ? BG_ERROR : undefined;
|
|
1569
|
+
const title = `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", displayCmd)}${timeout}`;
|
|
1570
|
+
text.setText(fillToolBackground(title, bg, termW()));
|
|
1286
1571
|
return text;
|
|
1287
1572
|
},
|
|
1288
1573
|
|
|
1289
|
-
renderResult(result: ToolResultLike, _opt: unknown, theme: ThemeLike, ctx: RenderContextLike) {
|
|
1574
|
+
renderResult(result: ToolResultLike, _opt: unknown, theme: ThemeLike, ctx: RenderContextLike<BashRenderState>) {
|
|
1290
1575
|
resolveBaseBackground(theme);
|
|
1291
|
-
const text = ctx.lastComponent
|
|
1576
|
+
const text = getWidthAwareText(ctx.lastComponent);
|
|
1292
1577
|
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1578
|
+
const details = result.details as RenderDetails | undefined;
|
|
1579
|
+
const textContent = getTextContent(result);
|
|
1580
|
+
const d: Extract<RenderDetails, { _type: "bashResult" }> | undefined =
|
|
1581
|
+
details?._type === "bashResult"
|
|
1582
|
+
? details
|
|
1583
|
+
: textContent || ctx.isError
|
|
1584
|
+
? {
|
|
1585
|
+
_type: "bashResult",
|
|
1586
|
+
text: textContent || "Error",
|
|
1587
|
+
exitCode: inferBashExitCode(textContent, ctx.isError ? 1 : 0),
|
|
1588
|
+
command: "",
|
|
1589
|
+
}
|
|
1590
|
+
: undefined;
|
|
1299
1591
|
if (d?._type === "bashResult") {
|
|
1300
|
-
const
|
|
1301
|
-
const
|
|
1592
|
+
const isBashError = ctx.isError || (d.exitCode !== null && d.exitCode !== 0);
|
|
1593
|
+
const bg = isBashError ? BG_ERROR : undefined;
|
|
1594
|
+
const cleanedText = stripBashExitStatusLine(d.text);
|
|
1595
|
+
const outputText = isBashError ? compactErrorLines(cleanedText).join("\n") : cleanedText;
|
|
1596
|
+
const { summary } = renderBashOutput(outputText, d.exitCode);
|
|
1597
|
+
const lines = outputText.split("\n");
|
|
1302
1598
|
const lineCount = lines.length;
|
|
1303
|
-
const lineInfo = lineCount > 1 ? ` ${FG_DIM}(${lineCount} lines)${RST}` :
|
|
1599
|
+
const lineInfo = lineCount > 1 ? ` ${FG_DIM}(${lineCount} lines)${RST} ${renderToolMetrics(result)}` : ` ${renderToolMetrics(result)}`;
|
|
1304
1600
|
const header = ` ${summary}${lineInfo}`;
|
|
1305
|
-
|
|
1306
|
-
|
|
1601
|
+
const renderAtWidth = (width: number) => {
|
|
1602
|
+
if (!outputText.trim()) return fillToolBackground(header, bg, width);
|
|
1307
1603
|
const maxShow = ctx.expanded ? lineCount : MAX_PREVIEW_LINES;
|
|
1308
1604
|
const show = lines.slice(0, maxShow);
|
|
1309
|
-
const
|
|
1310
|
-
const out: string[] = [header, rule(tw)];
|
|
1605
|
+
const out: string[] = [header, rule(width)];
|
|
1311
1606
|
for (const line of show) {
|
|
1312
1607
|
out.push(` ${line}`);
|
|
1313
1608
|
}
|
|
1314
|
-
out.push(rule(
|
|
1609
|
+
out.push(rule(width));
|
|
1315
1610
|
if (lineCount > maxShow) {
|
|
1316
1611
|
out.push(`${FG_DIM} … ${lineCount - maxShow} more lines${RST}`);
|
|
1317
1612
|
}
|
|
1318
|
-
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
|
|
1613
|
+
return fillToolBackground(out.join("\n"), bg, width);
|
|
1614
|
+
};
|
|
1615
|
+
text.__piPrettyTask = {
|
|
1616
|
+
key: (width: number) => `bash:${ctx.expanded ? "1" : "0"}:${width}:${d.exitCode ?? "killed"}:${outputText.length}:${renderToolMetrics(result)}`,
|
|
1617
|
+
render: renderAtWidth,
|
|
1618
|
+
};
|
|
1619
|
+
text.setText(renderAtWidth(termW()));
|
|
1620
|
+
return text;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
text.__piPrettyTask = undefined;
|
|
1624
|
+
if (ctx.isError) {
|
|
1625
|
+
text.setText(renderToolError(getTextContent(result) || "Error", theme));
|
|
1322
1626
|
return text;
|
|
1323
1627
|
}
|
|
1324
1628
|
|
|
@@ -1341,13 +1645,13 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1341
1645
|
...origLs,
|
|
1342
1646
|
name: "ls",
|
|
1343
1647
|
|
|
1344
|
-
async
|
|
1648
|
+
execute: wrapExecuteWithMetrics(async (
|
|
1345
1649
|
tid: string,
|
|
1346
1650
|
params: LsParams,
|
|
1347
1651
|
sig: AbortSignal | undefined,
|
|
1348
1652
|
upd: AgentToolUpdateCallback<unknown> | undefined,
|
|
1349
1653
|
ctx: ExtensionContext,
|
|
1350
|
-
) {
|
|
1654
|
+
) => {
|
|
1351
1655
|
const result = (await origLs.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
|
|
1352
1656
|
const textContent = getTextContent(result);
|
|
1353
1657
|
const fp = params.path ?? cwd;
|
|
@@ -1361,13 +1665,14 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1361
1665
|
});
|
|
1362
1666
|
|
|
1363
1667
|
return result;
|
|
1364
|
-
},
|
|
1668
|
+
}),
|
|
1365
1669
|
|
|
1366
1670
|
renderCall(args: LsParams, theme: ThemeLike, ctx: RenderContextLike) {
|
|
1367
1671
|
resolveBaseBackground(theme);
|
|
1368
1672
|
const fp = args.path ?? ".";
|
|
1369
1673
|
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1370
|
-
|
|
1674
|
+
const bg = ctx.isError ? BG_ERROR : undefined;
|
|
1675
|
+
text.setText(fillToolBackground(`${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", sp(fp))}`, bg));
|
|
1371
1676
|
return text;
|
|
1372
1677
|
},
|
|
1373
1678
|
|
|
@@ -1383,7 +1688,7 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1383
1688
|
const d = result.details as RenderDetails | undefined;
|
|
1384
1689
|
if (d?._type === "lsResult" && d.text) {
|
|
1385
1690
|
const tree = renderTree(d.text, d.path);
|
|
1386
|
-
const info = `${FG_DIM}${d.entryCount} entries${RST}`;
|
|
1691
|
+
const info = `${FG_DIM}${d.entryCount} entries${RST}${renderToolMetrics(result)}`;
|
|
1387
1692
|
text.setText(fillToolBackground(` ${info}\n${tree}`));
|
|
1388
1693
|
return text;
|
|
1389
1694
|
}
|
|
@@ -1407,13 +1712,13 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1407
1712
|
...origFind,
|
|
1408
1713
|
name: "find",
|
|
1409
1714
|
|
|
1410
|
-
async
|
|
1715
|
+
execute: wrapExecuteWithMetrics(async (
|
|
1411
1716
|
tid: string,
|
|
1412
1717
|
params: FindParams,
|
|
1413
1718
|
sig: AbortSignal | undefined,
|
|
1414
1719
|
upd: unknown,
|
|
1415
1720
|
ctx: ExtensionContext,
|
|
1416
|
-
) {
|
|
1721
|
+
) => {
|
|
1417
1722
|
// Try FFF first (frecency-ranked, SIMD-accelerated)
|
|
1418
1723
|
if (_fffFinder && !_fffFinder.isDestroyed) {
|
|
1419
1724
|
try {
|
|
@@ -1456,15 +1761,16 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1456
1761
|
});
|
|
1457
1762
|
|
|
1458
1763
|
return result;
|
|
1459
|
-
},
|
|
1764
|
+
}),
|
|
1460
1765
|
|
|
1461
1766
|
renderCall(args: FindParams, theme: ThemeLike, ctx: RenderContextLike) {
|
|
1462
1767
|
resolveBaseBackground(theme);
|
|
1463
1768
|
const pattern = args.pattern ?? "";
|
|
1464
1769
|
const path = args.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
|
|
1465
1770
|
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1771
|
+
const bg = ctx.isError ? BG_ERROR : undefined;
|
|
1466
1772
|
text.setText(
|
|
1467
|
-
fillToolBackground(`${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", pattern)}${path}
|
|
1773
|
+
fillToolBackground(`${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", pattern)}${path}`, bg),
|
|
1468
1774
|
);
|
|
1469
1775
|
return text;
|
|
1470
1776
|
},
|
|
@@ -1486,7 +1792,7 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1486
1792
|
const d = result.details;
|
|
1487
1793
|
if (d?._type === "findResult" && d.text) {
|
|
1488
1794
|
const rendered = renderFindResults(d.text);
|
|
1489
|
-
const info = `${FG_DIM}${d.matchCount} files${RST}`;
|
|
1795
|
+
const info = `${FG_DIM}${d.matchCount} files${RST}${renderToolMetrics(result)}`;
|
|
1490
1796
|
text.setText(fillToolBackground(` ${info}\n${rendered}`));
|
|
1491
1797
|
return text;
|
|
1492
1798
|
}
|
|
@@ -1510,13 +1816,13 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1510
1816
|
...origGrep,
|
|
1511
1817
|
name: "grep",
|
|
1512
1818
|
|
|
1513
|
-
async
|
|
1819
|
+
execute: wrapExecuteWithMetrics(async (
|
|
1514
1820
|
tid: string,
|
|
1515
1821
|
params: GrepParams,
|
|
1516
1822
|
sig: AbortSignal | undefined,
|
|
1517
1823
|
upd: unknown,
|
|
1518
1824
|
ctx: ExtensionContext,
|
|
1519
|
-
) {
|
|
1825
|
+
) => {
|
|
1520
1826
|
// Try FFF first (SIMD-accelerated, frecency-ranked).
|
|
1521
1827
|
// FFF 0.5.2 can abort the process when path/glob constraints meet
|
|
1522
1828
|
// Unicode filenames, so constrained searches use the SDK fallback.
|
|
@@ -1576,7 +1882,7 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1576
1882
|
});
|
|
1577
1883
|
|
|
1578
1884
|
return result;
|
|
1579
|
-
},
|
|
1885
|
+
}),
|
|
1580
1886
|
|
|
1581
1887
|
renderCall(args: GrepParams, theme: ThemeLike, ctx: RenderContextLike) {
|
|
1582
1888
|
resolveBaseBackground(theme);
|
|
@@ -1584,9 +1890,11 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1584
1890
|
const path = args.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
|
|
1585
1891
|
const glob = args.glob ? ` ${theme.fg("muted", `(${args.glob})`)}` : "";
|
|
1586
1892
|
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1893
|
+
const bg = ctx.isError ? BG_ERROR : undefined;
|
|
1587
1894
|
text.setText(
|
|
1588
1895
|
fillToolBackground(
|
|
1589
1896
|
`${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", pattern)}${path}${glob}`,
|
|
1897
|
+
bg,
|
|
1590
1898
|
),
|
|
1591
1899
|
);
|
|
1592
1900
|
return text;
|
|
@@ -1608,21 +1916,23 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1608
1916
|
|
|
1609
1917
|
const d = result.details;
|
|
1610
1918
|
if (d?._type === "grepResult" && d.text) {
|
|
1611
|
-
const
|
|
1919
|
+
const renderWidth = termW();
|
|
1920
|
+
const key = `grep:${d.pattern}:${d.matchCount}:${renderWidth}`;
|
|
1612
1921
|
if (ctx.state._gk !== key) {
|
|
1613
1922
|
ctx.state._gk = key;
|
|
1614
|
-
const
|
|
1615
|
-
|
|
1923
|
+
const metrics = renderToolMetrics(result);
|
|
1924
|
+
const info = `${FG_DIM}${d.matchCount} matches${RST}${metrics}`;
|
|
1925
|
+
ctx.state._gt = fillToolBackground(` ${info}`, undefined, renderWidth);
|
|
1616
1926
|
|
|
1617
1927
|
renderGrepResults(d.text, d.pattern)
|
|
1618
1928
|
.then((rendered: string) => {
|
|
1619
1929
|
if (ctx.state._gk !== key) return;
|
|
1620
|
-
ctx.state._gt = fillToolBackground(` ${info}\n${rendered}
|
|
1930
|
+
ctx.state._gt = fillToolBackground(` ${info}\n${rendered}`, undefined, renderWidth);
|
|
1621
1931
|
ctx.invalidate();
|
|
1622
1932
|
})
|
|
1623
1933
|
.catch(() => {});
|
|
1624
1934
|
}
|
|
1625
|
-
text.setText(ctx.state._gt ?? fillToolBackground(` ${FG_DIM}${d.matchCount} matches${RST}
|
|
1935
|
+
text.setText(ctx.state._gt ?? fillToolBackground(` ${FG_DIM}${d.matchCount} matches${RST}${renderToolMetrics(result)}`, undefined, renderWidth));
|
|
1626
1936
|
return text;
|
|
1627
1937
|
}
|
|
1628
1938
|
|
|
@@ -1689,13 +1999,13 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1689
1999
|
required: ["patterns"],
|
|
1690
2000
|
},
|
|
1691
2001
|
|
|
1692
|
-
async
|
|
2002
|
+
execute: wrapExecuteWithMetrics(async (
|
|
1693
2003
|
tid: string,
|
|
1694
2004
|
params: MultiGrepParams,
|
|
1695
2005
|
sig: AbortSignal | undefined,
|
|
1696
2006
|
upd: unknown,
|
|
1697
2007
|
ctx: ExtensionContext,
|
|
1698
|
-
) {
|
|
2008
|
+
) => {
|
|
1699
2009
|
if (sig?.aborted) return makeTextResult("Aborted", {});
|
|
1700
2010
|
|
|
1701
2011
|
if (!params.patterns || params.patterns.length === 0) {
|
|
@@ -1813,7 +2123,7 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1813
2123
|
const message = getErrorMessage(error);
|
|
1814
2124
|
return makeTextResult(`multi_grep error: ${message}`, { error: message });
|
|
1815
2125
|
}
|
|
1816
|
-
},
|
|
2126
|
+
}),
|
|
1817
2127
|
|
|
1818
2128
|
renderCall(args: MultiGrepParams, theme: ThemeLike, ctx: RenderContextLike) {
|
|
1819
2129
|
resolveBaseBackground(theme);
|
|
@@ -1821,13 +2131,14 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1821
2131
|
const path = args.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
|
|
1822
2132
|
const constraints = args.constraints;
|
|
1823
2133
|
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
2134
|
+
const bg = ctx.isError ? BG_ERROR : undefined;
|
|
1824
2135
|
let content =
|
|
1825
2136
|
theme.fg("toolTitle", theme.bold("multi_grep")) +
|
|
1826
2137
|
" " +
|
|
1827
2138
|
theme.fg("accent", patterns.map((p) => `"${p}"`).join(", "));
|
|
1828
2139
|
content += path;
|
|
1829
2140
|
if (constraints) content += theme.fg("muted", ` (${constraints})`);
|
|
1830
|
-
text.setText(fillToolBackground(content));
|
|
2141
|
+
text.setText(fillToolBackground(content, bg));
|
|
1831
2142
|
return text;
|
|
1832
2143
|
},
|
|
1833
2144
|
|
|
@@ -1841,7 +2152,7 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1841
2152
|
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
1842
2153
|
|
|
1843
2154
|
if (ctx.isError) {
|
|
1844
|
-
text.setText(
|
|
2155
|
+
text.setText(renderToolError(getTextContent(result) || "Error", theme));
|
|
1845
2156
|
return text;
|
|
1846
2157
|
}
|
|
1847
2158
|
|
|
@@ -1850,7 +2161,8 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1850
2161
|
const key = `mgrep:${d.pattern}:${d.matchCount}:${termW()}`;
|
|
1851
2162
|
if (ctx.state._mgk !== key) {
|
|
1852
2163
|
ctx.state._mgk = key;
|
|
1853
|
-
const
|
|
2164
|
+
const metrics = renderToolMetrics(result);
|
|
2165
|
+
const info = `${FG_DIM}${d.matchCount} matches${RST}${metrics}`;
|
|
1854
2166
|
ctx.state._mgt = ` ${info}`;
|
|
1855
2167
|
|
|
1856
2168
|
renderGrepResults(d.text, d.pattern)
|
|
@@ -1861,7 +2173,7 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
|
|
|
1861
2173
|
})
|
|
1862
2174
|
.catch(() => {});
|
|
1863
2175
|
}
|
|
1864
|
-
text.setText(ctx.state._mgt ?? ` ${FG_DIM}${d.matchCount} matches${RST}`);
|
|
2176
|
+
text.setText(ctx.state._mgt ?? ` ${FG_DIM}${d.matchCount} matches${RST}${renderToolMetrics(result)}`);
|
|
1865
2177
|
return text;
|
|
1866
2178
|
}
|
|
1867
2179
|
|
|
@@ -12,6 +12,9 @@ class MockText {
|
|
|
12
12
|
getText() {
|
|
13
13
|
return this.text;
|
|
14
14
|
}
|
|
15
|
+
render(_width: number) {
|
|
16
|
+
return this.text.split("\n");
|
|
17
|
+
}
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
const mockTheme = {
|
|
@@ -33,6 +36,10 @@ function mockToolFactory(exec: any) {
|
|
|
33
36
|
});
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
function stripAnsi(text: string): string {
|
|
40
|
+
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
function withStdoutColumns<T>(columns: number, fn: () => T): T {
|
|
37
44
|
const descriptor = Object.getOwnPropertyDescriptor(process.stdout, "columns");
|
|
38
45
|
Object.defineProperty(process.stdout, "columns", { configurable: true, value: columns });
|
|
@@ -127,7 +134,7 @@ describe("bash renderCall expansion", () => {
|
|
|
127
134
|
expect(expanded.getText()).toContain("5s timeout");
|
|
128
135
|
});
|
|
129
136
|
|
|
130
|
-
it("truncates
|
|
137
|
+
it("truncates ANSI tool headers that exceed the terminal width", () => {
|
|
131
138
|
withStdoutColumns(84, () => {
|
|
132
139
|
const bashTool = loadBashTool();
|
|
133
140
|
const command = `printf '${"界".repeat(120)}'`;
|
|
@@ -141,7 +148,7 @@ describe("bash renderCall expansion", () => {
|
|
|
141
148
|
});
|
|
142
149
|
|
|
143
150
|
for (const line of rendered.getText().split("\n")) {
|
|
144
|
-
expect(visibleWidth(line)).toBeLessThanOrEqual(
|
|
151
|
+
expect(visibleWidth(line)).toBeLessThanOrEqual(84);
|
|
145
152
|
}
|
|
146
153
|
});
|
|
147
154
|
});
|
|
@@ -160,7 +167,103 @@ describe("bash renderCall expansion", () => {
|
|
|
160
167
|
});
|
|
161
168
|
|
|
162
169
|
for (const line of rendered.getText().split("\n")) {
|
|
163
|
-
expect(visibleWidth(line)).toBeLessThanOrEqual(
|
|
170
|
+
expect(visibleWidth(line)).toBeLessThanOrEqual(24);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("does not add extra internal padding to the bash title in error state", () => {
|
|
176
|
+
withStdoutColumns(48, () => {
|
|
177
|
+
const bashTool = loadBashTool();
|
|
178
|
+
const rendered = bashTool.renderCall({ command: "false" }, mockTheme, {
|
|
179
|
+
lastComponent: new MockText(),
|
|
180
|
+
isError: true,
|
|
181
|
+
state: {},
|
|
182
|
+
expanded: false,
|
|
183
|
+
invalidate: () => {},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const lines = stripAnsi(rendered.getText()).split("\n");
|
|
187
|
+
expect(lines[0]).toMatch(/^bash false/);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("pads every line of multi-line tool errors", () => {
|
|
192
|
+
withStdoutColumns(48, () => {
|
|
193
|
+
const bashTool = loadBashTool();
|
|
194
|
+
const rendered = bashTool.renderResult(
|
|
195
|
+
{ content: [{ type: "text", text: "\nfirst error\n\n\nsecond error\n" }] },
|
|
196
|
+
{},
|
|
197
|
+
ansiMockTheme,
|
|
198
|
+
{
|
|
199
|
+
lastComponent: new MockText(),
|
|
200
|
+
isError: true,
|
|
201
|
+
state: {},
|
|
202
|
+
expanded: false,
|
|
203
|
+
invalidate: () => {},
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const lines = stripAnsi(rendered.getText()).split("\n");
|
|
208
|
+
expect(lines[0]).toContain("✗ exit 1");
|
|
209
|
+
expect(lines[1]).toMatch(/^─+$/);
|
|
210
|
+
expect(lines[2]).toMatch(/^ first error/);
|
|
211
|
+
expect(lines[3]).toMatch(/^ /);
|
|
212
|
+
expect(lines[3].trim()).toBe("");
|
|
213
|
+
expect(lines[4]).toMatch(/^ second error/);
|
|
214
|
+
expect(lines[5]).toMatch(/^─+$/);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("does not emit internal ANSI background padding or resets for bash results", () => {
|
|
219
|
+
withStdoutColumns(64, () => {
|
|
220
|
+
const bashTool = loadBashTool();
|
|
221
|
+
const rendered = bashTool.renderResult(
|
|
222
|
+
{
|
|
223
|
+
content: [{ type: "text", text: "output" }],
|
|
224
|
+
details: { _type: "bashResult", text: "output", exitCode: 1, command: "test" },
|
|
225
|
+
},
|
|
226
|
+
{},
|
|
227
|
+
ansiMockTheme,
|
|
228
|
+
{
|
|
229
|
+
lastComponent: new MockText(),
|
|
230
|
+
isError: true,
|
|
231
|
+
state: { _tw: "64" },
|
|
232
|
+
expanded: false,
|
|
233
|
+
invalidate: () => {},
|
|
234
|
+
},
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
expect(rendered.getText()).not.toMatch(/\x1b\[48;/);
|
|
238
|
+
expect(rendered.getText()).not.toContain("\x1b[0m");
|
|
239
|
+
expect(rendered.getText()).not.toContain("\x1b[49m");
|
|
240
|
+
for (const line of rendered.getText().split("\n")) {
|
|
241
|
+
expect(visibleWidth(line)).toBeLessThanOrEqual(64);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("renders bash results using the component render width instead of stdout columns", () => {
|
|
247
|
+
withStdoutColumns(120, () => {
|
|
248
|
+
const bashTool = loadBashTool();
|
|
249
|
+
const rendered = bashTool.renderResult(
|
|
250
|
+
{ content: [{ type: "text", text: "hello world" }], details: { _type: "bashResult", text: "hello world", exitCode: 0, command: "echo hi" } },
|
|
251
|
+
{},
|
|
252
|
+
mockTheme,
|
|
253
|
+
{
|
|
254
|
+
lastComponent: new MockText(),
|
|
255
|
+
isError: false,
|
|
256
|
+
state: {},
|
|
257
|
+
expanded: false,
|
|
258
|
+
invalidate: () => {},
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
rendered.render(80);
|
|
263
|
+
const lines = stripAnsi(rendered.getText()).split("\n");
|
|
264
|
+
expect(lines.some((line) => /^─{80}$/.test(line))).toBe(true);
|
|
265
|
+
for (const line of rendered.getText().split("\n")) {
|
|
266
|
+
expect(visibleWidth(line)).toBeLessThanOrEqual(80);
|
|
164
267
|
}
|
|
165
268
|
});
|
|
166
269
|
});
|