@heyhuynhgiabuu/pi-pretty 0.5.2 → 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.2",
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,22 +279,37 @@ 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, width?: number): string {
189
- if (width === undefined) 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
  }
@@ -645,6 +761,13 @@ async function renderFileContent(
645
761
  return out.join("\n");
646
762
  }
647
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
+
648
771
  /** Render bash output with colored exit code and stderr highlighting. */
649
772
  function renderBashOutput(text: string, exitCode: number | null): { summary: string; body: string } {
650
773
  const isOk = exitCode === 0;
@@ -870,8 +993,17 @@ type ToolTextContent = TextContent;
870
993
  type ToolImageContent = ImageContent;
871
994
  type ToolContent = TextContent | ImageContent;
872
995
  type ToolResultLike<TDetails = unknown> = AgentToolResult<TDetails | undefined>;
873
- type TextComponentLike = { setText(value: string): void; getText?: () => string };
996
+ type TextComponentLike = { setText(value: string): void; getText?: () => string; render?: (width: number) => string[] };
874
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
+ };
875
1007
  type ThemeLike = BgTheme & FgTheme & { bold: (text: string) => string };
876
1008
  type RenderContextLike<TState extends Record<string, string | undefined> = Record<string, string | undefined>> = {
877
1009
  lastComponent?: TextComponentLike;
@@ -934,6 +1066,7 @@ type MultiGrepParams = {
934
1066
  context?: number;
935
1067
  limit?: number;
936
1068
  };
1069
+ type BashRenderState = Record<string, string | undefined>;
937
1070
  type GrepRenderState = { _gk?: string; _gt?: string };
938
1071
  type MultiGrepRenderState = { _mgk?: string; _mgt?: string };
939
1072
  type FindResultDetails = { _type: "findResult"; text: string; pattern: string; matchCount: number };
@@ -1125,6 +1258,28 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1125
1258
  return !disabledTools.has(name.toLowerCase());
1126
1259
  }
1127
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
+
1128
1283
  // ===================================================================
1129
1284
  // Generic renderResult for custom tools (no custom renderer)
1130
1285
  // ===================================================================
@@ -1163,8 +1318,9 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1163
1318
  tool.renderCall = (args: any, theme: ThemeLike, ctx: RenderContextLike) => {
1164
1319
  resolveBaseBackground(theme);
1165
1320
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1321
+ const bg = ctx.isError ? BG_ERROR : undefined;
1166
1322
  text.setText(
1167
- fillToolBackground(`${theme.fg("toolTitle", theme.bold(toolName))}`),
1323
+ fillToolBackground(`${theme.fg("toolTitle", theme.bold(toolName))}`, bg),
1168
1324
  );
1169
1325
  return text;
1170
1326
  };
@@ -1298,12 +1454,14 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1298
1454
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1299
1455
  const offset = args.offset ? ` ${theme.fg("muted", `from line ${args.offset}`)}` : "";
1300
1456
  const limit = args.limit ? ` ${theme.fg("muted", `(${args.limit} lines)`)}` : "";
1301
- text.setText(
1302
- fillToolBackground(
1303
- `${theme.fg("toolTitle", theme.bold("read"))} ${theme.fg("accent", sp(fp))}${offset}${limit}`,
1304
- ),
1305
- );
1306
- 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;
1307
1465
  },
1308
1466
 
1309
1467
  renderResult(result: ToolResultLike, _opt: unknown, theme: ThemeLike, ctx: RenderContextLike) {
@@ -1381,14 +1539,7 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1381
1539
  const result = (await origBash.execute(tid, params, sig, upd, ctx)) as ToolResultLike;
1382
1540
  const textContent = getTextContent(result);
1383
1541
 
1384
- let exitCode: number | null = 0;
1385
- if (textContent) {
1386
- const exitMatch = textContent.match(/(?:exit code|exited with|exit status)[:\s]*(\d+)/i);
1387
- if (exitMatch) exitCode = Number(exitMatch[1]);
1388
- if (textContent.includes("command not found") || textContent.includes("No such file")) {
1389
- exitCode = 1;
1390
- }
1391
- }
1542
+ const exitCode = textContent ? inferBashExitCode(textContent, 0) : 0;
1392
1543
 
1393
1544
  setResultDetails(result, {
1394
1545
  _type: "bashResult",
@@ -1397,56 +1548,81 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1397
1548
  command: params.command ?? "",
1398
1549
  });
1399
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
+
1400
1559
  return result;
1401
1560
  }),
1402
1561
 
1403
- renderCall(args: BashParams, theme: ThemeLike, ctx: RenderContextLike) {
1562
+ renderCall(args: BashParams, theme: ThemeLike, ctx: RenderContextLike<BashRenderState>) {
1404
1563
  resolveBaseBackground(theme);
1405
1564
  const cmd = args.command ?? "";
1406
1565
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1407
1566
  const timeout = args.timeout ? ` ${theme.fg("muted", `(${args.timeout}s timeout)`)}` : "";
1408
1567
  const displayCmd = ctx.expanded || cmd.length <= 80 ? cmd : `${cmd.slice(0, 77)}…`;
1409
- text.setText(
1410
- fillToolBackground(
1411
- `${theme.fg("toolTitle", theme.bold("bash"))} ${theme.fg("accent", displayCmd)}${timeout}`,
1412
- ),
1413
- );
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()));
1414
1571
  return text;
