@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 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.1",
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,6 @@
1
+ {
2
+ "background": {
3
+ "tool": "#1e1e2e",
4
+ "error": "#2a1e1e"
5
+ }
6
+ }
@@ -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, visibleWidth } from "@earendil-works/pi-tui";
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 = `\x1b[0m${BG_BASE}`;
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
- return fillToolBackground(`\n${theme.fg("error", error)}`, BG_ERROR);
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
- function preserveToolBackground(ansi: string, bg: string): string {
182
- return ansi.replace(ANSI_CAPTURE_RE, (seq, params: string) => {
183
- const codes = params.split(";");
184
- return params === "0" || codes.includes("49") ? `${seq}${bg}` : seq;
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, bg = BG_BASE): 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
- const normalized = preserveToolBackground(line, bg);
194
- const fitted = preserveToolBackground(truncateToWidth(normalized, width, ""), bg);
195
- const padding = Math.max(0, width - visibleWidth(fitted));
196
- return `${bg}${fitted}${" ".repeat(padding)}${RST}`;
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
- const stderrWithColumns = process.stderr as NodeJS.WriteStream & { columns?: number };
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.stdout.columns || stderrWithColumns.columns || Number.parseInt(process.env.COLUMNS ?? "", 10) || 200;
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 execute(
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
- text.setText(
1176
- fillToolBackground(
1177
- `${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", sp(fp))}${offset}${limit}`,
1178
- ),
1179
- );
1180
- return text;
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 key = `read:${d.filePath}:${d.offset}:${d.lineCount}:${termW()}`;
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 info = `${FG_DIM}${d.lineCount} lines${RST}`;
1211
- ctx.state._rt = fillToolBackground(` ${info}`);
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 execute(
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
- let exitCode: number | null = 0;
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
- text.setText(
1282
- fillToolBackground(
1283
- `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", displayCmd)}${timeout}`,
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 ?? new TextComponent("", 0, 0);
1576
+ const text = getWidthAwareText(ctx.lastComponent);
1292
1577
 
1293
- if (ctx.isError) {
1294
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
1295
- return text;
1296
- }
1297
-
1298
- const d = result.details as RenderDetails | undefined;
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 { summary } = renderBashOutput(d.text, d.exitCode);
1301
- const lines = d.text.split("\n");
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
- if (d.text.trim()) {
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 tw = termW();
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(tw));
1609
+ out.push(rule(width));
1315
1610
  if (lineCount > maxShow) {
1316
1611
  out.push(`${FG_DIM} … ${lineCount - maxShow} more lines${RST}`);
1317
1612
  }
1318
- text.setText(fillToolBackground(out.join("\n")));
1319
- } else {
1320
- text.setText(fillToolBackground(header));
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 execute(
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
- text.setText(fillToolBackground(`${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", sp(fp))}`));
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 execute(
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 execute(
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 key = `grep:${d.pattern}:${d.matchCount}:${termW()}`;
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 info = `${FG_DIM}${d.matchCount} matches${RST}`;
1615
- ctx.state._gt = fillToolBackground(` ${info}`);
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 execute(
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(`\n${theme.fg("error", getTextContent(result) || "Error")}`);
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 info = `${FG_DIM}${d.matchCount} matches${RST}`;
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 expanded ANSI tool headers to the terminal width before padding backgrounds", () => {
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(80);
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(20);
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
  });