pi-pretty-tui 0.1.4 → 0.1.6

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
@@ -1,11 +1,13 @@
1
1
  # pi-pretty-tui
2
2
 
3
- A visual refinement extension for the [Pi coding agent](https://pi.dev/). It provides compact tool-call rendering and cleaner list presentation while preserving Pi's built-in tool behavior.
3
+ A visual refinement extension for the [Pi coding agent](https://pi.dev/). It provides framed user messages, compact tool-call rendering, and cleaner list presentation while preserving Pi's built-in behavior.
4
4
 
5
5
  ![pi-pretty-tui preview](./assets/screenshot.png)
6
6
 
7
7
  ## Features
8
8
 
9
+ - Rounded, titled `User` message frames with theme-aware borders, filled backgrounds, and Markdown support
10
+ - A rounded prompt editor that preserves native cursor, IME, autocomplete, and mouse behavior
9
11
  - Compact `Read`, `Bash`, `Edit`, `Write`, `Grep`, `Find`, and `List` calls
10
12
  - Concise per-tool call and result summaries with expandable details
11
13
  - Native-style `Write` previews in `full` mode: first 10 lines when collapsed, full content when expanded
@@ -37,9 +39,9 @@ Run `/pretty-tui` to choose a mode interactively, or set one directly:
37
39
  /pretty-tui status
38
40
  ```
39
41
 
40
- - `full` (default): preserve each tool's detailed call renderer and normal result preview. Press `Ctrl+O` to expand available output, diffs, and content.
42
+ - `full`: preserve each tool's detailed call renderer and normal result preview. Press `Ctrl+O` to expand available output, diffs, and content.
41
43
  - `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.
44
+ - `clean` (default): 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(...)`. Click a group status to reveal all tools in that group as indented compact children while retaining the parent `Running`/`Done` row, then click an individual tool to toggle its full details. 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 all expanded tool calls/output in transcript order.
43
45
 
44
46
  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
47
 
@@ -47,13 +49,13 @@ The selected mode applies immediately and persists in `~/.pi/agent/pretty-tui.js
47
49
 
48
50
  ## Expand tool output
49
51
 
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.
52
+ 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. In clean mode, mouse interaction also supports progressive disclosure: click a group status to retain it as a parent and reveal compact child tools, click one child for its full view, or click the parent again to collapse the group.
51
53
 
52
54
  ## Compatibility notice
53
55
 
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.
56
+ 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. Framing native user messages, rounding the native prompt editor, and changing unordered-list markers require reload-safe runtime patches because Pi does not currently expose public renderer hooks for those presentation details. User Markdown and terminal semantic zones remain handled by Pi, and code blocks use Pi's original renderer without modification.
55
57
 
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.
58
+ No Pi source files are modified. Runtime patches are removed during session shutdown, but a future Pi release may require this extension to be updated.
57
59
 
58
60
  ## Uninstall
59
61
 
@@ -1,7 +1,9 @@
1
1
  import {
2
2
  AssistantMessageComponent,
3
+ CustomEditor,
3
4
  InteractiveMode,
4
5
  ToolExecutionComponent,
6
+ UserMessageComponent,
5
7
  createBashTool,
6
8
  createEditTool,
7
9
  createFindTool,
@@ -40,16 +42,46 @@ const loadConfig = (path: string): PrettyTuiConfig => {
40
42
  }
41
43
  };
42
44
 
43
- /** Polished tool calls and list rendering for Pi's TUI. */
45
+ /** Polished user messages, tool calls, and list rendering for Pi's TUI. */
44
46
  export default function prettyTui(pi: ExtensionAPI) {
45
47
  const cwd = process.cwd();
46
48
  const configPath = join(getAgentDir(), "pretty-tui.json");
47
49
  let config = loadConfig(configPath);
48
50
  const configuredMode = config.mode ?? config.bash?.mode;
49
- let renderMode: PrettyTuiMode = configuredMode === "compact" || configuredMode === "clean"
51
+ let renderMode: PrettyTuiMode = configuredMode === "full" || configuredMode === "compact" || configuredMode === "clean"
50
52
  ? configuredMode
51
- : "full";
53
+ : "clean";
52
54
  let cleanToolsExpanded = false;
55
+ let changingAllToolsExpansion = false;
56
+ const cleanCompactToolCallIds = new Set<string>();
57
+ const cleanGroupToolCallIds = new Map<string, string[]>();
58
+ const cleanToolCallGroupOwners = new Map<string, string>();
59
+ const cleanToolComponents = new Map<string, any>();
60
+ const cleanToolThemes = new Map<string, any>();
61
+ const cleanToolNames = new Map<string, string>();
62
+ let renderCleanGroupSummary = (_lastToolCallId: string, _width: number): string[] => [];
63
+
64
+ const setCleanGroupMembers = (lastToolCallId: string, toolCallIds: string[]) => {
65
+ cleanGroupToolCallIds.set(lastToolCallId, toolCallIds);
66
+ for (const toolCallId of toolCallIds) cleanToolCallGroupOwners.set(toolCallId, lastToolCallId);
67
+ };
68
+
69
+ const isCleanGroupRevealed = (toolCallId: string): boolean => {
70
+ if (cleanCompactToolCallIds.has(toolCallId)) return true;
71
+ const groupOwner = cleanToolCallGroupOwners.get(toolCallId);
72
+ const groupToolCallIds = groupOwner ? cleanGroupToolCallIds.get(groupOwner) : undefined;
73
+ return groupToolCallIds?.some((id) => cleanCompactToolCallIds.has(id)) ?? false;
74
+ };
75
+
76
+ const cleanThemeForToolCall = (toolCallId: string): any => {
77
+ const directTheme = cleanToolThemes.get(toolCallId);
78
+ if (directTheme) return directTheme;
79
+ const groupOwner = cleanToolCallGroupOwners.get(toolCallId);
80
+ const groupToolCallIds = groupOwner ? cleanGroupToolCallIds.get(groupOwner) : undefined;
81
+ return groupToolCallIds
82
+ ?.map((id) => cleanToolThemes.get(id))
83
+ .find(Boolean);
84
+ };
53
85
 
54
86
  const saveRenderMode = (mode: PrettyTuiMode) => {
55
87
  config = { ...config, mode };
@@ -94,7 +126,9 @@ export default function prettyTui(pi: ExtensionAPI) {
94
126
  return;
95
127
  }
96
128
 
129
+ cleanCompactToolCallIds.clear();
97
130
  renderMode = requested;
131
+ for (const component of cleanToolComponents.values()) component.updateDisplay?.();
98
132
  try {
99
133
  saveRenderMode(renderMode);
100
134
  ctx.ui.notify(`pi-pretty-tui mode set to: ${renderMode}`, "info");
@@ -105,6 +139,149 @@ export default function prettyTui(pi: ExtensionAPI) {
105
139
  },
106
140
  });
107
141
 
142
+ // Give Pi's main prompt editor a complete rounded frame. Render the native
143
+ // editor at a two-column narrower width so cursor layout, wrapping, IME, and
144
+ // autocomplete remain native, then add one themed border column per side.
145
+ const customEditorPrototype = CustomEditor.prototype as any;
146
+ const editorFramePatchKey = Symbol.for("pretty-tui.rounded-editor-frame");
147
+ if (!customEditorPrototype[editorFramePatchKey]) {
148
+ const hadOwnRender = Object.prototype.hasOwnProperty.call(customEditorPrototype, "render");
149
+ const hadOwnHandleMouse = Object.prototype.hasOwnProperty.call(customEditorPrototype, "handleMouse");
150
+ const originalEditorRender = customEditorPrototype.render;
151
+ const originalEditorHandleMouse = customEditorPrototype.handleMouse;
152
+ const patchedEditorRender = function (this: any, width: number): string[] {
153
+ if (width < 3) return originalEditorRender.call(this, width);
154
+
155
+ const innerWidth = width - 2;
156
+ const lines = originalEditorRender.call(this, innerWidth) as string[];
157
+ const visibleLineCount = Math.max(0, Number(this.renderedVisibleLineCount) || 0);
158
+ const bottomBorderIndex = visibleLineCount + 1;
159
+ const fitInnerWidth = (line: string) =>
160
+ line + " ".repeat(Math.max(0, innerWidth - visibleWidth(line)));
161
+
162
+ return lines.map((line, index) => {
163
+ const fitted = fitInnerWidth(line);
164
+ if (index === 0) return this.borderColor("╭") + fitted + this.borderColor("╮");
165
+ if (index === bottomBorderIndex) return this.borderColor("╰") + fitted + this.borderColor("╯");
166
+ if (index < bottomBorderIndex) return this.borderColor("│") + fitted + this.borderColor("│");
167
+ // Autocomplete remains outside the input frame, aligned to its content.
168
+ return " " + fitted + " ";
169
+ });
170
+ };
171
+ const patchedEditorHandleMouse = function (this: any, event: any) {
172
+ if (event.width < 3) return originalEditorHandleMouse.call(this, event);
173
+ const innerWidth = event.width - 2;
174
+ return originalEditorHandleMouse.call(this, {
175
+ ...event,
176
+ x: Math.max(0, Math.min(innerWidth - 1, event.x - 1)),
177
+ width: innerWidth,
178
+ });
179
+ };
180
+
181
+ customEditorPrototype[editorFramePatchKey] = {
182
+ hadOwnRender,
183
+ hadOwnHandleMouse,
184
+ originalRender: originalEditorRender,
185
+ originalHandleMouse: originalEditorHandleMouse,
186
+ patchedRender: patchedEditorRender,
187
+ patchedHandleMouse: patchedEditorHandleMouse,
188
+ };
189
+ customEditorPrototype.render = patchedEditorRender;
190
+ customEditorPrototype.handleMouse = patchedEditorHandleMouse;
191
+
192
+ pi.on("session_shutdown", () => {
193
+ const patch = customEditorPrototype[editorFramePatchKey];
194
+ if (!patch) return;
195
+ if (patch.patchedRender === customEditorPrototype.render) {
196
+ if (patch.hadOwnRender) customEditorPrototype.render = patch.originalRender;
197
+ else delete customEditorPrototype.render;
198
+ }
199
+ if (patch.patchedHandleMouse === customEditorPrototype.handleMouse) {
200
+ if (patch.hadOwnHandleMouse) customEditorPrototype.handleMouse = patch.originalHandleMouse;
201
+ else delete customEditorPrototype.handleMouse;
202
+ }
203
+ if (
204
+ customEditorPrototype.render === patch.originalRender &&
205
+ customEditorPrototype.handleMouse === patch.originalHandleMouse
206
+ ) {
207
+ delete customEditorPrototype[editorFramePatchKey];
208
+ }
209
+ });
210
+ }
211
+
212
+ // Render user prompts as a titled, rounded frame while preserving Pi's
213
+ // original Markdown component, wrapping, output padding, and OSC 133 zones.
214
+ const userMessagePrototype = UserMessageComponent.prototype as any;
215
+ const userMessagePatchKey = Symbol.for("pretty-tui.user-message-frame");
216
+ if (!userMessagePrototype[userMessagePatchKey]) {
217
+ const originalUserMessageRebuild = userMessagePrototype.rebuild;
218
+ const patchedUserMessageRebuild = function (this: any) {
219
+ originalUserMessageRebuild.call(this);
220
+ const content = this.children?.[0] as any;
221
+ if (!content) return;
222
+
223
+ // Reuse Pi's theme-aware user background across the complete frame.
224
+ const userMessageBg = content.bgFn as ((text: string) => string) | undefined;
225
+ content.paddingX = 0;
226
+ content.paddingY = 0;
227
+ content.setBgFn?.(undefined);
228
+ content.invalidate?.();
229
+
230
+ const outputPad = Math.max(0, Number(this.outputPad) || 0);
231
+ const markdownTheme = this.markdownTheme;
232
+ const border = (text: string) => markdownTheme.quoteBorder(text);
233
+ const frame: Component = {
234
+ render(width: number): string[] {
235
+ const sidePad = Math.min(outputPad, Math.max(0, Math.floor((width - 1) / 2)));
236
+ const outer = " ".repeat(sidePad);
237
+ const frameWidth = Math.max(1, width - sidePad * 2);
238
+ const paintBackground = (line: string) => {
239
+ const padding = " ".repeat(Math.max(0, frameWidth - visibleWidth(line)));
240
+ return userMessageBg ? userMessageBg(line + padding) : line + padding;
241
+ };
242
+ if (frameWidth < 4) {
243
+ return content.render(frameWidth).map((line: string) => outer + paintBackground(line));
244
+ }
245
+
246
+ const title = " User ";
247
+ const topStart = "╭─";
248
+ const topTail = "─".repeat(Math.max(0, frameWidth - visibleWidth(topStart) - visibleWidth(title) - 1));
249
+ const top = border(topStart + markdownTheme.bold(title) + topTail + "╮");
250
+ const bottom = border("╰" + "─".repeat(Math.max(0, frameWidth - 2)) + "╯");
251
+ const contentWidth = Math.max(1, frameWidth - 4);
252
+ const body = content.render(contentWidth).map((line: string) => {
253
+ const padding = " ".repeat(Math.max(0, contentWidth - visibleWidth(line)));
254
+ return border("│ ") + line + padding + border(" │");
255
+ });
256
+ return [top, ...body, bottom].map((line: string) => outer + paintBackground(line));
257
+ },
258
+ invalidate() {
259
+ content.invalidate?.();
260
+ },
261
+ };
262
+
263
+ this.clear();
264
+ this.addChild(frame);
265
+ };
266
+
267
+ userMessagePrototype[userMessagePatchKey] = {
268
+ originalRebuild: originalUserMessageRebuild,
269
+ patchedRebuild: patchedUserMessageRebuild,
270
+ };
271
+ userMessagePrototype.rebuild = patchedUserMessageRebuild;
272
+
273
+ pi.on("session_shutdown", () => {
274
+ const patch = userMessagePrototype[userMessagePatchKey];
275
+ if (!patch) return;
276
+ if (patch.patchedRebuild === userMessagePrototype.rebuild) {
277
+ userMessagePrototype.rebuild = patch.originalRebuild;
278
+ }
279
+ if (userMessagePrototype.rebuild === patch.originalRebuild) {
280
+ delete userMessagePrototype[userMessagePatchKey];
281
+ }
282
+ });
283
+ }
284
+
108
285
  // Pi's built-in assistant component turns hidden thinking into a static
109
286
  // "Thinking..." label. In collapsed clean mode, omit that label entirely;
110
287
  // the clean-mode Running(...) row owns activity, while Ctrl+O still reveals
@@ -200,7 +377,13 @@ export default function prettyTui(pi: ExtensionAPI) {
200
377
  const originalSetToolsExpanded = interactiveModePrototype.setToolsExpanded;
201
378
  const patchedSetToolsExpanded = function (this: any, expanded: boolean) {
202
379
  cleanToolsExpanded = expanded;
203
- return originalSetToolsExpanded.call(this, expanded);
380
+ changingAllToolsExpansion = true;
381
+ if (!expanded) cleanCompactToolCallIds.clear();
382
+ try {
383
+ return originalSetToolsExpanded.call(this, expanded);
384
+ } finally {
385
+ changingAllToolsExpansion = false;
386
+ }
204
387
  };
205
388
 
206
389
  interactiveModePrototype[toolsExpansionPatchKey] = {
@@ -472,6 +655,7 @@ export default function prettyTui(pi: ExtensionAPI) {
472
655
  count: number;
473
656
  failed: number;
474
657
  lastToolCallId: string;
658
+ toolCallIds?: string[];
475
659
  activity?: string;
476
660
  };
477
661
  type ToolSummaryData = {
@@ -503,6 +687,8 @@ export default function prettyTui(pi: ExtensionAPI) {
503
687
  requestRender?: () => void;
504
688
  count: number;
505
689
  failed: number;
690
+ currentToolCallIds: string[];
691
+ activeToolCallIds: Set<string>;
506
692
  groups: ToolSummaryGroup[];
507
693
  active: boolean;
508
694
  settled: boolean;
@@ -510,6 +696,8 @@ export default function prettyTui(pi: ExtensionAPI) {
510
696
  const cleanRun: CleanRunState = {
511
697
  count: 0,
512
698
  failed: 0,
699
+ currentToolCallIds: [],
700
+ activeToolCallIds: new Set(),
513
701
  groups: [],
514
702
  active: false,
515
703
  settled: false,
@@ -596,6 +784,9 @@ export default function prettyTui(pi: ExtensionAPI) {
596
784
  return () => heldActivity(hold);
597
785
  };
598
786
 
787
+ const liveCleanToolCount = (): number =>
788
+ Math.max(cleanRun.count, cleanRun.currentToolCallIds.length);
789
+
599
790
  const currentCleanActivity = (): string => {
600
791
  if (cleanRun.activeToolCallId) {
601
792
  return cleanRun.activeToolName ?? cleanRun.activity ?? "thinking...";
@@ -616,10 +807,23 @@ export default function prettyTui(pi: ExtensionAPI) {
616
807
  const toolExecutionPatchKey = Symbol.for("pretty-tui.clean-tool-execution");
617
808
  if (!toolExecutionPrototype[toolExecutionPatchKey]) {
618
809
  const originalMarkExecutionStarted = toolExecutionPrototype.markExecutionStarted;
810
+ const originalSetExpanded = toolExecutionPrototype.setExpanded;
619
811
  const originalToolRender = toolExecutionPrototype.render;
812
+ const originalToolHandleMouse = toolExecutionPrototype.handleMouse;
620
813
  const renderedModeKey = Symbol("pretty-tui.tool-rendered-mode");
814
+ const revealCleanGroup = (groupToolCallIds: string[], ui?: any) => {
815
+ for (const toolCallId of groupToolCallIds) cleanCompactToolCallIds.add(toolCallId);
816
+ for (const toolCallId of groupToolCallIds) cleanToolComponents.get(toolCallId)?.updateDisplay();
817
+ ui?.requestRender?.();
818
+ };
621
819
  const patchedMarkExecutionStarted = function (this: any) {
622
820
  if (supportedTools.has(this.toolName)) {
821
+ if (!cleanRun.currentToolCallIds.includes(this.toolCallId)) {
822
+ cleanRun.currentToolCallIds.push(this.toolCallId);
823
+ }
824
+ cleanRun.activeToolCallIds.add(this.toolCallId);
825
+ cleanToolNames.set(this.toolCallId, toolDisplayName(this.toolName));
826
+ setCleanGroupMembers(this.toolCallId, cleanRun.currentToolCallIds.slice());
623
827
  if (typeof this.ui?.requestRender === "function") {
624
828
  cleanRun.requestRender = () => this.ui.requestRender();
625
829
  }
@@ -629,22 +833,127 @@ export default function prettyTui(pi: ExtensionAPI) {
629
833
  }
630
834
  return originalMarkExecutionStarted.call(this);
631
835
  };
836
+ const patchedSetExpanded = function (this: any, expanded: boolean) {
837
+ const groupToolCallIds = cleanGroupToolCallIds.get(this.toolCallId);
838
+ if (
839
+ renderMode === "clean" &&
840
+ !changingAllToolsExpansion &&
841
+ expanded &&
842
+ !this.expanded &&
843
+ !isCleanGroupRevealed(this.toolCallId) &&
844
+ groupToolCallIds?.length
845
+ ) {
846
+ revealCleanGroup(groupToolCallIds, this.ui);
847
+ return;
848
+ }
849
+ return originalSetExpanded.call(this, expanded);
850
+ };
632
851
  const patchedToolRender = function (this: any, width: number): string[] {
852
+ if (supportedTools.has(this.toolName)) {
853
+ cleanToolComponents.set(this.toolCallId, this);
854
+ knownToolCallIds.add(this.toolCallId);
855
+ }
633
856
  if (this[renderedModeKey] !== renderMode) {
634
857
  this[renderedModeKey] = renderMode;
635
858
  this.updateDisplay();
636
859
  }
637
- return originalToolRender.call(this, width);
860
+
861
+ const groupOwner = cleanToolCallGroupOwners.get(this.toolCallId);
862
+ const groupToolCallIds = groupOwner ? cleanGroupToolCallIds.get(groupOwner) : undefined;
863
+ const childIndex = groupToolCallIds?.indexOf(this.toolCallId) ?? -1;
864
+ const showAsChild = renderMode === "clean" && childIndex >= 0 && isCleanGroupRevealed(this.toolCallId);
865
+ if (!showAsChild || !groupOwner || !groupToolCallIds) {
866
+ return originalToolRender.call(this, width);
867
+ }
868
+
869
+ const rawChildPrefix = childIndex === groupToolCallIds.length - 1 ? " └─ " : " ├─ ";
870
+ const rawContinuation = childIndex === groupToolCallIds.length - 1 ? " " : " │ ";
871
+ const childTheme = cleanThemeForToolCall(this.toolCallId);
872
+ const childPrefix = childTheme ? childTheme.fg("dim", rawChildPrefix) : rawChildPrefix;
873
+ const continuation = childTheme ? childTheme.fg("dim", rawContinuation) : rawContinuation;
874
+ const childWidth = Math.max(1, width - visibleWidth(rawChildPrefix));
875
+ const lines = originalToolRender.call(this, childWidth);
876
+ if (lines.length === 0) return lines;
877
+
878
+ const decoratedContent = lines.slice(1).map((line: string, index: number) =>
879
+ (index === 0 ? childPrefix : continuation) + line
880
+ );
881
+ if (childIndex !== 0) return decoratedContent;
882
+ return [lines[0], ...renderCleanGroupSummary(groupOwner, width), ...decoratedContent];
883
+ };
884
+ const patchedToolHandleMouse = function (this: any, event: any) {
885
+ const groupOwner = cleanToolCallGroupOwners.get(this.toolCallId);
886
+ const groupToolCallIds = groupOwner ? cleanGroupToolCallIds.get(groupOwner) : undefined;
887
+ const childIndex = groupToolCallIds?.indexOf(this.toolCallId) ?? -1;
888
+ const showAsChild = renderMode === "clean" && childIndex >= 0 && isCleanGroupRevealed(this.toolCallId);
889
+ const isLeftClick = event.type === "click" && event.button === "left";
890
+
891
+ // Pi normally ignores tool clicks until a result exists. Clean mode can
892
+ // still reveal the active call safely because this changes presentation
893
+ // only; it does not affect or delay execution.
894
+ if (
895
+ renderMode === "clean" &&
896
+ !showAsChild &&
897
+ isLeftClick &&
898
+ this.toolCallId === groupOwner &&
899
+ groupToolCallIds?.length
900
+ ) {
901
+ revealCleanGroup(groupToolCallIds, this.ui);
902
+ return { handled: true };
903
+ }
904
+ if (!showAsChild || !groupToolCallIds) return originalToolHandleMouse.call(this, event);
905
+
906
+ const summaryHeight = childIndex === 0 ? renderCleanGroupSummary(groupOwner!, event.width).length : 0;
907
+ if (
908
+ childIndex === 0 &&
909
+ event.y > 0 &&
910
+ event.y <= summaryHeight &&
911
+ isLeftClick
912
+ ) {
913
+ changingAllToolsExpansion = true;
914
+ try {
915
+ for (const toolCallId of groupToolCallIds) {
916
+ const component = cleanToolComponents.get(toolCallId);
917
+ if (component?.expanded) originalSetExpanded.call(component, false);
918
+ cleanCompactToolCallIds.delete(toolCallId);
919
+ }
920
+ for (const toolCallId of groupToolCallIds) cleanToolComponents.get(toolCallId)?.updateDisplay();
921
+ } finally {
922
+ changingAllToolsExpansion = false;
923
+ }
924
+ this.ui?.requestRender?.();
925
+ return { handled: true };
926
+ }
927
+
928
+ if (!this.result && isLeftClick) {
929
+ originalSetExpanded.call(this, !this.expanded);
930
+ this.ui?.requestRender?.();
931
+ return { handled: true };
932
+ }
933
+
934
+ const childPrefixWidth = visibleWidth(" ├─ ");
935
+ return originalToolHandleMouse.call(this, {
936
+ ...event,
937
+ x: Math.max(0, event.x - childPrefixWidth),
938
+ y: event.y - summaryHeight + (childIndex === 0 ? 0 : 1),
939
+ width: Math.max(1, event.width - childPrefixWidth),
940
+ });
638
941
  };
639
942
 
640
943
  toolExecutionPrototype[toolExecutionPatchKey] = {
641
944
  originalMarkExecutionStarted,
642
945
  patchedMarkExecutionStarted,
946
+ originalSetExpanded,
947
+ patchedSetExpanded,
643
948
  originalRender: originalToolRender,
644
949
  patchedRender: patchedToolRender,
950
+ originalHandleMouse: originalToolHandleMouse,
951
+ patchedHandleMouse: patchedToolHandleMouse,
645
952
  };
646
953
  toolExecutionPrototype.markExecutionStarted = patchedMarkExecutionStarted;
954
+ toolExecutionPrototype.setExpanded = patchedSetExpanded;
647
955
  toolExecutionPrototype.render = patchedToolRender;
956
+ toolExecutionPrototype.handleMouse = patchedToolHandleMouse;
648
957
 
649
958
  pi.on("session_shutdown", () => {
650
959
  const patch = toolExecutionPrototype[toolExecutionPatchKey];
@@ -652,12 +961,20 @@ export default function prettyTui(pi: ExtensionAPI) {
652
961
  if (patch.patchedMarkExecutionStarted === toolExecutionPrototype.markExecutionStarted) {
653
962
  toolExecutionPrototype.markExecutionStarted = patch.originalMarkExecutionStarted;
654
963
  }
964
+ if (patch.patchedSetExpanded === toolExecutionPrototype.setExpanded) {
965
+ toolExecutionPrototype.setExpanded = patch.originalSetExpanded;
966
+ }
655
967
  if (patch.patchedRender === toolExecutionPrototype.render) {
656
968
  toolExecutionPrototype.render = patch.originalRender;
657
969
  }
970
+ if (patch.patchedHandleMouse === toolExecutionPrototype.handleMouse) {
971
+ toolExecutionPrototype.handleMouse = patch.originalHandleMouse;
972
+ }
658
973
  if (
659
974
  toolExecutionPrototype.markExecutionStarted === patch.originalMarkExecutionStarted &&
660
- toolExecutionPrototype.render === patch.originalRender
975
+ toolExecutionPrototype.setExpanded === patch.originalSetExpanded &&
976
+ toolExecutionPrototype.render === patch.originalRender &&
977
+ toolExecutionPrototype.handleMouse === patch.originalHandleMouse
661
978
  ) {
662
979
  delete toolExecutionPrototype[toolExecutionPatchKey];
663
980
  }
@@ -670,6 +987,7 @@ export default function prettyTui(pi: ExtensionAPI) {
670
987
  count: cleanRun.count,
671
988
  failed: cleanRun.failed,
672
989
  lastToolCallId: cleanRun.lastCompletedToolCallId,
990
+ toolCallIds: cleanRun.currentToolCallIds.slice(),
673
991
  activity,
674
992
  };
675
993
  cleanRun.groups.push(group);
@@ -678,9 +996,12 @@ export default function prettyTui(pi: ExtensionAPI) {
678
996
  failed: group.failed,
679
997
  activity: activityValueForHold(group.lastToolCallId, activity),
680
998
  });
999
+ setCleanGroupMembers(group.lastToolCallId, group.toolCallIds ?? []);
681
1000
  }
682
1001
  cleanRun.count = 0;
683
1002
  cleanRun.failed = 0;
1003
+ cleanRun.currentToolCallIds = [];
1004
+ cleanRun.activeToolCallIds.clear();
684
1005
  cleanRun.lastCompletedToolCallId = undefined;
685
1006
  cleanRun.activeToolCallId = undefined;
686
1007
  cleanRun.activeToolName = undefined;
@@ -728,6 +1049,27 @@ export default function prettyTui(pi: ExtensionAPI) {
728
1049
  },
729
1050
  });
730
1051
 
1052
+ renderCleanGroupSummary = (lastToolCallId: string, width: number): string[] => {
1053
+ const summaryTheme = cleanThemeForToolCall(lastToolCallId);
1054
+ if (!summaryTheme) return [];
1055
+ const settled = settledSummaries.get(lastToolCallId);
1056
+ if (settled) {
1057
+ return block([summaryRow(summaryTheme, settled.count, settled.failed, settled.activity)]).render(width);
1058
+ }
1059
+ if (cleanRun.activeToolCallId === lastToolCallId) {
1060
+ return block([summaryRow(
1061
+ summaryTheme,
1062
+ liveCleanToolCount(),
1063
+ cleanRun.failed,
1064
+ () => cleanRun.activeToolName ?? currentCleanActivity(),
1065
+ )]).render(width);
1066
+ }
1067
+ if (cleanRun.lastCompletedToolCallId === lastToolCallId && cleanRun.count > 0) {
1068
+ return block([summaryRow(summaryTheme, cleanRun.count, cleanRun.failed, currentCleanActivity)]).render(width);
1069
+ }
1070
+ return [];
1071
+ };
1072
+
731
1073
  /**
732
1074
  * Clean mode keeps Running/Done rows in the existing tool components. This
733
1075
  * avoids waiting for agent_end (which can be followed by retry/compaction),
@@ -735,6 +1077,7 @@ export default function prettyTui(pi: ExtensionAPI) {
735
1077
  */
736
1078
  const cleanToolCall = (theme: any, name: string, toolCallId: string): Component => {
737
1079
  knownToolCallIds.add(toolCallId);
1080
+ cleanToolThemes.set(toolCallId, theme);
738
1081
  return {
739
1082
  render(width: number): string[] {
740
1083
  if (renderMode !== "clean") return [];
@@ -748,7 +1091,7 @@ export default function prettyTui(pi: ExtensionAPI) {
748
1091
  if (cleanRun.activeToolCallId === toolCallId) {
749
1092
  return block([summaryRow(
750
1093
  theme,
751
- cleanRun.count + 1,
1094
+ liveCleanToolCount(),
752
1095
  cleanRun.failed,
753
1096
  () => cleanRun.activeToolName ?? name,
754
1097
  )]).render(width);
@@ -783,10 +1126,16 @@ export default function prettyTui(pi: ExtensionAPI) {
783
1126
  // In collapsed clean mode, every supported tool call is represented by its
784
1127
  // Running row. Ctrl+O bypasses this and restores the normal full renderer.
785
1128
  const hideCleanTool = (
786
- _toolCallId: string,
1129
+ toolCallId: string,
787
1130
  expanded: boolean,
788
1131
  _executionStarted = false,
789
- ): boolean => renderMode === "clean" && !expanded;
1132
+ ): boolean => renderMode === "clean" && !expanded && !isCleanGroupRevealed(toolCallId);
1133
+
1134
+ const useCompactToolView = (toolCallId: string, expanded: boolean): boolean =>
1135
+ !expanded && (
1136
+ renderMode === "compact" ||
1137
+ (renderMode === "clean" && isCleanGroupRevealed(toolCallId))
1138
+ );
790
1139
 
791
1140
  pi.registerEntryRenderer<ToolSummaryData>("pretty-tui-tool-summary", (entry, { expanded }, theme) => ({
792
1141
  render(width: number): string[] {
@@ -825,14 +1174,33 @@ export default function prettyTui(pi: ExtensionAPI) {
825
1174
  invalidate() {},
826
1175
  }));
827
1176
 
828
- pi.on("session_start", (_event, ctx) => {
1177
+ const messageContentItems = (message: any): any[] =>
1178
+ Array.isArray(message?.content) ? message.content : [];
1179
+
1180
+ const messageHasVisibleText = (message: any): boolean =>
1181
+ typeof message?.content === "string"
1182
+ ? message.content.trim().length > 0
1183
+ : messageContentItems(message).some(
1184
+ (item: any) => item.type === "text" && typeof item.text === "string" && item.text.trim().length > 0,
1185
+ );
1186
+
1187
+ const restoreCleanSession = (ctx: any) => {
829
1188
  cleanToolsExpanded = ctx.ui.getToolsExpanded();
830
1189
  settledSummaries.clear();
831
1190
  legacySummaryLastToolCallIds.clear();
1191
+ knownToolCallIds.clear();
1192
+ cleanCompactToolCallIds.clear();
1193
+ cleanGroupToolCallIds.clear();
1194
+ cleanToolCallGroupOwners.clear();
1195
+ cleanToolComponents.clear();
1196
+ cleanToolThemes.clear();
1197
+ cleanToolNames.clear();
832
1198
  clearToolActivityHolds();
833
1199
  cleanRun.requestRender = undefined;
834
1200
  cleanRun.count = 0;
835
1201
  cleanRun.failed = 0;
1202
+ cleanRun.currentToolCallIds = [];
1203
+ cleanRun.activeToolCallIds.clear();
836
1204
  cleanRun.groups = [];
837
1205
  cleanRun.activeToolCallId = undefined;
838
1206
  cleanRun.activeToolName = undefined;
@@ -854,13 +1222,17 @@ export default function prettyTui(pi: ExtensionAPI) {
854
1222
  failed: group.failed,
855
1223
  activity: "done",
856
1224
  });
1225
+ if (group.toolCallIds?.length) {
1226
+ setCleanGroupMembers(group.lastToolCallId, group.toolCallIds.slice());
1227
+ }
857
1228
  if (entryId) legacySummaryLastToolCallIds.set(entryId, group.lastToolCallId);
858
1229
  explicitSummaryIds.add(group.lastToolCallId);
859
1230
  };
860
1231
 
861
1232
  const finishGroup = () => {
862
1233
  if (lastToolCallId && count > 0) {
863
- lastFinishedGroup = { count, failed, lastToolCallId };
1234
+ lastFinishedGroup = { count, failed, lastToolCallId, toolCallIds: [...toolCalls] };
1235
+ setCleanGroupMembers(lastToolCallId, [...toolCalls]);
864
1236
  if (!explicitSummaryIds.has(lastToolCallId)) {
865
1237
  settledSummaries.set(lastToolCallId, { count, failed, activity: "done" });
866
1238
  }
@@ -916,11 +1288,8 @@ export default function prettyTui(pi: ExtensionAPI) {
916
1288
  }
917
1289
 
918
1290
  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 ?? []) {
1291
+ if (messageHasVisibleText(message)) finishGroup();
1292
+ for (const item of messageContentItems(message)) {
924
1293
  if (item.type !== "toolCall" || !supportedTools.has(item.name)) continue;
925
1294
  count++;
926
1295
  lastToolCallId = item.id;
@@ -935,21 +1304,37 @@ export default function prettyTui(pi: ExtensionAPI) {
935
1304
  }
936
1305
 
937
1306
  finishGroup();
1307
+ };
1308
+
1309
+ pi.on("session_start", (_event, ctx) => restoreCleanSession(ctx));
1310
+ pi.on("session_tree", (_event, ctx) => restoreCleanSession(ctx));
1311
+ pi.on("session_compact", () => {
1312
+ // Tool components removed by compaction must no longer suppress the
1313
+ // durable summary fallback. Remaining components register again as Pi
1314
+ // renders the rebuilt transcript.
1315
+ knownToolCallIds.clear();
938
1316
  });
939
1317
 
940
1318
  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
- );
1319
+ message?.role === "assistant" && messageHasVisibleText(message);
944
1320
 
945
- const latestPendingToolCall = (message: any): any =>
946
- [...(message?.content ?? [])].reverse().find(
1321
+ const pendingToolCalls = (message: any): any[] =>
1322
+ messageContentItems(message).filter(
947
1323
  (item: any) => item.type === "toolCall" && supportedTools.has(item.name) && item.id,
948
1324
  );
949
1325
 
950
1326
  const trackPendingToolActivity = (message: any): boolean => {
951
- const toolCall = latestPendingToolCall(message);
952
- if (!toolCall || cleanRun.lastCompletedToolCallId === toolCall.id) return false;
1327
+ const toolCalls = pendingToolCalls(message);
1328
+ if (toolCalls.length === 0) return false;
1329
+ for (const toolCall of toolCalls) {
1330
+ if (!cleanRun.currentToolCallIds.includes(toolCall.id)) {
1331
+ cleanRun.currentToolCallIds.push(toolCall.id);
1332
+ }
1333
+ cleanToolNames.set(toolCall.id, toolDisplayName(toolCall.name));
1334
+ }
1335
+ const toolCall = toolCalls[toolCalls.length - 1];
1336
+ if (cleanRun.lastCompletedToolCallId === toolCall.id) return false;
1337
+ setCleanGroupMembers(toolCall.id, cleanRun.currentToolCallIds.slice());
953
1338
  cleanRun.active = true;
954
1339
  cleanRun.activeToolCallId = toolCall.id;
955
1340
  beginToolActivity(toolCall.id, toolDisplayName(toolCall.name));
@@ -996,17 +1381,26 @@ export default function prettyTui(pi: ExtensionAPI) {
996
1381
  clearToolActivityHolds();
997
1382
  cleanRun.count = 0;
998
1383
  cleanRun.failed = 0;
1384
+ cleanRun.currentToolCallIds = [];
1385
+ cleanRun.activeToolCallIds.clear();
999
1386
  cleanRun.groups = [];
1000
1387
  cleanRun.lastCompletedToolCallId = undefined;
1001
1388
  cleanRun.settled = false;
1002
1389
  }
1003
1390
  cleanRun.active = true;
1391
+ cleanRun.activeToolCallIds.clear();
1004
1392
  cleanRun.activeToolCallId = undefined;
1005
1393
  cleanRun.activeToolName = undefined;
1006
1394
  cleanRun.activity = "thinking...";
1007
1395
  });
1008
1396
  pi.on("tool_execution_start", (event) => {
1009
1397
  if (!supportedTools.has(event.toolName)) return;
1398
+ if (!cleanRun.currentToolCallIds.includes(event.toolCallId)) {
1399
+ cleanRun.currentToolCallIds.push(event.toolCallId);
1400
+ }
1401
+ cleanRun.activeToolCallIds.add(event.toolCallId);
1402
+ cleanToolNames.set(event.toolCallId, toolDisplayName(event.toolName));
1403
+ setCleanGroupMembers(event.toolCallId, cleanRun.currentToolCallIds.slice());
1010
1404
  cleanRun.active = true;
1011
1405
  cleanRun.activeToolCallId = event.toolCallId;
1012
1406
  beginToolActivity(event.toolCallId, toolDisplayName(event.toolName), true);
@@ -1022,16 +1416,24 @@ export default function prettyTui(pi: ExtensionAPI) {
1022
1416
  cleanRun.count++;
1023
1417
  if (event.isError) cleanRun.failed++;
1024
1418
  cleanRun.lastCompletedToolCallId = event.toolCallId;
1419
+ cleanRun.activeToolCallIds.delete(event.toolCallId);
1025
1420
  if (cleanRun.activeToolCallId === event.toolCallId) {
1026
- cleanRun.activeToolCallId = undefined;
1027
- cleanRun.activeToolName = undefined;
1028
- cleanRun.activity = "thinking...";
1421
+ cleanRun.activeToolCallId = [...cleanRun.currentToolCallIds]
1422
+ .reverse()
1423
+ .find((toolCallId) => cleanRun.activeToolCallIds.has(toolCallId));
1424
+ cleanRun.activeToolName = cleanRun.activeToolCallId
1425
+ ? cleanToolNames.get(cleanRun.activeToolCallId)
1426
+ : undefined;
1427
+ cleanRun.activity = cleanRun.activeToolName ?? "thinking...";
1029
1428
  }
1429
+ const visibleSummaryToolCallId = cleanRun.activeToolCallId ?? cleanRun.lastCompletedToolCallId;
1430
+ setCleanGroupMembers(visibleSummaryToolCallId, cleanRun.currentToolCallIds.slice());
1030
1431
  });
1031
1432
  pi.on("agent_end", () => {
1032
1433
  // No summary is appended here: this event may be followed by an automatic
1033
1434
  // retry or compaction. The live row remains available until settled.
1034
1435
  clearPendingToolActivities();
1436
+ cleanRun.activeToolCallIds.clear();
1035
1437
  cleanRun.activeToolCallId = undefined;
1036
1438
  cleanRun.activeToolName = undefined;
1037
1439
  cleanRun.activity = "thinking...";
@@ -1051,6 +1453,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1051
1453
  });
1052
1454
  }
1053
1455
  cleanRun.active = false;
1456
+ cleanRun.activeToolCallIds.clear();
1054
1457
  cleanRun.activeToolCallId = undefined;
1055
1458
  cleanRun.activeToolName = undefined;
1056
1459
  cleanRun.activity = "done";
@@ -1066,7 +1469,12 @@ export default function prettyTui(pi: ExtensionAPI) {
1066
1469
  count,
1067
1470
  failed,
1068
1471
  lastToolCallId,
1069
- groups: groups.map(({ count, failed, lastToolCallId }) => ({ count, failed, lastToolCallId })),
1472
+ groups: groups.map(({ count, failed, lastToolCallId, toolCallIds }) => ({
1473
+ count,
1474
+ failed,
1475
+ lastToolCallId,
1476
+ toolCallIds,
1477
+ })),
1070
1478
  });
1071
1479
  });
1072
1480
 
@@ -1107,7 +1515,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1107
1515
  renderCall(args: any, theme: any, context: any) {
1108
1516
  if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, "Bash", context.toolCallId);
1109
1517
  const command = typeof args.command === "string" ? args.command : "";
1110
- if (renderMode === "compact" && !context.expanded) {
1518
+ if (useCompactToolView(context.toolCallId, context.expanded)) {
1111
1519
  const commandLines = command.split(/\r\n|\r|\n/);
1112
1520
  const firstLine = commandLines[0] ?? "";
1113
1521
  const omitted = commandLines.length - 1;
@@ -1121,7 +1529,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1121
1529
  renderResult(toolResult: any, options: any, theme: any, context: any) {
1122
1530
  const output = textContent(toolResult);
1123
1531
  if (hideCleanTool(context.toolCallId, options.expanded, context.executionStarted)) return hiddenToolResult(context, options, output);
1124
- if (renderMode === "compact" && !options.expanded) {
1532
+ if (useCompactToolView(context.toolCallId, options.expanded)) {
1125
1533
  if (options.isPartial) return partialResult(context, theme, "Running…");
1126
1534
  const failed = completeStatus(context, output);
1127
1535
  return result(theme, failed ? "Command failed" : "Done", "", false, failed);
@@ -1199,7 +1607,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1199
1607
  if (hideCleanTool(context.toolCallId, context.expanded, context.executionStarted)) return cleanToolCall(theme, "Write", context.toolCallId);
1200
1608
  const content = typeof args.content === "string" ? args.content : "";
1201
1609
  const path = String(args.path ?? "");
1202
- if (renderMode === "compact" && !context.expanded) {
1610
+ if (useCompactToolView(context.toolCallId, context.expanded)) {
1203
1611
  const lines = content.replace(/\t/g, " ").split("\n");
1204
1612
  while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
1205
1613
  const total = lines.length;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-pretty-tui",
3
- "version": "0.1.4",
4
- "description": "Polished tool calls and list rendering for the Pi coding agent TUI",
3
+ "version": "0.1.6",
4
+ "description": "Polished user messages, 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",
@@ -40,6 +40,6 @@
40
40
  "extensions": [
41
41
  "./extensions/index.ts"
42
42
  ],
43
- "image": "https://unpkg.com/pi-pretty-tui@0.1.4/assets/screenshot.png"
43
+ "image": "https://unpkg.com/pi-pretty-tui@0.1.6/assets/screenshot.png"
44
44
  }
45
45
  }