1415
1572
  },
1416
1573
 
1417
- renderResult(result: ToolResultLike, _opt: unknown, theme: ThemeLike, ctx: RenderContextLike) {
1574
+ renderResult(result: ToolResultLike, _opt: unknown, theme: ThemeLike, ctx: RenderContextLike<BashRenderState>) {
1418
1575
  resolveBaseBackground(theme);
1419
- const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1576
+ const text = getWidthAwareText(ctx.lastComponent);
1420
1577
 
1421
- if (ctx.isError) {
1422
- text.setText(renderToolError(getTextContent(result) || "Error", theme));
1423
- return text;
1424
- }
1425
-
1426
- 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;
1427
1591
  if (d?._type === "bashResult") {
1428
- const { summary } = renderBashOutput(d.text, d.exitCode);
1429
- 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");
1430
1598
  const lineCount = lines.length;
1431
1599
  const lineInfo = lineCount > 1 ? ` ${FG_DIM}(${lineCount} lines)${RST} ${renderToolMetrics(result)}` : ` ${renderToolMetrics(result)}`;
1432
1600
  const header = ` ${summary}${lineInfo}`;
1433
-
1434
- if (d.text.trim()) {
1601
+ const renderAtWidth = (width: number) => {
1602
+ if (!outputText.trim()) return fillToolBackground(header, bg, width);
1435
1603
  const maxShow = ctx.expanded ? lineCount : MAX_PREVIEW_LINES;
1436
1604
  const show = lines.slice(0, maxShow);
1437
- const tw = termW();
1438
- const out: string[] = [header, rule(tw)];
1605
+ const out: string[] = [header, rule(width)];
1439
1606
  for (const line of show) {
1440
1607
  out.push(` ${line}`);
1441
1608
  }
1442
- out.push(rule(tw));
1609
+ out.push(rule(width));
1443
1610
  if (lineCount > maxShow) {
1444
1611
  out.push(`${FG_DIM} … ${lineCount - maxShow} more lines${RST}`);
1445
1612
  }
1446
- text.setText(fillToolBackground(out.join("\n")));
1447
- } else {
1448
- text.setText(fillToolBackground(header));
1449
- }
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));
1450
1626
  return text;
1451
1627
  }
1452
1628
 
@@ -1495,7 +1671,8 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1495
1671
  resolveBaseBackground(theme);
1496
1672
  const fp = args.path ?? ".";
1497
1673
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1498
- 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));
1499
1676
  return text;
1500
1677
  },
1501
1678
 
@@ -1591,8 +1768,9 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1591
1768
  const pattern = args.pattern ?? "";
1592
1769
  const path = args.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
1593
1770
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1771
+ const bg = ctx.isError ? BG_ERROR : undefined;
1594
1772
  text.setText(
1595
- 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),
1596
1774
  );
1597
1775
  return text;
1598
1776
  },
@@ -1712,9 +1890,11 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1712
1890
  const path = args.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
1713
1891
  const glob = args.glob ? ` ${theme.fg("muted", `(${args.glob})`)}` : "";
1714
1892
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1893
+ const bg = ctx.isError ? BG_ERROR : undefined;
1715
1894
  text.setText(
1716
1895
  fillToolBackground(
1717
1896
  `${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", pattern)}${path}${glob}`,
1897
+ bg,
1718
1898
  ),
1719
1899
  );
1720
1900
  return text;
@@ -1951,13 +2131,14 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1951
2131
  const path = args.path ? ` ${theme.fg("muted", `in ${sp(args.path)}`)}` : "";
1952
2132
  const constraints = args.constraints;
1953
2133
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
2134
+ const bg = ctx.isError ? BG_ERROR : undefined;
1954
2135
  let content =
1955
2136
  theme.fg("toolTitle", theme.bold("multi_grep")) +
1956
2137
  " " +
1957
2138
  theme.fg("accent", patterns.map((p) => `"${p}"`).join(", "));
1958
2139
  content += path;
1959
2140
  if (constraints) content += theme.fg("muted", ` (${constraints})`);
1960
- text.setText(fillToolBackground(content));
2141
+ text.setText(fillToolBackground(content, bg));
1961
2142
  return text;
1962
2143
  },
1963
2144
 
@@ -1971,7 +2152,7 @@ export default function piPrettyExtension(pi: PiPrettyApi, deps?: PiPrettyDeps):
1971
2152
  const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
1972
2153
 
1973
2154
  if (ctx.isError) {
1974
- text.setText(`\n${theme.fg("error", getTextContent(result) || "Error")}`);
2155
+ text.setText(renderToolError(getTextContent(result) || "Error", theme));
1975
2156
  return text;
1976
2157
  }
1977
2158
 
@@ -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)}'`;
@@ -164,4 +171,100 @@ describe("bash renderCall expansion", () => {
164
171
  }
165
172
  });
166
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);
267
+ }
268
+ });
269
+ });
167
270
  });