@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 +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 +243 -62
- package/test/bash-rendering.test.ts +104 -1
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,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
|
-
|
|
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
|
-
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
),
|
|
1305
|
-
|
|
1306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
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
|
|
1576
|
+
const text = getWidthAwareText(ctx.lastComponent);
|
|
1420
1577
|
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
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
|
|
1429
|
-
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");
|
|
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
|
-
|
|
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
|
|
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(
|
|
1609
|
+
out.push(rule(width));
|
|
1443
1610
|
if (lineCount > maxShow) {
|
|
1444
1611
|
out.push(`${FG_DIM} … ${lineCount - maxShow} more lines${RST}`);
|
|
1445
1612
|
}
|
|
1446
|
-
|
|
1447
|
-
}
|
|
1448
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
});
|