pi-pretty-tui 0.1.2 → 0.1.4

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
@@ -7,8 +7,11 @@ A visual refinement extension for the [Pi coding agent](https://pi.dev/). It pro
7
7
  ## Features
8
8
 
9
9
  - Compact `Read`, `Bash`, `Edit`, `Write`, `Grep`, `Find`, and `List` calls
10
- - Gray running, green success, and red failure indicators
11
- - Concise result summaries with expandable output
10
+ - Concise per-tool call and result summaries with expandable details
11
+ - Native-style `Write` previews in `full` mode: first 10 lines when collapsed, full content when expanded
12
+ - Live `Bash` output in `full` mode: latest 5 lines when collapsed, all available output when expanded
13
+ - Switchable `full`, `compact`, and `clean` rendering modes with persistent settings
14
+ - Clean mode hides thinking blocks (including Pi's standalone `Thinking...` placeholder) while collapsed and reveals the original thinking content when expanded
12
15
  - Colored `+added` and `-removed` edit statistics and diffs
13
16
  - Correct hanging indentation for long paths and wrapped tool output
14
17
  - Bullet (`•`) markers for unordered Markdown lists
@@ -23,13 +26,32 @@ Restart Pi or run `/reload` after installation.
23
26
 
24
27
  Pi may warn that built-in tools are being overridden. This is expected: pi-pretty-tui re-registers Pi's built-in tools and delegates execution to their original implementations, changing only their TUI renderers.
25
28
 
29
+ ## Rendering modes
30
+
31
+ Run `/pretty-tui` to choose a mode interactively, or set one directly:
32
+
33
+ ```text
34
+ /pretty-tui full
35
+ /pretty-tui compact
36
+ /pretty-tui clean
37
+ /pretty-tui status
38
+ ```
39
+
40
+ - `full` (default): preserve each tool's detailed call renderer and normal result preview. Press `Ctrl+O` to expand available output, diffs, and content.
41
+ - `compact`: when collapsed, all supported built-in tools use concise one-line call summaries and final result summaries. Long commands, written content, edit diffs, and tool output stay hidden until expanded; press `Ctrl+O` to reveal the full details.
42
+ - `clean`: collapse supported tool calls into a single `Running(...)` status; the activity slot shows a tool name as soon as its streamed tool call appears, such as `Running(3 tool calls · Read)`, and remains visible for at least 1 second before changing to `thinking...`. Once settled, the label becomes `Done(...)`. Visible assistant text starts a new tool group, so later calls get a separate status. Collapsed thinking blocks stay hidden without Pi's `Thinking...` placeholder; press `Ctrl+O` to reveal thinking content and normal expanded tool calls/output in transcript order.
43
+
44
+ Clean mode covers the built-in tools managed by this package: `read`, `bash`, `edit`, `write`, `grep`, `find`, and `ls`. Third-party tools keep their own rendering.
45
+
46
+ The selected mode applies immediately and persists in `~/.pi/agent/pretty-tui.json` (or the directory selected by `PI_CODING_AGENT_DIR`).
47
+
26
48
  ## Expand tool output
27
49
 
28
- Press `Ctrl+O` (Pi's default `app.tools.expand` keybinding) to show or hide detailed output and edit diffs.
50
+ Press `Ctrl+O` (Pi's default `app.tools.expand` keybinding) to show or hide detailed output, edit diffs, complete `Write` content, full tool details, and thinking content in `compact` or `clean` mode.
29
51
 
30
52
  ## Compatibility notice
31
53
 
32
- Tool rendering uses Pi's documented extension APIs. Changing unordered-list markers currently requires runtime patching of an internal `@earendil-works/pi-tui` Markdown renderer method because Pi does not expose a public hook for that presentation detail. Code blocks use Pi's original renderer without modification.
54
+ Tool rendering uses Pi's documented extension APIs. Clean thinking and active-tool state follow Pi's built-in expansion and execution transitions through its exported interactive components. Changing unordered-list markers currently requires runtime patching of an internal `@earendil-works/pi-tui` Markdown renderer method because Pi does not expose a public hook for that presentation detail. Code blocks use Pi's original renderer without modification.
33
55
 
34
56
  No Pi source files are modified. The list-marker patch is removed during session shutdown, but a future Pi release may require this extension to be updated.
35
57
 
Binary file
@@ -1,5 +1,7 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
1
  import {
2
+ AssistantMessageComponent,
3
+ InteractiveMode,
4
+ ToolExecutionComponent,
3
5
  createBashTool,
4
6
  createEditTool,
5
7
  createFindTool,
@@ -7,6 +9,8 @@ import {
7
9
  createLsTool,
8
10
  createReadTool,
9
11
  createWriteTool,
12
+ getAgentDir,
13
+ type ExtensionAPI,
10
14
  } from "@earendil-works/pi-coding-agent";
11
15
  import {
12
16
  type Component,
@@ -14,10 +18,208 @@ import {
14
18
  visibleWidth,
15
19
  wrapTextWithAnsi,
16
20
  } from "@earendil-works/pi-tui";
21
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+
24
+ type PrettyTuiMode = "full" | "compact" | "clean";
25
+ const CLEAN_TOOL_ACTIVITY_MIN_MS = 1000;
26
+ type PrettyTuiConfig = {
27
+ mode?: PrettyTuiMode;
28
+ /** Legacy location used by the first mode implementation. */
29
+ bash?: {
30
+ mode?: "full" | "compact";
31
+ };
32
+ };
33
+
34
+ const loadConfig = (path: string): PrettyTuiConfig => {
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
37
+ return parsed && typeof parsed === "object" ? parsed : {};
38
+ } catch {
39
+ return {};
40
+ }
41
+ };
17
42
 
18
43
  /** Polished tool calls and list rendering for Pi's TUI. */
19
44
  export default function prettyTui(pi: ExtensionAPI) {
20
45
  const cwd = process.cwd();
46
+ const configPath = join(getAgentDir(), "pretty-tui.json");
47
+ let config = loadConfig(configPath);
48
+ const configuredMode = config.mode ?? config.bash?.mode;
49
+ let renderMode: PrettyTuiMode = configuredMode === "compact" || configuredMode === "clean"
50
+ ? configuredMode
51
+ : "full";
52
+ let cleanToolsExpanded = false;
53
+
54
+ const saveRenderMode = (mode: PrettyTuiMode) => {
55
+ config = { ...config, mode };
56
+ mkdirSync(getAgentDir(), { recursive: true });
57
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
58
+ };
59
+
60
+ pi.registerCommand("pretty-tui", {
61
+ description: "Configure pi-pretty-tui rendering mode",
62
+ getArgumentCompletions: (prefix: string) => {
63
+ const items = [
64
+ { value: "full", label: "full", description: "Full tool details and output" },
65
+ { value: "compact", label: "compact", description: "Concise summaries for all built-in tools" },
66
+ { value: "clean", label: "clean", description: "Group supported tools into Running/Done status" },
67
+ { value: "status", label: "status", description: "Show the current mode" },
68
+ ];
69
+ const filtered = items.filter((item) => item.value.startsWith(prefix.trim().toLowerCase()));
70
+ return filtered.length > 0 ? filtered : null;
71
+ },
72
+ handler: async (args, ctx) => {
73
+ let requested = args.trim().toLowerCase();
74
+
75
+ if (!requested) {
76
+ if (!ctx.hasUI) {
77
+ ctx.ui.notify(`pi-pretty-tui mode: ${renderMode}`, "info");
78
+ return;
79
+ }
80
+ const full = `Full — full tool details and output${renderMode === "full" ? " (current)" : ""}`;
81
+ const compact = `Compact — concise summaries for all built-in tools${renderMode === "compact" ? " (current)" : ""}`;
82
+ const clean = `Clean — group supported tools into Running/Done status${renderMode === "clean" ? " (current)" : ""}`;
83
+ const selected = await ctx.ui.select("pi-pretty-tui rendering mode", [full, compact, clean]);
84
+ if (!selected) return;
85
+ requested = selected === full ? "full" : selected === compact ? "compact" : "clean";
86
+ }
87
+
88
+ if (requested === "status") {
89
+ ctx.ui.notify(`pi-pretty-tui mode: ${renderMode}`, "info");
90
+ return;
91
+ }
92
+ if (requested !== "full" && requested !== "compact" && requested !== "clean") {
93
+ ctx.ui.notify("Usage: /pretty-tui [full|compact|clean|status]", "error");
94
+ return;
95
+ }
96
+
97
+ renderMode = requested;
98
+ try {
99
+ saveRenderMode(renderMode);
100
+ ctx.ui.notify(`pi-pretty-tui mode set to: ${renderMode}`, "info");
101
+ } catch (error) {
102
+ const message = error instanceof Error ? error.message : String(error);
103
+ ctx.ui.notify(`Mode changed for this session, but could not save settings: ${message}`, "warning");
104
+ }
105
+ },
106
+ });
107
+
108
+ // Pi's built-in assistant component turns hidden thinking into a static
109
+ // "Thinking..." label. In collapsed clean mode, omit that label entirely;
110
+ // the clean-mode Running(...) row owns activity, while Ctrl+O still reveals
111
+ // the thinking content. The component is exported by Pi specifically for
112
+ // extension-level rendering customizations, so patch its public methods
113
+ // rather than Pi's source.
114
+ const assistantPrototype = AssistantMessageComponent.prototype as any;
115
+ const thinkingPatchKey = Symbol.for("pretty-tui.clean-thinking");
116
+ if (!assistantPrototype[thinkingPatchKey]) {
117
+ const originalUpdateContent = assistantPrototype.updateContent;
118
+ const originalRender = assistantPrototype.render;
119
+ const originalMessageKey = Symbol("pretty-tui.original-assistant-message");
120
+ const renderedModeKey = Symbol("pretty-tui.rendered-assistant-mode");
121
+ const renderedExpansionKey = Symbol("pretty-tui.rendered-thinking-expansion");
122
+
123
+ const patchedUpdateContent = function (this: any, message: any, isStreaming = this.isStreaming) {
124
+ if (renderMode !== "clean") {
125
+ originalUpdateContent.call(this, message, isStreaming);
126
+ this[originalMessageKey] = message;
127
+ this[renderedModeKey] = renderMode;
128
+ this[renderedExpansionKey] = undefined;
129
+ return;
130
+ }
131
+
132
+ const showThinking = cleanToolsExpanded;
133
+ const content = Array.isArray(message?.content) ? message.content : [];
134
+ // In collapsed clean mode, omit Pi's built-in Thinking... placeholder
135
+ // entirely. The clean-mode Running(...) row owns the activity label;
136
+ // expanded mode still reveals the actual thinking content.
137
+ const displayMessage = showThinking
138
+ ? message
139
+ : { ...message, content: content.filter((item: any) => item.type !== "thinking") };
140
+ const previousHideThinkingBlock = this.hideThinkingBlock;
141
+ this.hideThinkingBlock = false;
142
+ try {
143
+ originalUpdateContent.call(this, displayMessage, isStreaming);
144
+ } finally {
145
+ this.hideThinkingBlock = previousHideThinkingBlock;
146
+ }
147
+
148
+ // Pi's invalidate()/setHideThinkingBlock() call updateContent with
149
+ // lastMessage, so retain the unfiltered message for later mode changes.
150
+ this[originalMessageKey] = message;
151
+ this[renderedModeKey] = renderMode;
152
+ this[renderedExpansionKey] = showThinking;
153
+ this.lastMessage = message;
154
+ };
155
+
156
+ const patchedRender = function (this: any, width: number): string[] {
157
+ const needsRefresh = this[originalMessageKey] && (
158
+ this[renderedModeKey] !== renderMode ||
159
+ (renderMode === "clean" && this[renderedExpansionKey] !== cleanToolsExpanded)
160
+ );
161
+ if (needsRefresh) {
162
+ patchedUpdateContent.call(this, this[originalMessageKey], this.isStreaming);
163
+ }
164
+ return originalRender.call(this, width);
165
+ };
166
+
167
+ assistantPrototype[thinkingPatchKey] = {
168
+ originalUpdateContent,
169
+ patchedUpdateContent,
170
+ originalRender,
171
+ patchedRender,
172
+ };
173
+ assistantPrototype.updateContent = patchedUpdateContent;
174
+ assistantPrototype.render = patchedRender;
175
+
176
+ pi.on("session_shutdown", () => {
177
+ const patch = assistantPrototype[thinkingPatchKey];
178
+ if (!patch) return;
179
+ if (patch.patchedUpdateContent === assistantPrototype.updateContent) {
180
+ assistantPrototype.updateContent = patch.originalUpdateContent;
181
+ }
182
+ if (patch.patchedRender === assistantPrototype.render) {
183
+ assistantPrototype.render = patch.originalRender;
184
+ }
185
+ if (
186
+ assistantPrototype.updateContent === patch.originalUpdateContent &&
187
+ assistantPrototype.render === patch.originalRender
188
+ ) {
189
+ delete assistantPrototype[thinkingPatchKey];
190
+ }
191
+ });
192
+ }
193
+
194
+ // Extension shortcuts cannot replace Pi's built-in Ctrl+O binding. Wrap
195
+ // the exported state transition instead, so both Ctrl+O and UI callers keep
196
+ // their normal behavior while clean thinking follows the same state.
197
+ const interactiveModePrototype = InteractiveMode.prototype as any;
198
+ const toolsExpansionPatchKey = Symbol.for("pretty-tui.clean-tool-expansion");
199
+ if (!interactiveModePrototype[toolsExpansionPatchKey]) {
200
+ const originalSetToolsExpanded = interactiveModePrototype.setToolsExpanded;
201
+ const patchedSetToolsExpanded = function (this: any, expanded: boolean) {
202
+ cleanToolsExpanded = expanded;
203
+ return originalSetToolsExpanded.call(this, expanded);
204
+ };
205
+
206
+ interactiveModePrototype[toolsExpansionPatchKey] = {
207
+ originalSetToolsExpanded,
208
+ patchedSetToolsExpanded,
209
+ };
210
+ interactiveModePrototype.setToolsExpanded = patchedSetToolsExpanded;
211
+
212
+ pi.on("session_shutdown", () => {
213
+ const patch = interactiveModePrototype[toolsExpansionPatchKey];
214
+ if (!patch) return;
215
+ if (patch.patchedSetToolsExpanded === interactiveModePrototype.setToolsExpanded) {
216
+ interactiveModePrototype.setToolsExpanded = patch.originalSetToolsExpanded;
217
+ }
218
+ if (interactiveModePrototype.setToolsExpanded === patch.originalSetToolsExpanded) {
219
+ delete interactiveModePrototype[toolsExpansionPatchKey];
220
+ }
221
+ });
222
+ }
21
223
 
22
224
  // Pi normalizes unordered-list markers to "-". Replace only that marker;
23
225
  // leave code-block rendering entirely to Pi's built-in Markdown renderer.
@@ -101,20 +303,53 @@ export default function prettyTui(pi: ExtensionAPI) {
101
303
  context.state.compactToolStatus = status;
102
304
  };
103
305
 
306
+ const callRow = (theme: any, name: string, detail: string, state: any): DisplayRow => ({
307
+ prefix: () => {
308
+ const status = (state.compactToolStatus ?? "running") as ToolStatus;
309
+ const color = status === "success" ? "success" : status === "error" ? "error" : "dim";
310
+ return theme.fg(color, "● ");
311
+ },
312
+ continuation: " ",
313
+ content:
314
+ theme.fg("accent", theme.bold(name)) +
315
+ theme.fg("dim", "(") +
316
+ theme.fg("text", detail) +
317
+ theme.fg("dim", ")"),
318
+ });
319
+
104
320
  const call = (theme: any, name: string, detail: string, state: any) =>
105
- block([{
106
- prefix: () => {
107
- const status = (state.compactToolStatus ?? "running") as ToolStatus;
108
- const color = status === "success" ? "success" : status === "error" ? "error" : "dim";
109
- return theme.fg(color, "● ");
110
- },
111
- continuation: " ",
112
- content:
113
- theme.fg("accent", theme.bold(name)) +
114
- theme.fg("dim", "(") +
115
- theme.fg("text", detail) +
116
- theme.fg("dim", ")"),
117
- }]);
321
+ block([callRow(theme, name, detail, state)]);
322
+
323
+ const writeCall = (theme: any, path: string, content: string, expanded: boolean, state: any) => {
324
+ const lines = content.replace(/\t/g, " ").split("\n");
325
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
326
+
327
+ const total = lines.length;
328
+ const shown = lines.slice(0, expanded ? total : 10);
329
+ const remaining = total - shown.length;
330
+ const rows: DisplayRow[] = [
331
+ callRow(theme, "Write", `${path} · ${total} ${total === 1 ? "line" : "lines"}`, state),
332
+ ];
333
+
334
+ for (let index = 0; index < shown.length; index++) {
335
+ const isLast = index === shown.length - 1 && remaining === 0;
336
+ rows.push({
337
+ prefix: theme.fg("dim", isLast ? " └ " : " │ "),
338
+ continuation: theme.fg("dim", " │ "),
339
+ content: theme.fg("toolOutput", shown[index] || " "),
340
+ });
341
+ }
342
+
343
+ if (remaining > 0) {
344
+ rows.push({
345
+ prefix: theme.fg("muted", " └ "),
346
+ continuation: " ",
347
+ content: theme.fg("muted", `… ${remaining} more ${remaining === 1 ? "line" : "lines"}`),
348
+ });
349
+ }
350
+
351
+ return block(rows);
352
+ };
118
353
 
119
354
  const result = (
120
355
  theme: any,
@@ -161,25 +396,695 @@ export default function prettyTui(pi: ExtensionAPI) {
161
396
  return result(theme, label);
162
397
  };
163
398
 
399
+ const terminalOutputLines = (output: string): string[] => {
400
+ const lines: string[] = [];
401
+ let current = "";
402
+
403
+ for (let index = 0; index < output.length; index++) {
404
+ const char = output[index];
405
+ if (char === "\r") {
406
+ if (output[index + 1] === "\n") {
407
+ lines.push(current);
408
+ current = "";
409
+ index++;
410
+ } else {
411
+ // A bare carriage return redraws the current terminal line. Progress
412
+ // bars such as tqdm use this to update in place.
413
+ current = "";
414
+ }
415
+ } else if (char === "\n") {
416
+ lines.push(current);
417
+ current = "";
418
+ } else {
419
+ current += char;
420
+ }
421
+ }
422
+
423
+ if (current) lines.push(current);
424
+ return lines;
425
+ };
426
+
427
+ const bashResult = (
428
+ context: any,
429
+ theme: any,
430
+ summary: string,
431
+ output: string,
432
+ expanded: boolean,
433
+ status: ToolStatus,
434
+ ): Component => {
435
+ setStatus(context, status);
436
+ const lines = terminalOutputLines(output);
437
+ const shown = expanded ? lines : lines.slice(-5);
438
+ const omitted = lines.length - shown.length;
439
+ const rows: DisplayRow[] = [{
440
+ prefix: theme.fg("dim", "└ "),
441
+ continuation: " ",
442
+ content: theme.fg(status === "error" ? "error" : "toolOutput", summary),
443
+ }];
444
+
445
+ if (omitted > 0) {
446
+ rows.push({
447
+ prefix: theme.fg("muted", " │ "),
448
+ continuation: theme.fg("muted", " │ "),
449
+ content: theme.fg("muted", `… ${omitted} earlier ${omitted === 1 ? "line" : "lines"}`),
450
+ });
451
+ }
452
+
453
+ for (let index = 0; index < shown.length; index++) {
454
+ const isLast = index === shown.length - 1;
455
+ rows.push({
456
+ prefix: theme.fg("dim", isLast ? " └ " : " │ "),
457
+ continuation: theme.fg("dim", " │ "),
458
+ content: theme.fg("toolOutput", shown[index] || " "),
459
+ });
460
+ }
461
+
462
+ return block(rows);
463
+ };
464
+
164
465
  const completeStatus = (context: any, output: string): boolean => {
165
466
  const failed = isError(context, output);
166
467
  setStatus(context, failed ? "error" : "success");
167
468
  return failed;
168
469
  };
169
470
 
471
+ type ToolSummaryGroup = {
472
+ count: number;
473
+ failed: number;
474
+ lastToolCallId: string;
475
+ activity?: string;
476
+ };
477
+ type ToolSummaryData = {
478
+ count?: number;
479
+ failed?: number;
480
+ /** Identifies the last tool component for older single-group entries. */
481
+ lastToolCallId?: string;
482
+ /** All groups from a run, used to restore summaries after reload. */
483
+ groups?: ToolSummaryGroup[];
484
+ };
485
+ const supportedTools = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
486
+ type ToolActivityHold = {
487
+ toolCallId: string;
488
+ name: string;
489
+ until: number;
490
+ after: string;
491
+ started: boolean;
492
+ timer?: ReturnType<typeof setTimeout>;
493
+ };
494
+ const settledSummaries = new Map<string, { count: number; failed: number; activity: DisplayValue }>();
495
+ const toolActivityHolds = new Map<string, ToolActivityHold>();
496
+ const legacySummaryLastToolCallIds = new Map<string, string>();
497
+ const knownToolCallIds = new Set<string>();
498
+ type CleanRunState = {
499
+ activeToolCallId?: string;
500
+ lastCompletedToolCallId?: string;
501
+ activeToolName?: string;
502
+ activity?: string;
503
+ requestRender?: () => void;
504
+ count: number;
505
+ failed: number;
506
+ groups: ToolSummaryGroup[];
507
+ active: boolean;
508
+ settled: boolean;
509
+ };
510
+ const cleanRun: CleanRunState = {
511
+ count: 0,
512
+ failed: 0,
513
+ groups: [],
514
+ active: false,
515
+ settled: false,
516
+ };
517
+
518
+ const toolDisplayName = (name: string): string =>
519
+ name === "ls" ? "List" : name.charAt(0).toUpperCase() + name.slice(1);
520
+
521
+ const clearToolActivityHolds = () => {
522
+ for (const hold of toolActivityHolds.values()) {
523
+ if (hold.timer) clearTimeout(hold.timer);
524
+ }
525
+ toolActivityHolds.clear();
526
+ };
527
+
528
+ const heldActivity = (hold: ToolActivityHold): string =>
529
+ Date.now() < hold.until ? hold.name : hold.after;
530
+
531
+ const scheduleToolActivityRelease = (hold: ToolActivityHold) => {
532
+ if (hold.timer) clearTimeout(hold.timer);
533
+ const delay = Math.max(0, hold.until - Date.now());
534
+ hold.timer = setTimeout(() => {
535
+ hold.timer = undefined;
536
+ if (Date.now() < hold.until) {
537
+ scheduleToolActivityRelease(hold);
538
+ return;
539
+ }
540
+ if (toolActivityHolds.get(hold.toolCallId) === hold) {
541
+ toolActivityHolds.delete(hold.toolCallId);
542
+ }
543
+ if (cleanRun.lastCompletedToolCallId === hold.toolCallId && !cleanRun.activeToolCallId) {
544
+ cleanRun.activeToolName = undefined;
545
+ cleanRun.activity = hold.after;
546
+ }
547
+ cleanRun.requestRender?.();
548
+ }, delay);
549
+ };
550
+
551
+ const beginToolActivity = (toolCallId: string, name: string, started = false) => {
552
+ const previous = toolActivityHolds.get(toolCallId);
553
+ if (previous) {
554
+ if (previous.timer) clearTimeout(previous.timer);
555
+ previous.name = name;
556
+ previous.started ||= started;
557
+ cleanRun.activeToolName = name;
558
+ cleanRun.activity = name;
559
+ return;
560
+ }
561
+ const startedAt = Date.now();
562
+ toolActivityHolds.set(toolCallId, {
563
+ toolCallId,
564
+ name,
565
+ until: startedAt + CLEAN_TOOL_ACTIVITY_MIN_MS,
566
+ after: "thinking...",
567
+ started,
568
+ });
569
+ cleanRun.activeToolName = name;
570
+ cleanRun.activity = name;
571
+ };
572
+
573
+ const holdToolActivity = (toolCallId: string, name: string) => {
574
+ let hold = toolActivityHolds.get(toolCallId);
575
+ if (!hold) {
576
+ const now = Date.now();
577
+ hold = {
578
+ toolCallId,
579
+ name,
580
+ until: now + CLEAN_TOOL_ACTIVITY_MIN_MS,
581
+ after: "thinking...",
582
+ started: true,
583
+ };
584
+ toolActivityHolds.set(toolCallId, hold);
585
+ }
586
+ hold.name = name;
587
+ hold.started = true;
588
+ hold.after = "thinking...";
589
+ scheduleToolActivityRelease(hold);
590
+ };
591
+
592
+ const activityValueForHold = (toolCallId: string, after: string): DisplayValue => {
593
+ const hold = toolActivityHolds.get(toolCallId);
594
+ if (!hold) return after;
595
+ hold.after = after;
596
+ return () => heldActivity(hold);
597
+ };
598
+
599
+ const currentCleanActivity = (): string => {
600
+ if (cleanRun.activeToolCallId) {
601
+ return cleanRun.activeToolName ?? cleanRun.activity ?? "thinking...";
602
+ }
603
+ if (cleanRun.lastCompletedToolCallId) {
604
+ const hold = toolActivityHolds.get(cleanRun.lastCompletedToolCallId);
605
+ if (hold) return heldActivity(hold);
606
+ }
607
+ return cleanRun.activeToolName ?? cleanRun.activity ?? (cleanRun.active ? "thinking..." : "done");
608
+ };
609
+
610
+ pi.on("session_shutdown", clearToolActivityHolds);
611
+
612
+ // A tool component can be created from the streamed assistant message
613
+ // before Pi dispatches tool_execution_start. Mark its activity at the
614
+ // component's own execution boundary too, so the first clean render names it.
615
+ const toolExecutionPrototype = ToolExecutionComponent.prototype as any;
616
+ const toolExecutionPatchKey = Symbol.for("pretty-tui.clean-tool-execution");
617
+ if (!toolExecutionPrototype[toolExecutionPatchKey]) {
618
+ const originalMarkExecutionStarted = toolExecutionPrototype.markExecutionStarted;
619
+ const originalToolRender = toolExecutionPrototype.render;
620
+ const renderedModeKey = Symbol("pretty-tui.tool-rendered-mode");
621
+ const patchedMarkExecutionStarted = function (this: any) {
622
+ if (supportedTools.has(this.toolName)) {
623
+ if (typeof this.ui?.requestRender === "function") {
624
+ cleanRun.requestRender = () => this.ui.requestRender();
625
+ }
626
+ cleanRun.active = true;
627
+ cleanRun.activeToolCallId = this.toolCallId;
628
+ beginToolActivity(this.toolCallId, toolDisplayName(this.toolName), true);
629
+ }
630
+ return originalMarkExecutionStarted.call(this);
631
+ };
632
+ const patchedToolRender = function (this: any, width: number): string[] {
633
+ if (this[renderedModeKey] !== renderMode) {
634
+ this[renderedModeKey] = renderMode;
635
+ this.updateDisplay();
636
+ }
637
+ return originalToolRender.call(this, width);
638
+ };
639
+
640
+ toolExecutionPrototype[toolExecutionPatchKey] = {
641
+ originalMarkExecutionStarted,
642
+ patchedMarkExecutionStarted,
643
+ originalRender: originalToolRender,
644
+ patchedRender: patchedToolRender,
645
+ };
646
+ toolExecutionPrototype.markExecutionStarted = patchedMarkExecutionStarted;
647
+ toolExecutionPrototype.render = patchedToolRender;
648
+
649
+ pi.on("session_shutdown", () => {
650
+ const patch = toolExecutionPrototype[toolExecutionPatchKey];
651
+ if (!patch) return;
652
+ if (patch.patchedMarkExecutionStarted === toolExecutionPrototype.markExecutionStarted) {
653
+ toolExecutionPrototype.markExecutionStarted = patch.originalMarkExecutionStarted;
654
+ }
655
+ if (patch.patchedRender === toolExecutionPrototype.render) {
656
+ toolExecutionPrototype.render = patch.originalRender;
657
+ }
658
+ if (
659
+ toolExecutionPrototype.markExecutionStarted === patch.originalMarkExecutionStarted &&
660
+ toolExecutionPrototype.render === patch.originalRender
661
+ ) {
662
+ delete toolExecutionPrototype[toolExecutionPatchKey];
663
+ }
664
+ });
665
+ }
666
+
667
+ const finishCleanGroup = (activity = cleanRun.activity ?? "done") => {
668
+ if (cleanRun.lastCompletedToolCallId && cleanRun.count > 0) {
669
+ const group: ToolSummaryGroup = {
670
+ count: cleanRun.count,
671
+ failed: cleanRun.failed,
672
+ lastToolCallId: cleanRun.lastCompletedToolCallId,
673
+ activity,
674
+ };
675
+ cleanRun.groups.push(group);
676
+ settledSummaries.set(group.lastToolCallId, {
677
+ count: group.count,
678
+ failed: group.failed,
679
+ activity: activityValueForHold(group.lastToolCallId, activity),
680
+ });
681
+ }
682
+ cleanRun.count = 0;
683
+ cleanRun.failed = 0;
684
+ cleanRun.lastCompletedToolCallId = undefined;
685
+ cleanRun.activeToolCallId = undefined;
686
+ cleanRun.activeToolName = undefined;
687
+ };
688
+
689
+ const settleLastCleanGroup = () => {
690
+ const group = cleanRun.groups[cleanRun.groups.length - 1];
691
+ if (!group) return;
692
+ group.activity = "done";
693
+ settledSummaries.set(group.lastToolCallId, {
694
+ count: group.count,
695
+ failed: group.failed,
696
+ activity: activityValueForHold(group.lastToolCallId, "done"),
697
+ });
698
+ };
699
+
700
+ const summaryText = (count: number, _failed: number, activity = "done"): string => {
701
+ const activityText = typeof activity === "string" ? activity : "done";
702
+ const countLabel = `${count} tool ${count === 1 ? "call" : "calls"}`;
703
+ const activityLabel = activityText !== "done" && activityText.trim()
704
+ ? ` · ${activityText}`
705
+ : "";
706
+ return `${countLabel}${activityLabel}`;
707
+ };
708
+
709
+ const summaryRow = (
710
+ theme: any,
711
+ count: number,
712
+ failed: number,
713
+ activity: DisplayValue = "done",
714
+ ): DisplayRow => ({
715
+ prefix: () => {
716
+ const currentActivity = typeof activity === "function" ? activity() : activity;
717
+ const color = currentActivity === "done" ? "success" : "accent";
718
+ return theme.fg(color, "● ");
719
+ },
720
+ continuation: " ",
721
+ content: () => {
722
+ const currentActivity = typeof activity === "function" ? activity() : activity;
723
+ const label = currentActivity === "done" ? "Done" : "Running";
724
+ const color = label === "Done" ? "success" : "accent";
725
+ return theme.fg(color, theme.bold(label)) +
726
+ theme.fg("dim", "(") +
727
+ theme.fg("text", summaryText(count, failed, currentActivity)) + theme.fg("dim", ")");
728
+ },
729
+ });
730
+
731
+ /**
732
+ * Clean mode keeps Running/Done rows in the existing tool components. This
733
+ * avoids waiting for agent_end (which can be followed by retry/compaction),
734
+ * leaves a visible count between calls, and keeps each row in transcript order.
735
+ */
736
+ const cleanToolCall = (theme: any, name: string, toolCallId: string): Component => {
737
+ knownToolCallIds.add(toolCallId);
738
+ return {
739
+ render(width: number): string[] {
740
+ if (renderMode !== "clean") return [];
741
+
742
+ const settledSummary = settledSummaries.get(toolCallId);
743
+ if (settledSummary) {
744
+ return block([summaryRow(theme, settledSummary.count, settledSummary.failed, settledSummary.activity)]).render(width);
745
+ }
746
+ if (cleanRun.settled) return [];
747
+
748
+ if (cleanRun.activeToolCallId === toolCallId) {
749
+ return block([summaryRow(
750
+ theme,
751
+ cleanRun.count + 1,
752
+ cleanRun.failed,
753
+ () => cleanRun.activeToolName ?? name,
754
+ )]).render(width);
755
+ }
756
+
757
+ // The active tool owns the live Running row. Keep the latest completed
758
+ // portion hidden while that tool's minimum display time is running.
759
+ if (
760
+ cleanRun.lastCompletedToolCallId === toolCallId &&
761
+ cleanRun.count > 0 &&
762
+ !cleanRun.activeToolCallId
763
+ ) {
764
+ return block([summaryRow(theme, cleanRun.count, cleanRun.failed, currentCleanActivity)]).render(width);
765
+ }
766
+
767
+ // Do not fall back to the per-component pending state here: Pi may
768
+ // create several tool-call components before execution starts, and
769
+ // showing that fallback would briefly expose all of them. The active
770
+ // component is selected to render the current Running row at execution start.
771
+ return [];
772
+ },
773
+ invalidate() {},
774
+ };
775
+ };
776
+
777
+ const hiddenToolResult = (context: any, options: any, output: string): Component => {
778
+ if (options.isPartial) setStatus(context, "running");
779
+ else completeStatus(context, output);
780
+ return block([]);
781
+ };
782
+
783
+ // In collapsed clean mode, every supported tool call is represented by its
784
+ // Running row. Ctrl+O bypasses this and restores the normal full renderer.
785
+ const hideCleanTool = (
786
+ _toolCallId: string,
787
+ expanded: boolean,
788
+ _executionStarted = false,
789
+ ): boolean => renderMode === "clean" && !expanded;
790
+
791
+ pi.registerEntryRenderer<ToolSummaryData>("pretty-tui-tool-summary", (entry, { expanded }, theme) => ({
792
+ render(width: number): string[] {
793
+ if (renderMode !== "clean" || expanded) return [];
794
+ const data = entry.data;
795
+ const groups = data?.groups?.length
796
+ ? data.groups
797
+ : data?.lastToolCallId
798
+ ? [{
799
+ count: data.count ?? 0,
800
+ failed: data.failed ?? 0,
801
+ lastToolCallId: data.lastToolCallId,
802
+ }]
803
+ : (() => {
804
+ const summaryCallId = legacySummaryLastToolCallIds.get(entry.id);
805
+ return summaryCallId
806
+ ? [{
807
+ count: data?.count ?? 0,
808
+ failed: data?.failed ?? 0,
809
+ lastToolCallId: summaryCallId,
810
+ }]
811
+ : [];
812
+ })();
813
+ // The live tool components own the visual positions. Keep this durable
814
+ // entry as a fallback only for groups whose tool components are absent
815
+ // from the current branch (for example, after compaction).
816
+ const missingGroups = groups.filter((group) => !knownToolCallIds.has(group.lastToolCallId));
817
+ if (missingGroups.length === 0) return [];
818
+ // Persisted entries represent settled groups. Older versions could
819
+ // accidentally store the transient responding... activity, so always
820
+ // normalize their display to Done.
821
+ return block(missingGroups.map((group) =>
822
+ summaryRow(theme, group.count, group.failed, "done")
823
+ )).render(width);
824
+ },
825
+ invalidate() {},
826
+ }));
827
+
828
+ pi.on("session_start", (_event, ctx) => {
829
+ cleanToolsExpanded = ctx.ui.getToolsExpanded();
830
+ settledSummaries.clear();
831
+ legacySummaryLastToolCallIds.clear();
832
+ clearToolActivityHolds();
833
+ cleanRun.requestRender = undefined;
834
+ cleanRun.count = 0;
835
+ cleanRun.failed = 0;
836
+ cleanRun.groups = [];
837
+ cleanRun.activeToolCallId = undefined;
838
+ cleanRun.activeToolName = undefined;
839
+ cleanRun.lastCompletedToolCallId = undefined;
840
+ cleanRun.activity = undefined;
841
+ cleanRun.active = false;
842
+ cleanRun.settled = false;
843
+
844
+ let count = 0;
845
+ let failed = 0;
846
+ let lastToolCallId: string | undefined;
847
+ let lastFinishedGroup: ToolSummaryGroup | undefined;
848
+ const toolCalls = new Set<string>();
849
+ const explicitSummaryIds = new Set<string>();
850
+
851
+ const rememberGroup = (group: ToolSummaryGroup, entryId?: string) => {
852
+ settledSummaries.set(group.lastToolCallId, {
853
+ count: group.count,
854
+ failed: group.failed,
855
+ activity: "done",
856
+ });
857
+ if (entryId) legacySummaryLastToolCallIds.set(entryId, group.lastToolCallId);
858
+ explicitSummaryIds.add(group.lastToolCallId);
859
+ };
860
+
861
+ const finishGroup = () => {
862
+ if (lastToolCallId && count > 0) {
863
+ lastFinishedGroup = { count, failed, lastToolCallId };
864
+ if (!explicitSummaryIds.has(lastToolCallId)) {
865
+ settledSummaries.set(lastToolCallId, { count, failed, activity: "done" });
866
+ }
867
+ }
868
+ count = 0;
869
+ failed = 0;
870
+ lastToolCallId = undefined;
871
+ toolCalls.clear();
872
+ };
873
+
874
+ // Use the same compaction-aware branch that Pi renders in the transcript;
875
+ // getEntries() can also contain entries from other branches.
876
+ for (const entry of ctx.sessionManager.buildContextEntries()) {
877
+ if (entry.type === "custom" && entry.customType === "pretty-tui-tool-summary") {
878
+ const data = entry.data as ToolSummaryData | undefined;
879
+ if (data?.groups?.length) {
880
+ for (const group of data.groups) {
881
+ if (group?.lastToolCallId && group.count > 0) rememberGroup(group);
882
+ }
883
+ } else if (data?.lastToolCallId) {
884
+ rememberGroup({
885
+ count: data.count ?? 0,
886
+ failed: data.failed ?? 0,
887
+ lastToolCallId: data.lastToolCallId,
888
+ });
889
+ } else if (lastFinishedGroup) {
890
+ // Migrate summaries written by the earlier clean-mode versions,
891
+ // which did not persist the final tool call id. The text message
892
+ // preceding this entry has already closed the group.
893
+ rememberGroup({
894
+ count: data?.count ?? lastFinishedGroup.count,
895
+ failed: data?.failed ?? lastFinishedGroup.failed,
896
+ lastToolCallId: lastFinishedGroup.lastToolCallId,
897
+ }, entry.id);
898
+ } else if (lastToolCallId && count > 0) {
899
+ // Also handle a legacy entry inserted before the boundary text.
900
+ rememberGroup({
901
+ count: data?.count ?? count,
902
+ failed: data?.failed ?? failed,
903
+ lastToolCallId,
904
+ }, entry.id);
905
+ }
906
+ continue;
907
+ }
908
+
909
+ if (entry.type !== "message") continue;
910
+ const message = entry.message as any;
911
+
912
+ if (message.role === "user") {
913
+ finishGroup();
914
+ lastFinishedGroup = undefined;
915
+ continue;
916
+ }
917
+
918
+ if (message.role === "assistant") {
919
+ const hasVisibleText = (message.content ?? []).some(
920
+ (item: any) => item.type === "text" && typeof item.text === "string" && item.text.trim().length > 0,
921
+ );
922
+ if (hasVisibleText) finishGroup();
923
+ for (const item of message.content ?? []) {
924
+ if (item.type !== "toolCall" || !supportedTools.has(item.name)) continue;
925
+ count++;
926
+ lastToolCallId = item.id;
927
+ toolCalls.add(item.id);
928
+ }
929
+ continue;
930
+ }
931
+
932
+ if (message.role === "toolResult" && toolCalls.has(message.toolCallId) && message.isError) {
933
+ failed++;
934
+ }
935
+ }
936
+
937
+ finishGroup();
938
+ });
939
+
940
+ const hasVisibleAssistantText = (message: any): boolean =>
941
+ message?.role === "assistant" && (message.content ?? []).some(
942
+ (item: any) => item.type === "text" && typeof item.text === "string" && item.text.trim().length > 0,
943
+ );
944
+
945
+ const latestPendingToolCall = (message: any): any =>
946
+ [...(message?.content ?? [])].reverse().find(
947
+ (item: any) => item.type === "toolCall" && supportedTools.has(item.name) && item.id,
948
+ );
949
+
950
+ const trackPendingToolActivity = (message: any): boolean => {
951
+ const toolCall = latestPendingToolCall(message);
952
+ if (!toolCall || cleanRun.lastCompletedToolCallId === toolCall.id) return false;
953
+ cleanRun.active = true;
954
+ cleanRun.activeToolCallId = toolCall.id;
955
+ beginToolActivity(toolCall.id, toolDisplayName(toolCall.name));
956
+ return true;
957
+ };
958
+
959
+ const clearPendingToolActivities = () => {
960
+ for (const [toolCallId, hold] of toolActivityHolds) {
961
+ if (hold.started) continue;
962
+ if (hold.timer) clearTimeout(hold.timer);
963
+ toolActivityHolds.delete(toolCallId);
964
+ if (cleanRun.activeToolCallId === toolCallId) {
965
+ cleanRun.activeToolCallId = undefined;
966
+ cleanRun.activeToolName = undefined;
967
+ }
968
+ }
969
+ };
970
+
971
+ // A visible assistant response is the boundary between tool groups. Do
972
+ // this during streaming so a following tool call cannot inherit the prior
973
+ // summary, while message_end keeps the rule correct for non-streaming paths.
974
+ pi.on("message_update", (event) => {
975
+ if (hasVisibleAssistantText(event.message)) {
976
+ cleanRun.activity = "responding...";
977
+ finishCleanGroup("responding...");
978
+ return;
979
+ }
980
+ trackPendingToolActivity(event.message);
981
+ });
982
+ pi.on("message_end", (event) => {
983
+ if (hasVisibleAssistantText(event.message)) {
984
+ cleanRun.activity = "done";
985
+ finishCleanGroup("done");
986
+ settleLastCleanGroup();
987
+ return;
988
+ }
989
+ trackPendingToolActivity(event.message);
990
+ });
991
+
992
+ pi.on("agent_start", () => {
993
+ // A retry can start another low-level agent loop. Keep the accumulated
994
+ // count until the whole run reaches agent_settled.
995
+ if (!cleanRun.active) {
996
+ clearToolActivityHolds();
997
+ cleanRun.count = 0;
998
+ cleanRun.failed = 0;
999
+ cleanRun.groups = [];
1000
+ cleanRun.lastCompletedToolCallId = undefined;
1001
+ cleanRun.settled = false;
1002
+ }
1003
+ cleanRun.active = true;
1004
+ cleanRun.activeToolCallId = undefined;
1005
+ cleanRun.activeToolName = undefined;
1006
+ cleanRun.activity = "thinking...";
1007
+ });
1008
+ pi.on("tool_execution_start", (event) => {
1009
+ if (!supportedTools.has(event.toolName)) return;
1010
+ cleanRun.active = true;
1011
+ cleanRun.activeToolCallId = event.toolCallId;
1012
+ beginToolActivity(event.toolCallId, toolDisplayName(event.toolName), true);
1013
+ });
1014
+ pi.on("tool_execution_end", (event) => {
1015
+ if (!supportedTools.has(event.toolName)) return;
1016
+ const activityName = cleanRun.activeToolCallId === event.toolCallId
1017
+ ? cleanRun.activeToolName ?? toolDisplayName(event.toolName)
1018
+ : toolDisplayName(event.toolName);
1019
+ // Keep the tool activity visible until at least one second after start;
1020
+ // this only delays the status transition, never the tool result itself.
1021
+ holdToolActivity(event.toolCallId, activityName);
1022
+ cleanRun.count++;
1023
+ if (event.isError) cleanRun.failed++;
1024
+ cleanRun.lastCompletedToolCallId = event.toolCallId;
1025
+ if (cleanRun.activeToolCallId === event.toolCallId) {
1026
+ cleanRun.activeToolCallId = undefined;
1027
+ cleanRun.activeToolName = undefined;
1028
+ cleanRun.activity = "thinking...";
1029
+ }
1030
+ });
1031
+ pi.on("agent_end", () => {
1032
+ // No summary is appended here: this event may be followed by an automatic
1033
+ // retry or compaction. The live row remains available until settled.
1034
+ clearPendingToolActivities();
1035
+ cleanRun.activeToolCallId = undefined;
1036
+ cleanRun.activeToolName = undefined;
1037
+ cleanRun.activity = "thinking...";
1038
+ });
1039
+ pi.on("agent_settled", () => {
1040
+ if (cleanRun.settled) return;
1041
+
1042
+ clearPendingToolActivities();
1043
+ // Finalize the last group after retries/compaction have definitely ended.
1044
+ finishCleanGroup("done");
1045
+ for (const group of cleanRun.groups) {
1046
+ group.activity = "done";
1047
+ settledSummaries.set(group.lastToolCallId, {
1048
+ count: group.count,
1049
+ failed: group.failed,
1050
+ activity: activityValueForHold(group.lastToolCallId, "done"),
1051
+ });
1052
+ }
1053
+ cleanRun.active = false;
1054
+ cleanRun.activeToolCallId = undefined;
1055
+ cleanRun.activeToolName = undefined;
1056
+ cleanRun.activity = "done";
1057
+ cleanRun.settled = true;
1058
+
1059
+ const groups = cleanRun.groups.slice();
1060
+ if (renderMode !== "clean" || groups.length === 0) return;
1061
+
1062
+ const count = groups.reduce((total, group) => total + group.count, 0);
1063
+ const failed = groups.reduce((total, group) => total + group.failed, 0);
1064
+ const lastToolCallId = groups[groups.length - 1]?.lastToolCallId;
1065
+ pi.appendEntry<ToolSummaryData>("pretty-tui-tool-summary", {
1066
+ count,
1067
+ failed,
1068
+ lastToolCallId,
1069
+ groups: groups.map(({ count, failed, lastToolCallId }) => ({ count, failed, lastToolCallId })),
1070
+ });
1071
+ });
1072
+
170
1073
  const read = createReadTool(cwd);
171
1074
  pi.registerTool({
172
1075
  ...read,
173
1076
  renderShell: "self",
174
1077
  renderCall(args: any, theme: any, context: any) {
1078
+ if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, "Read", context.toolCallId);
175
1079
  const range = args.offset || args.limit
176
1080
  ? ` · lines ${args.offset ?? 1}${args.limit ? `–${(args.offset ?? 1) + args.limit - 1}` : "+"}`
177
1081
  : "";
178
1082
  return call(theme, "Read", `${args.path}${range}`, context.state);
179
1083
  },
180
1084
  renderResult(toolResult: any, options: any, theme: any, context: any) {
181
- if (options.isPartial) return partialResult(context, theme, "Reading…");
182
1085
  const output = textContent(toolResult);
1086
+ if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
1087
+ if (options.isPartial) return partialResult(context, theme, "Reading…");
183
1088
  const image = toolResult.content?.find((item: any) => item.type === "image");
184
1089
  const failed = completeStatus(context, output);
185
1090
  if (image) return result(theme, "Read image", "", false, failed);
@@ -200,20 +1105,48 @@ export default function prettyTui(pi: ExtensionAPI) {
200
1105
  ...bash,
201
1106
  renderShell: "self",
202
1107
  renderCall(args: any, theme: any, context: any) {
203
- const command = String(args.command).replace(/\s*\n\s*/g, " ↵ ");
1108
+ if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, "Bash", context.toolCallId);
1109
+ const command = typeof args.command === "string" ? args.command : "";
1110
+ if (renderMode === "compact" && !context.expanded) {
1111
+ const commandLines = command.split(/\r\n|\r|\n/);
1112
+ const firstLine = commandLines[0] ?? "";
1113
+ const omitted = commandLines.length - 1;
1114
+ const detail = omitted > 0
1115
+ ? `${firstLine} … (${omitted} more ${omitted === 1 ? "line" : "lines"})`
1116
+ : firstLine;
1117
+ return call(theme, "Bash", detail, context.state);
1118
+ }
204
1119
  return call(theme, "Bash", command, context.state);
205
1120
  },
206
1121
  renderResult(toolResult: any, options: any, theme: any, context: any) {
207
- if (options.isPartial) return partialResult(context, theme, "Running…");
208
1122
  const output = textContent(toolResult);
209
- const failed = completeStatus(context, output);
210
- const lines = nonEmptyLines(output);
1123
+ if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
1124
+ if (renderMode === "compact" && !options.expanded) {
1125
+ if (options.isPartial) return partialResult(context, theme, "Running…");
1126
+ const failed = completeStatus(context, output);
1127
+ return result(theme, failed ? "Command failed" : "Done", "", false, failed);
1128
+ }
1129
+
1130
+ if (options.isPartial) {
1131
+ return bashResult(context, theme, "Running…", output, options.expanded, "running");
1132
+ }
1133
+
1134
+ const failed = isError(context, output);
1135
+ const outputLines = terminalOutputLines(output);
1136
+ const lineCount = outputLines.filter((line) => line.length > 0).length;
211
1137
  const summary = failed
212
- ? output.split("\n")[0] || "Command failed"
213
- : output
214
- ? `Done · ${lines} output ${lines === 1 ? "line" : "lines"}`
1138
+ ? outputLines[0] || "Command failed"
1139
+ : outputLines.length > 0
1140
+ ? `Done · ${lineCount} output ${lineCount === 1 ? "line" : "lines"}`
215
1141
  : "Done";
216
- return result(theme, summary, output, options.expanded, failed);
1142
+ return bashResult(
1143
+ context,
1144
+ theme,
1145
+ summary,
1146
+ output,
1147
+ options.expanded,
1148
+ failed ? "error" : "success",
1149
+ );
217
1150
  },
218
1151
  } as any);
219
1152
 
@@ -222,12 +1155,14 @@ export default function prettyTui(pi: ExtensionAPI) {
222
1155
  ...edit,
223
1156
  renderShell: "self",
224
1157
  renderCall(args: any, theme: any, context: any) {
1158
+ if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, "Edit", context.toolCallId);
225
1159
  const count = Array.isArray(args.edits) ? ` · ${args.edits.length} changes` : "";
226
1160
  return call(theme, "Edit", `${args.path}${count}`, context.state);
227
1161
  },
228
1162
  renderResult(toolResult: any, options: any, theme: any, context: any) {
229
- if (options.isPartial) return partialResult(context, theme, "Editing…");
230
1163
  const output = textContent(toolResult);
1164
+ if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
1165
+ if (options.isPartial) return partialResult(context, theme, "Editing…");
231
1166
  const failed = completeStatus(context, output);
232
1167
  const diff = toolResult.details?.diff ?? "";
233
1168
  const additions = diff.split("\n").filter((line: string) => line.startsWith("+") && !line.startsWith("+++")).length;
@@ -261,12 +1196,21 @@ export default function prettyTui(pi: ExtensionAPI) {
261
1196
  ...write,
262
1197
  renderShell: "self",
263
1198
  renderCall(args: any, theme: any, context: any) {
264
- const content = String(args.content ?? "");
265
- return call(theme, "Write", `${args.path} · ${nonEmptyLines(content)} lines`, context.state);
1199
+ if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, "Write", context.toolCallId);
1200
+ const content = typeof args.content === "string" ? args.content : "";
1201
+ const path = String(args.path ?? "");
1202
+ if (renderMode === "compact" && !context.expanded) {
1203
+ const lines = content.replace(/\t/g, " ").split("\n");
1204
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
1205
+ const total = lines.length;
1206
+ return call(theme, "Write", `${path} · ${total} ${total === 1 ? "line" : "lines"}`, context.state);
1207
+ }
1208
+ return writeCall(theme, path, content, context.expanded, context.state);
266
1209
  },
267
1210
  renderResult(toolResult: any, options: any, theme: any, context: any) {
268
- if (options.isPartial) return partialResult(context, theme, "Writing…");
269
1211
  const output = textContent(toolResult);
1212
+ if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
1213
+ if (options.isPartial) return partialResult(context, theme, "Writing…");
270
1214
  const failed = completeStatus(context, output);
271
1215
  return result(
272
1216
  theme,
@@ -283,13 +1227,15 @@ export default function prettyTui(pi: ExtensionAPI) {
283
1227
  ...grep,
284
1228
  renderShell: "self",
285
1229
  renderCall(args: any, theme: any, context: any) {
1230
+ if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, "Grep", context.toolCallId);
286
1231
  const where = args.path ? ` · ${args.path}` : "";
287
1232
  const glob = args.glob ? ` · ${args.glob}` : "";
288
1233
  return call(theme, "Grep", `${args.pattern}${where}${glob}`, context.state);
289
1234
  },
290
1235
  renderResult(toolResult: any, options: any, theme: any, context: any) {
291
- if (options.isPartial) return partialResult(context, theme, "Searching…");
292
1236
  const output = textContent(toolResult);
1237
+ if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
1238
+ if (options.isPartial) return partialResult(context, theme, "Searching…");
293
1239
  const failed = completeStatus(context, output);
294
1240
  const matches = nonEmptyLines(output);
295
1241
  return result(theme, failed ? output.split("\n")[0] : `Found ${matches} matches`, output, options.expanded, failed);
@@ -301,11 +1247,13 @@ export default function prettyTui(pi: ExtensionAPI) {
301
1247
  ...find,
302
1248
  renderShell: "self",
303
1249
  renderCall(args: any, theme: any, context: any) {
1250
+ if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, "Find", context.toolCallId);
304
1251
  return call(theme, "Find", `${args.pattern}${args.path ? ` · ${args.path}` : ""}`, context.state);
305
1252
  },
306
1253
  renderResult(toolResult: any, options: any, theme: any, context: any) {
307
- if (options.isPartial) return partialResult(context, theme, "Searching…");
308
1254
  const output = textContent(toolResult);
1255
+ if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
1256
+ if (options.isPartial) return partialResult(context, theme, "Searching…");
309
1257
  const failed = completeStatus(context, output);
310
1258
  const matches = nonEmptyLines(output);
311
1259
  return result(theme, failed ? output.split("\n")[0] : `Found ${matches} paths`, output, options.expanded, failed);
@@ -317,11 +1265,13 @@ export default function prettyTui(pi: ExtensionAPI) {
317
1265
  ...ls,
318
1266
  renderShell: "self",
319
1267
  renderCall(args: any, theme: any, context: any) {
1268
+ if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, "List", context.toolCallId);
320
1269
  return call(theme, "List", args.path ?? ".", context.state);
321
1270
  },
322
1271
  renderResult(toolResult: any, options: any, theme: any, context: any) {
323
- if (options.isPartial) return partialResult(context, theme, "Listing…");
324
1272
  const output = textContent(toolResult);
1273
+ if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
1274
+ if (options.isPartial) return partialResult(context, theme, "Listing…");
325
1275
  const failed = completeStatus(context, output);
326
1276
  const entries = nonEmptyLines(output);
327
1277
  return result(theme, failed ? output.split("\n")[0] : `Listed ${entries} entries`, output, options.expanded, failed);
package/package.json CHANGED
@@ -1,10 +1,18 @@
1
1
  {
2
2
  "name": "pi-pretty-tui",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Polished tool calls and list rendering for the Pi coding agent TUI",
5
5
  "license": "MIT",
6
6
  "author": "Kainan Yang <ykn0309@whu.edu.cn>",
7
7
  "type": "module",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ykn0309/pi-pretty-tui.git"
11
+ },
12
+ "homepage": "https://github.com/ykn0309/pi-pretty-tui#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/ykn0309/pi-pretty-tui/issues"
15
+ },
8
16
  "keywords": [
9
17
  "pi-package",
10
18
  "pi-extension",
@@ -32,6 +40,6 @@
32
40
  "extensions": [
33
41
  "./extensions/index.ts"
34
42
  ],
35
- "image": "https://unpkg.com/pi-pretty-tui@0.1.2/assets/screenshot.png"
43
+ "image": "https://unpkg.com/pi-pretty-tui@0.1.4/assets/screenshot.png"
36
44
  }
37
45
  }