@diegopetrucci/pi-extensions 0.1.16 → 0.1.17

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
@@ -2,13 +2,13 @@
2
2
 
3
3
  A collection of [pi](https://github.com/earendil-works/pi-mono) agent extensions I made:
4
4
 
5
+ - [`quiet-tools`](./extensions/quiet-tools): Renders collapsed built-in tool rows as quiet one-line previews without changing model-visible tool results; toggle temporarily with `/quiet-tools`.
6
+ - [`confirm-destructive`](./extensions/confirm-destructive): Confirms before destructive session actions like clear, switch, and fork.
7
+ - [`context-cap`](./extensions/context-cap): Caps effective model context windows at 200k tokens by default so pi avoids the `dumb zone`; toggle temporarily with `/context-cap`.
5
8
  - [`minimal-footer`](./extensions/minimal-footer): Replaces pi's built-in footer with a minimal configurable two-line layout: branch/repo on the first line, context/model on the second, optional `DUMB ZONE`, plus OpenAI Codex 5-hour and 7-day usage when available.
9
+ - [`notify`](./extensions/notify): Sends configurable terminal, desktop, bell, and sound notifications when pi finishes and is ready for input.
6
10
  - [`oracle`](./extensions/oracle): Adds an Amp-style read-only oracle tool that auto-selects the strongest reasoning model on the current provider/subscription, covers pi’s built-in providers with hardcoded rankings, sets reasoning to xhigh by default, and shows live status while running.
7
- - [`context-cap`](./extensions/context-cap): Caps effective model context windows at 200k tokens by default so pi avoids the `dumb zone`; toggle temporarily with `/context-cap`.
8
- - [`compact-bash`](./extensions/compact-bash): Renders collapsed assistant `bash` tool output as a one-line preview; toggle temporarily with `/compact-bash`.
9
11
  - [`permission-gate`](./extensions/permission-gate): Prompts for confirmation before dangerous bash commands like `rm -rf`, `sudo`, and `chmod 777`.
10
- - [`confirm-destructive`](./extensions/confirm-destructive): Confirms before destructive session actions like clear, switch, and fork.
11
- - [`notify`](./extensions/notify): Sends configurable terminal, desktop, bell, and sound notifications when pi finishes and is ready for input.
12
12
 
13
13
  (For the full list of pi extensions I use, [check out my dotfiles](https://github.com/diegopetrucci/dot/blob/main/.pi/agent/settings.json).)
14
14
 
@@ -23,13 +23,13 @@ pi install npm:@diegopetrucci/pi-extensions
23
23
  Or pin the GitHub package to this release:
24
24
 
25
25
  ```bash
26
- pi install git:github.com/diegopetrucci/pi-extensions@v0.1.16
26
+ pi install git:github.com/diegopetrucci/pi-extensions@v0.1.17
27
27
  ```
28
28
 
29
29
  Or a specific extension:
30
30
 
31
31
  ```bash
32
- pi install npm:@diegopetrucci/pi-oracle
32
+ pi install npm:@diegopetrucci/pi-quiet-tools
33
33
  ```
34
34
 
35
35
  Then reload pi:
@@ -0,0 +1,63 @@
1
+ # quiet-tools
2
+
3
+ A pi extension that makes collapsed built-in tool rows much quieter in the TUI.
4
+
5
+ When enabled, collapsed tool output renders as one output line plus an inline hidden-line count and `Ctrl+O` expand hint. Expanding with `Ctrl+O` still shows pi's full rendered output.
6
+
7
+ `quiet-tools` only changes the visual renderer. It does not truncate, summarize, or rewrite the actual tool results sent to the model.
8
+
9
+ ## Covered tools
10
+
11
+ - `bash`
12
+ - `read`
13
+ - `grep`
14
+ - `find`
15
+ - `ls`
16
+ - `edit`
17
+ - `write`
18
+
19
+ For `edit` and `write`, the collapsed call preview is also shortened so large diffs or file contents do not fill the TUI. Expanding restores pi's normal renderer.
20
+
21
+ ## Commands
22
+
23
+ ```text
24
+ /quiet-tools status
25
+ /quiet-tools off
26
+ /quiet-tools on
27
+ /quiet-tools toggle
28
+ ```
29
+
30
+ The extension starts enabled by default. Disabling is temporary for the current extension runtime/session; after `/reload`, `/new`, `/resume`, or `/fork`, it starts enabled again.
31
+
32
+ ## Install
33
+
34
+ ### Standalone npm package
35
+
36
+ ```bash
37
+ pi install npm:@diegopetrucci/pi-quiet-tools
38
+ ```
39
+
40
+ ### Collection package
41
+
42
+ ```bash
43
+ pi install npm:@diegopetrucci/pi-extensions
44
+ ```
45
+
46
+ ### GitHub package
47
+
48
+ ```bash
49
+ pi install git:github.com/diegopetrucci/pi-extensions
50
+ ```
51
+
52
+ Then reload pi:
53
+
54
+ ```text
55
+ /reload
56
+ ```
57
+
58
+ ## Notes
59
+
60
+ - This extension overrides pi's built-in tool definitions so it can customize only their TUI renderers.
61
+ - It reuses pi's built-in implementations and preserves `shellPath`, `shellCommandPrefix`, and image autoresize settings when they are available from settings files.
62
+ - If another extension also overrides built-in tool execution, pi's extension load order determines which override wins.
63
+ - It affects assistant-invoked tool rows. User `!`/`!!` bash commands are rendered by a separate pi component and keep pi's default preview behavior.
@@ -0,0 +1,384 @@
1
+ import {
2
+ createBashToolDefinition,
3
+ createEditToolDefinition,
4
+ createFindToolDefinition,
5
+ createGrepToolDefinition,
6
+ createLsToolDefinition,
7
+ createReadToolDefinition,
8
+ createWriteToolDefinition,
9
+ DEFAULT_MAX_BYTES,
10
+ DEFAULT_MAX_LINES,
11
+ formatSize,
12
+ keyHint,
13
+ SettingsManager,
14
+ type ExtensionAPI,
15
+ type ToolDefinition as PiToolDefinition,
16
+ type ToolsOptions,
17
+ } from "@earendil-works/pi-coding-agent";
18
+ import { Box, Container, Text, truncateToWidth } from "@earendil-works/pi-tui";
19
+
20
+ const COLLAPSED_PREVIEW_LINES = 1;
21
+ const QUIET_CALL_TOOL_NAMES = new Set(["edit", "write"]);
22
+
23
+ type ToolDefinition = PiToolDefinition<any, any, any>;
24
+ type ToolRenderCall = NonNullable<ToolDefinition["renderCall"]>;
25
+ type ToolRenderResult = NonNullable<ToolDefinition["renderResult"]>;
26
+ type ToolRenderCallParams = Parameters<ToolRenderCall>;
27
+ type ToolRenderResultParams = Parameters<ToolRenderResult>;
28
+ type RenderTheme = ToolRenderResultParams[2];
29
+ type ToolRenderContext = ToolRenderResultParams[3];
30
+
31
+ type TimerRenderState = {
32
+ startedAt?: number;
33
+ endedAt?: number;
34
+ interval?: ReturnType<typeof setInterval>;
35
+ };
36
+
37
+ type TruncationLike = {
38
+ truncated?: boolean;
39
+ truncatedBy?: "lines" | "bytes";
40
+ firstLineExceedsLimit?: boolean;
41
+ outputLines?: number;
42
+ totalLines?: number;
43
+ maxBytes?: number;
44
+ maxLines?: number;
45
+ };
46
+
47
+ class QuietResultRenderComponent extends Container {}
48
+
49
+ function sanitizePreviewText(text: string): string {
50
+ return text
51
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
52
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
53
+ .replace(/[\x00-\x08\x0B-\x1F\x7F]/g, "");
54
+ }
55
+
56
+ function getTextOutput(result: ToolRenderResultParams[0]): string {
57
+ return sanitizePreviewText(
58
+ result.content
59
+ .filter((content) => content.type === "text")
60
+ .map((content) => content.text ?? "")
61
+ .join("\n")
62
+ .trim(),
63
+ );
64
+ }
65
+
66
+ function visibleLength(text: string): number {
67
+ return text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").length;
68
+ }
69
+
70
+ function plural(count: number, singular: string): string {
71
+ return `${count} ${singular}${count === 1 ? "" : "s"}`;
72
+ }
73
+
74
+ function formatDuration(ms: number): string {
75
+ return `${(ms / 1000).toFixed(1)}s`;
76
+ }
77
+
78
+ function renderCollapsedLine(line: string, hiddenLines: number, theme: RenderTheme, width: number): string {
79
+ if (width <= 0) return "";
80
+
81
+ const styledLine = theme.fg("toolOutput", line);
82
+ if (hiddenLines <= 0) {
83
+ return truncateToWidth(styledLine, width, "...");
84
+ }
85
+
86
+ const hint = theme.fg(
87
+ "muted",
88
+ ` ... ${plural(hiddenLines, "more line")} (${keyHint("app.tools.expand", "to expand")})`,
89
+ );
90
+ const hintWidth = visibleLength(hint);
91
+ if (hintWidth + 8 > width) {
92
+ return truncateToWidth(`${styledLine}${hint}`, width, "...");
93
+ }
94
+
95
+ return `${truncateToWidth(styledLine, width - hintWidth, "...")}${hint}`;
96
+ }
97
+
98
+ function formatLimitWarning(label: string, count: unknown): string | undefined {
99
+ return typeof count === "number" ? `${count} ${label} limit` : undefined;
100
+ }
101
+
102
+ function getTruncationWarnings(result: ToolRenderResultParams[0]): string[] {
103
+ const details = result.details as
104
+ | {
105
+ truncation?: TruncationLike;
106
+ fullOutputPath?: string;
107
+ matchLimitReached?: number;
108
+ resultLimitReached?: number;
109
+ entryLimitReached?: number;
110
+ linesTruncated?: boolean;
111
+ }
112
+ | undefined;
113
+ const truncation = details?.truncation;
114
+ const warnings: string[] = [];
115
+
116
+ if (details?.fullOutputPath) {
117
+ warnings.push(`Full output: ${details.fullOutputPath}`);
118
+ }
119
+
120
+ if (truncation?.truncated) {
121
+ if (truncation.firstLineExceedsLimit) {
122
+ warnings.push(`First line exceeds ${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
123
+ } else if (truncation.truncatedBy === "lines") {
124
+ warnings.push(
125
+ `Truncated: showing ${truncation.outputLines ?? "some"} of ${truncation.totalLines ?? "?"} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)`,
126
+ );
127
+ } else {
128
+ warnings.push(
129
+ `Truncated: ${truncation.outputLines ?? "some"} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`,
130
+ );
131
+ }
132
+ }
133
+
134
+ const matchLimitWarning = formatLimitWarning("matches", details?.matchLimitReached);
135
+ if (matchLimitWarning) warnings.push(matchLimitWarning);
136
+
137
+ const resultLimitWarning = formatLimitWarning("results", details?.resultLimitReached);
138
+ if (resultLimitWarning) warnings.push(resultLimitWarning);
139
+
140
+ const entryLimitWarning = formatLimitWarning("entries", details?.entryLimitReached);
141
+ if (entryLimitWarning) warnings.push(entryLimitWarning);
142
+
143
+ if (details?.linesTruncated) warnings.push("Some lines truncated");
144
+
145
+ return warnings;
146
+ }
147
+
148
+ function syncElapsedTimer(options: ToolRenderResultParams[1], context: ToolRenderContext): TimerRenderState {
149
+ const state = context.state as TimerRenderState;
150
+
151
+ if (state.startedAt !== undefined && options.isPartial && !state.interval) {
152
+ state.interval = setInterval(() => context.invalidate(), 1000);
153
+ }
154
+
155
+ if (!options.isPartial || context.isError) {
156
+ state.endedAt ??= Date.now();
157
+ if (state.interval) {
158
+ clearInterval(state.interval);
159
+ state.interval = undefined;
160
+ }
161
+ }
162
+
163
+ return state;
164
+ }
165
+
166
+ function renderQuietCollapsedResult(
167
+ result: ToolRenderResultParams[0],
168
+ options: ToolRenderResultParams[1],
169
+ theme: RenderTheme,
170
+ context: ToolRenderContext,
171
+ ): Container {
172
+ const state = syncElapsedTimer(options, context);
173
+ const component = context.lastComponent instanceof QuietResultRenderComponent
174
+ ? context.lastComponent
175
+ : new QuietResultRenderComponent();
176
+ component.clear();
177
+
178
+ const output = getTextOutput(result);
179
+ const outputLines = output ? output.split("\n") : [];
180
+
181
+ if (outputLines.length > 0) {
182
+ const firstLine = outputLines[0] ?? "";
183
+ const hiddenLines = Math.max(0, outputLines.length - COLLAPSED_PREVIEW_LINES);
184
+ component.addChild({
185
+ render: (width) => [renderCollapsedLine(firstLine, hiddenLines, theme, width)],
186
+ invalidate: () => undefined,
187
+ });
188
+ } else if (options.isPartial) {
189
+ component.addChild(new Text(theme.fg("muted", "Running..."), 0, 0));
190
+ }
191
+
192
+ const warnings = getTruncationWarnings(result);
193
+ if (warnings.length > 0) {
194
+ component.addChild(new Text(theme.fg("warning", `[${warnings.join(". ")}]`), 0, 0));
195
+ }
196
+
197
+ if (state.startedAt !== undefined && options.isPartial) {
198
+ component.addChild(new Text(theme.fg("muted", `Elapsed ${formatDuration(Date.now() - state.startedAt)}`), 0, 0));
199
+ }
200
+
201
+ component.invalidate();
202
+ return component;
203
+ }
204
+
205
+ function getPathArg(args: unknown): string | undefined {
206
+ if (!args || typeof args !== "object") return undefined;
207
+ const values = args as { path?: unknown; file_path?: unknown };
208
+ return typeof values.file_path === "string"
209
+ ? values.file_path
210
+ : typeof values.path === "string"
211
+ ? values.path
212
+ : undefined;
213
+ }
214
+
215
+ function formatQuietEditCall(args: unknown, theme: ToolRenderCallParams[1]): string {
216
+ const path = getPathArg(args);
217
+ const edits = args && typeof args === "object" && Array.isArray((args as { edits?: unknown }).edits)
218
+ ? (args as { edits: unknown[] }).edits.length
219
+ : undefined;
220
+ const editSummary = typeof edits === "number" && edits > 0 ? `${plural(edits, "edit block")} hidden` : "preview hidden";
221
+ return `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", path ?? "...")}${theme.fg(
222
+ "muted",
223
+ ` ... ${editSummary} (${keyHint("app.tools.expand", "to expand")})`,
224
+ )}`;
225
+ }
226
+
227
+ function formatQuietWriteCall(args: unknown, theme: ToolRenderCallParams[1]): string {
228
+ const path = getPathArg(args);
229
+ const content = args && typeof args === "object" ? (args as { content?: unknown }).content : undefined;
230
+ const contentSummary = typeof content === "string"
231
+ ? `${plural(content.split("\n").length, "line")}, ${formatSize(Buffer.byteLength(content, "utf8"))} hidden`
232
+ : "content hidden";
233
+ return `${theme.fg("toolTitle", theme.bold("write"))} ${theme.fg("accent", path ?? "...")}${theme.fg(
234
+ "muted",
235
+ ` ... ${contentSummary} (${keyHint("app.tools.expand", "to expand")})`,
236
+ )}`;
237
+ }
238
+
239
+ function renderQuietCall(
240
+ toolName: string,
241
+ base: ToolDefinition,
242
+ args: ToolRenderCallParams[0],
243
+ theme: ToolRenderCallParams[1],
244
+ context: ToolRenderCallParams[2],
245
+ ) {
246
+ if (context.expanded || !QUIET_CALL_TOOL_NAMES.has(toolName)) {
247
+ return base.renderCall?.(args, theme, context) ?? new Text(theme.fg("toolTitle", theme.bold(toolName)), 0, 0);
248
+ }
249
+
250
+ if (toolName === "edit") {
251
+ const component = context.lastComponent instanceof Box ? context.lastComponent : new Box(1, 1);
252
+ const bgColor = context.isError
253
+ ? "toolErrorBg"
254
+ : context.executionStarted && !context.isPartial
255
+ ? "toolSuccessBg"
256
+ : "toolPendingBg";
257
+ component.setBgFn((text) => theme.bg(bgColor, text));
258
+ component.clear();
259
+ component.addChild(new Text(formatQuietEditCall(args, theme), 0, 0));
260
+ component.invalidate();
261
+ return component;
262
+ }
263
+
264
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
265
+ text.setText(formatQuietWriteCall(args, theme));
266
+ return text;
267
+ }
268
+
269
+ function createQuietToolDefinition(base: ToolDefinition): ToolDefinition {
270
+ const baseRenderResult = base.renderResult;
271
+
272
+ return {
273
+ ...base,
274
+ renderCall(args, theme, context) {
275
+ return renderQuietCall(base.name, base, args, theme, context);
276
+ },
277
+ renderResult(result, options, theme, context) {
278
+ if (options.expanded && baseRenderResult) {
279
+ const delegateContext = context.lastComponent instanceof QuietResultRenderComponent
280
+ ? { ...context, lastComponent: undefined }
281
+ : context;
282
+ return baseRenderResult(result, options, theme, delegateContext);
283
+ }
284
+
285
+ return renderQuietCollapsedResult(result, options, theme, context);
286
+ },
287
+ };
288
+ }
289
+
290
+ function createBaseToolOptions(cwd: string): ToolsOptions | undefined {
291
+ try {
292
+ const settings = SettingsManager.create(cwd);
293
+ return {
294
+ read: { autoResizeImages: settings.getImageAutoResize() },
295
+ bash: {
296
+ commandPrefix: settings.getShellCommandPrefix(),
297
+ shellPath: settings.getShellPath(),
298
+ },
299
+ };
300
+ } catch {
301
+ return undefined;
302
+ }
303
+ }
304
+
305
+ function createBaseToolDefinitions(cwd: string): ToolDefinition[] {
306
+ const options = createBaseToolOptions(cwd);
307
+ return [
308
+ createReadToolDefinition(cwd, options?.read),
309
+ createBashToolDefinition(cwd, options?.bash),
310
+ createEditToolDefinition(cwd, options?.edit),
311
+ createWriteToolDefinition(cwd, options?.write),
312
+ createGrepToolDefinition(cwd, options?.grep),
313
+ createFindToolDefinition(cwd, options?.find),
314
+ createLsToolDefinition(cwd, options?.ls),
315
+ ] as ToolDefinition[];
316
+ }
317
+
318
+ function createQuietToolDefinitions(cwd: string, enabled: boolean): ToolDefinition[] {
319
+ const baseDefinitions = createBaseToolDefinitions(cwd);
320
+ return enabled ? baseDefinitions.map(createQuietToolDefinition) : baseDefinitions;
321
+ }
322
+
323
+ export default function quietToolsExtension(pi: ExtensionAPI) {
324
+ let enabled = true;
325
+
326
+ function registerTools(cwd: string): void {
327
+ for (const tool of createQuietToolDefinitions(cwd, enabled)) {
328
+ pi.registerTool(tool);
329
+ }
330
+ }
331
+
332
+ pi.on("session_start", async (_event, ctx) => {
333
+ registerTools(ctx.cwd);
334
+ });
335
+
336
+ pi.registerCommand("quiet-tools", {
337
+ description: "Toggle compact collapsed previews for built-in tool rows",
338
+ getArgumentCompletions: (prefix) => {
339
+ const commands = ["on", "off", "toggle", "status"];
340
+ const query = prefix.trim().toLowerCase();
341
+ const matches = commands.filter((command) => command.startsWith(query));
342
+ return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
343
+ },
344
+ handler: async (args, ctx) => {
345
+ const action = args.trim().toLowerCase() || "toggle";
346
+
347
+ if (action === "on" || action === "enable") {
348
+ enabled = true;
349
+ registerTools(ctx.cwd);
350
+ ctx.ui.notify("Quiet tool previews enabled: collapsed built-in tool rows show one-line output.", "info");
351
+ return;
352
+ }
353
+
354
+ if (action === "off" || action === "disable") {
355
+ enabled = false;
356
+ registerTools(ctx.cwd);
357
+ ctx.ui.notify("Quiet tool previews disabled: restored pi's standard built-in tool renderers.", "info");
358
+ return;
359
+ }
360
+
361
+ if (action === "toggle") {
362
+ enabled = !enabled;
363
+ registerTools(ctx.cwd);
364
+ ctx.ui.notify(
365
+ enabled
366
+ ? "Quiet tool previews enabled: collapsed built-in tool rows show one-line output."
367
+ : "Quiet tool previews disabled: restored pi's standard built-in tool renderers.",
368
+ "info",
369
+ );
370
+ return;
371
+ }
372
+
373
+ if (action === "status") {
374
+ ctx.ui.notify(
375
+ `Quiet tool previews are ${enabled ? "enabled" : "disabled"}. Collapsed preview lines: ${enabled ? COLLAPSED_PREVIEW_LINES : "pi default"}. Model-visible tool results are unchanged.`,
376
+ "info",
377
+ );
378
+ return;
379
+ }
380
+
381
+ ctx.ui.notify("Usage: /quiet-tools on | off | toggle | status", "warning");
382
+ },
383
+ });
384
+ }
@@ -1,13 +1,13 @@
1
1
  {
2
- "name": "@diegopetrucci/pi-compact-bash",
2
+ "name": "@diegopetrucci/pi-quiet-tools",
3
3
  "version": "0.1.0",
4
- "description": "A pi extension that renders collapsed bash tool output as a one-line preview.",
5
- "keywords": ["pi-package", "pi", "bash", "terminal"],
4
+ "description": "A pi extension that visually compacts collapsed built-in tool output in the TUI without changing tool results sent to the model.",
5
+ "keywords": ["pi-package", "pi", "tools", "terminal", "tui"],
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/diegopetrucci/pi-extensions.git",
10
- "directory": "extensions/compact-bash"
10
+ "directory": "extensions/quiet-tools"
11
11
  },
12
12
  "files": [
13
13
  "index.ts",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-extensions",
3
- "version": "0.1.16",
4
- "description": "A collection of pi extensions, including a minimal custom footer, an Amp-style oracle, a 200k context cap for auto-compaction, one-line collapsed bash previews, a permission gate for dangerous bash commands, confirm-before-destructive session actions, and terminal notifications when pi is ready for input.",
3
+ "version": "0.1.17",
4
+ "description": "A collection of pi extensions, including a minimal custom footer, an Amp-style oracle, a 200k context cap for auto-compaction, quiet one-line collapsed tool previews, a permission gate for dangerous bash commands, confirm-before-destructive session actions, and terminal notifications when pi is ready for input.",
5
5
  "keywords": ["pi-package", "pi", "terminal", "agent"],
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -31,7 +31,7 @@
31
31
  "./extensions/minimal-footer/index.ts",
32
32
  "./extensions/oracle/index.ts",
33
33
  "./extensions/context-cap/index.ts",
34
- "./extensions/compact-bash/index.ts",
34
+ "./extensions/quiet-tools/index.ts",
35
35
  "./extensions/permission-gate/index.ts",
36
36
  "./extensions/confirm-destructive/index.ts",
37
37
  "./extensions/notify/index.ts"
@@ -1,49 +0,0 @@
1
- # compact-bash
2
-
3
- A pi extension that makes collapsed assistant `bash` tool output much quieter.
4
-
5
- When enabled, collapsed bash output renders as one output line plus an inline hidden-line count and `Ctrl+O` expand hint. Expanding with `Ctrl+O` still shows the full rendered output.
6
-
7
- ## Commands
8
-
9
- ```text
10
- /compact-bash status
11
- /compact-bash off
12
- /compact-bash on
13
- /compact-bash toggle
14
- ```
15
-
16
- The extension starts enabled by default. Disabling is temporary for the current extension runtime/session; after `/reload`, `/new`, `/resume`, or `/fork`, it starts enabled again.
17
-
18
- ## Install
19
-
20
- ### Standalone npm package
21
-
22
- ```bash
23
- pi install npm:@diegopetrucci/pi-compact-bash
24
- ```
25
-
26
- ### Collection package
27
-
28
- ```bash
29
- pi install npm:@diegopetrucci/pi-extensions
30
- ```
31
-
32
- ### GitHub package
33
-
34
- ```bash
35
- pi install git:github.com/diegopetrucci/pi-extensions
36
- ```
37
-
38
- Then reload pi:
39
-
40
- ```text
41
- /reload
42
- ```
43
-
44
- ## Notes
45
-
46
- - This extension overrides pi's built-in `bash` tool so it can customize only the TUI renderer.
47
- - It reuses pi's built-in bash implementation and preserves `shellPath`/`shellCommandPrefix` settings when they are available from settings files.
48
- - It does not truncate or rewrite the actual tool result sent to the model.
49
- - It affects assistant-invoked `bash` tool rows. User `!`/`!!` bash commands are rendered by a separate pi component and keep pi's default preview behavior.
@@ -1,241 +0,0 @@
1
- import {
2
- createBashToolDefinition,
3
- DEFAULT_MAX_BYTES,
4
- formatSize,
5
- keyHint,
6
- SettingsManager,
7
- type ExtensionAPI,
8
- } from "@earendil-works/pi-coding-agent";
9
- import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui";
10
-
11
- const COLLAPSED_PREVIEW_LINES = 1;
12
-
13
- type BashToolDefinition = ReturnType<typeof createBashToolDefinition>;
14
- type BashRenderResult = NonNullable<BashToolDefinition["renderResult"]>;
15
- type BashRenderResultParams = Parameters<BashRenderResult>;
16
- type BashRenderState = {
17
- endedAt?: number;
18
- interval?: ReturnType<typeof setInterval>;
19
- };
20
-
21
- function getTextOutput(result: BashRenderResultParams[0]): string {
22
- return result.content
23
- .filter((content) => content.type === "text")
24
- .map((content) => content.text ?? "")
25
- .join("\n")
26
- .trim();
27
- }
28
-
29
- function formatDuration(ms: number): string {
30
- return `${(ms / 1000).toFixed(1)}s`;
31
- }
32
-
33
- function visibleLength(text: string): number {
34
- return text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").length;
35
- }
36
-
37
- function renderCollapsedLine(
38
- line: string,
39
- hiddenLines: number,
40
- theme: BashRenderResultParams[2],
41
- width: number,
42
- ): string {
43
- if (width <= 0) return "";
44
-
45
- const styledLine = theme.fg("toolOutput", line);
46
- if (hiddenLines <= 0) {
47
- return truncateToWidth(styledLine, width, "...");
48
- }
49
-
50
- const hint = theme.fg(
51
- "muted",
52
- ` ... ${hiddenLines} more line${hiddenLines === 1 ? "" : "s"} (${keyHint("app.tools.expand", "to expand")})`,
53
- );
54
- const hintWidth = visibleLength(hint);
55
- if (hintWidth + 8 > width) {
56
- return truncateToWidth(`${styledLine}${hint}`, width, "...");
57
- }
58
-
59
- return `${truncateToWidth(styledLine, width - hintWidth, "...")}${hint}`;
60
- }
61
-
62
- function getTruncationWarnings(result: BashRenderResultParams[0]): string[] {
63
- const details = result.details as
64
- | {
65
- truncation?: {
66
- truncated?: boolean;
67
- truncatedBy?: "lines" | "bytes";
68
- outputLines?: number;
69
- totalLines?: number;
70
- maxBytes?: number;
71
- };
72
- fullOutputPath?: string;
73
- }
74
- | undefined;
75
- const truncation = details?.truncation;
76
- const warnings: string[] = [];
77
-
78
- if (details?.fullOutputPath) {
79
- warnings.push(`Full output: ${details.fullOutputPath}`);
80
- }
81
-
82
- if (truncation?.truncated) {
83
- if (truncation.truncatedBy === "lines") {
84
- warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
85
- } else {
86
- warnings.push(
87
- `Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`,
88
- );
89
- }
90
- }
91
-
92
- return warnings;
93
- }
94
-
95
- function syncElapsedTimer(options: BashRenderResultParams[1], context: BashRenderResultParams[3]): BashRenderState {
96
- const state = context.state as BashRenderState & { startedAt?: number };
97
-
98
- if (state.startedAt !== undefined && options.isPartial && !state.interval) {
99
- state.interval = setInterval(() => context.invalidate(), 1000);
100
- }
101
-
102
- if (!options.isPartial || context.isError) {
103
- state.endedAt ??= Date.now();
104
- if (state.interval) {
105
- clearInterval(state.interval);
106
- state.interval = undefined;
107
- }
108
- }
109
-
110
- return state;
111
- }
112
-
113
- const renderCompactBashResult: BashRenderResult = (result, options, theme, context) => {
114
- const state = syncElapsedTimer(options, context);
115
- const component = (context.lastComponent as Container | undefined) ?? new Container();
116
- component.clear();
117
-
118
- const output = getTextOutput(result);
119
- const outputLines = output ? output.split("\n") : [];
120
-
121
- if (outputLines.length > 0) {
122
- if (options.expanded) {
123
- const displayText = outputLines.map((line) => theme.fg("toolOutput", line)).join("\n");
124
- component.addChild(new Text(displayText, 0, 0));
125
- } else {
126
- const firstLine = outputLines[0] ?? "";
127
- const hiddenLines = Math.max(0, outputLines.length - COLLAPSED_PREVIEW_LINES);
128
- component.addChild({
129
- render: (width) => [renderCollapsedLine(firstLine, hiddenLines, theme, width)],
130
- invalidate: () => undefined,
131
- });
132
- }
133
- } else if (options.isPartial) {
134
- component.addChild(new Text(theme.fg("muted", "Running..."), 0, 0));
135
- }
136
-
137
- const warnings = getTruncationWarnings(result);
138
- if (warnings.length > 0) {
139
- component.addChild(new Text(theme.fg("warning", `[${warnings.join(". ")}]`), 0, 0));
140
- }
141
-
142
- if (options.expanded) {
143
- const statusParts: string[] = [];
144
- if (outputLines.length > COLLAPSED_PREVIEW_LINES) {
145
- statusParts.push(`(${keyHint("app.tools.expand", "to collapse")})`);
146
- }
147
- if (state.startedAt !== undefined) {
148
- const endTime = state.endedAt ?? Date.now();
149
- statusParts.push(theme.fg("muted", `${options.isPartial ? "Elapsed" : "Took"} ${formatDuration(endTime - state.startedAt)}`));
150
- }
151
- if (statusParts.length > 0) {
152
- component.addChild(new Text(statusParts.join("\n"), 0, 0));
153
- }
154
- }
155
-
156
- component.invalidate();
157
- return component;
158
- };
159
-
160
- function createBaseBashToolDefinition(cwd: string): BashToolDefinition {
161
- try {
162
- const settings = SettingsManager.create(cwd);
163
- return createBashToolDefinition(cwd, {
164
- commandPrefix: settings.getShellCommandPrefix(),
165
- shellPath: settings.getShellPath(),
166
- });
167
- } catch {
168
- return createBashToolDefinition(cwd);
169
- }
170
- }
171
-
172
- function createCompactBashToolDefinition(cwd: string, enabled: boolean): BashToolDefinition {
173
- const base = createBaseBashToolDefinition(cwd);
174
- if (!enabled) return base;
175
-
176
- return {
177
- ...base,
178
- renderResult: renderCompactBashResult,
179
- };
180
- }
181
-
182
- export default function compactBashExtension(pi: ExtensionAPI) {
183
- let enabled = true;
184
-
185
- function registerBashTool(cwd: string): void {
186
- pi.registerTool(createCompactBashToolDefinition(cwd, enabled));
187
- }
188
-
189
- pi.on("session_start", async (_event, ctx) => {
190
- registerBashTool(ctx.cwd);
191
- });
192
-
193
- pi.registerCommand("compact-bash", {
194
- description: "Toggle one-line collapsed previews for bash tool output",
195
- getArgumentCompletions: (prefix) => {
196
- const commands = ["on", "off", "toggle", "status"];
197
- const query = prefix.trim().toLowerCase();
198
- const matches = commands.filter((command) => command.startsWith(query));
199
- return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
200
- },
201
- handler: async (args, ctx) => {
202
- const action = args.trim().toLowerCase() || "toggle";
203
-
204
- if (action === "on" || action === "enable") {
205
- enabled = true;
206
- registerBashTool(ctx.cwd);
207
- ctx.ui.notify("Compact bash previews enabled: collapsed bash output shows one line.", "info");
208
- return;
209
- }
210
-
211
- if (action === "off" || action === "disable") {
212
- enabled = false;
213
- registerBashTool(ctx.cwd);
214
- ctx.ui.notify("Compact bash previews disabled: restored pi's standard bash renderer.", "info");
215
- return;
216
- }
217
-
218
- if (action === "toggle") {
219
- enabled = !enabled;
220
- registerBashTool(ctx.cwd);
221
- ctx.ui.notify(
222
- enabled
223
- ? "Compact bash previews enabled: collapsed bash output shows one line."
224
- : "Compact bash previews disabled: restored pi's standard bash renderer.",
225
- "info",
226
- );
227
- return;
228
- }
229
-
230
- if (action === "status") {
231
- ctx.ui.notify(
232
- `Compact bash previews are ${enabled ? "enabled" : "disabled"}. Collapsed preview lines: ${enabled ? COLLAPSED_PREVIEW_LINES : "pi default"}.`,
233
- "info",
234
- );
235
- return;
236
- }
237
-
238
- ctx.ui.notify("Usage: /compact-bash on | off | toggle | status", "warning");
239
- },
240
- });
241
- }