pi-better-harness 0.22.0 → 0.23.0

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
@@ -68,14 +68,24 @@ The bundled harness provides **Tool output: Normal / Minimal** in
68
68
  `/harness-settings`. `/tool-output minimal`, `/tool-output normal`, and
69
69
  `/tool-output` (toggle) remain compatibility shortcuts to the same preference.
70
70
  Minimal mode folds each tool call into a single-line header, including running
71
- calls. Muted tool names and quieter inline command/path context distinguish
72
- activity from conversation text; running and failed calls retain visible state
73
- markers. Long headers are truncated to the terminal width; result bodies,
71
+ calls. Each row has a single tool-specific icon: accent-colored while running,
72
+ muted when completed, and error-colored when failed. Tool names stay muted;
73
+ inline command/path arguments use the theme's distinct dim tone. Running and
74
+ failed calls retain text labels so state never depends on color alone. Activity
75
+ stays quieter than conversation text. Tool rows are indented two columns past
76
+ the assistant text padding. Long
77
+ headers are truncated to the terminal width; result bodies,
74
78
  images, boxes, and tool spacers are hidden. Built-in, extension, and MCP calls
75
79
  are included. Execution, sandboxing, and agent-facing payloads are unchanged.
76
- In newer Pi fullscreen mode, click a folded row to expand its original details,
77
- then click the expanded call/result to collapse it. Regular terminals keep mouse
78
- input for terminal selection and scrollback; Ctrl+O expands tools in all modes.
80
+ When a foreground run ends, each consecutive block of tool calls folds further
81
+ into one disclosure row showing the call count and any failures. Only the failure
82
+ count uses the error color; the disclosure and total stay muted. Assistant text
83
+ stays visible, and restored history uses the same folded view. In newer Pi
84
+ fullscreen mode, click a block disclosure to reveal its call rows, then click a
85
+ call to expand its original details. Click the disclosure again to refold the
86
+ block; native expanded call/result clicks collapse individual details. Regular
87
+ terminals keep mouse input for terminal selection and scrollback; Ctrl+O expands
88
+ all original tool details in both modes and folds them again on the next toggle.
79
89
  `/tool-output normal` restores ordinary rendering. Error result bodies are also
80
90
  hidden in minimal mode and remain available when expanded.
81
91
 
@@ -8,6 +8,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
8
8
 
9
9
  interface ToolComponent {
10
10
  toolName: string;
11
+ toolCallId: string;
11
12
  args: unknown;
12
13
  callRendererComponent?: Component;
13
14
  expanded: boolean;
@@ -15,12 +16,19 @@ interface ToolComponent {
15
16
  result?: { isError?: boolean };
16
17
  showImages: boolean;
17
18
  resultRendererComponent?: unknown;
18
- ui: { requestRender(): void };
19
+ ui: { requestRender(): void; children?: TranscriptComponent[] };
19
20
  invalidate(): void;
20
21
  setExpanded(expanded: boolean): void;
21
22
  render(width: number): string[];
22
23
  }
23
24
 
25
+ interface TranscriptComponent extends Component {
26
+ children?: TranscriptComponent[];
27
+ outputPad?: number;
28
+ lastMessage?: { content: { type: string; text?: string; thinking?: string }[]; stopReason?: string };
29
+ }
30
+ interface ToolGroup { members: ToolComponent[]; open: boolean; expanded: number }
31
+
24
32
  type Method = (this: ToolComponent, ...args: unknown[]) => unknown;
25
33
  interface ToolMouseEvent { type: string; button: string; y: number; shift?: boolean; alt?: boolean; ctrl?: boolean }
26
34
  type MouseHandler = (this: ToolComponent, event: ToolMouseEvent) => unknown;
@@ -39,16 +47,29 @@ interface HookState {
39
47
  owners: number;
40
48
  redraw(collapse?: boolean): void;
41
49
  restore(): void;
50
+ completeRun(): void;
51
+ restoreCompletedCalls(ids: Iterable<string>): void;
42
52
  }
43
53
  export interface MinimalOutputHook {
44
54
  setEnabled(enabled: boolean): void;
55
+ completeRun(): void;
56
+ restoreCompletedCalls(ids: Iterable<string>): void;
45
57
  dispose(): void;
46
58
  }
47
59
 
48
60
  const HOOK = Symbol.for("pi-better-harness.minimal-output-hook");
49
61
  const emptyResult = () => ({ render: () => [] as string[], invalidate() {} });
62
+ const TOOL_ICONS: Record<string, string> = {
63
+ bash: "\u2318",
64
+ read: "\u25a4",
65
+ write: "\u2710",
66
+ edit: "\u270e",
67
+ grep: "\u2315",
68
+ find: "\u2316",
69
+ ls: "\u2261",
70
+ };
50
71
 
51
- function compactCall(component: ToolComponent, width: number, theme?: Theme): string[] {
72
+ function compactCall(component: ToolComponent, width: number, theme?: Theme, indent = 3): string[] {
52
73
  if (width <= 0) return [];
53
74
  // Render the call alone at a wider width so terminal wrapping becomes truncation.
54
75
  const header = component.callRendererComponent?.render(Math.max(4096, width + 1))
@@ -67,13 +88,35 @@ function compactCall(component: ToolComponent, width: number, theme?: Theme): st
67
88
  : text === component.toolName ? "" : text;
68
89
  const label = component.toolName === "bash" ? "Shell"
69
90
  : component.toolName.replace(/_/g, " ").replace(/^./, letter => letter.toUpperCase());
70
- const marker = failed ? "!" : running ? "~" : ">";
71
91
  const suffix = failed ? " (failed)" : running ? " (running)" : "";
72
- const title = `${marker} ${label}`;
92
+ const icon = TOOL_ICONS[component.toolName] ?? "\u25c7";
93
+ const title = `${icon} ${label}`;
73
94
  const styled = theme
74
- ? `${theme.fg(failed ? "error" : "muted", title)}${detail ? ` ${theme.fg("dim", detail)}` : ""}${theme.fg(failed ? "error" : "muted", suffix)}`
95
+ ? `${theme.fg(failed ? "error" : running ? "accent" : "muted", icon)} ${theme.fg("muted", label)}${detail ? ` ${theme.fg("dim", detail)}` : ""}${theme.fg(failed ? "error" : "dim", suffix)}`
75
96
  : `${title}${detail ? ` ${detail}` : ""}${suffix}`;
76
- return [truncateToWidth(styled, width)];
97
+ return [truncateToWidth(`${" ".repeat(Math.min(indent, Math.max(0, width - 1)))}${styled}`, width)];
98
+ }
99
+
100
+ function toolParent(component: ToolComponent): TranscriptComponent | undefined {
101
+ const visit = (children: TranscriptComponent[]): TranscriptComponent | undefined => {
102
+ for (const child of children) {
103
+ if (child.children?.includes(component)) return child;
104
+ if (child.children) {
105
+ const found = visit(child.children);
106
+ if (found) return found;
107
+ }
108
+ }
109
+ return undefined;
110
+ };
111
+ return visit(component.ui.children ?? []);
112
+ }
113
+
114
+ function paddingAnchor(component: ToolComponent, parent?: TranscriptComponent): TranscriptComponent | undefined {
115
+ const siblings = parent?.children ?? [];
116
+ for (let index = siblings.indexOf(component) - 1; index >= 0; index--) {
117
+ if (typeof siblings[index].outputPad === "number") return siblings[index];
118
+ }
119
+ return undefined;
77
120
  }
78
121
 
79
122
  function hostRequire(): NodeRequire {
@@ -136,14 +179,72 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
136
179
  updateDisplay: prototype.updateDisplay,
137
180
  getResultRenderer: prototype.getResultRenderer,
138
181
  getTextOutput: prototype.getTextOutput,
182
+ setExpanded: prototype.setExpanded,
139
183
  render: prototype.render,
140
184
  ...(typeof prototype.handleMouse === "function" ? { handleMouse: prototype.handleMouse } : {}),
141
185
  };
186
+ let mouseHandling = false;
142
187
  const seen = new WeakSet<ToolComponent>();
143
188
  const components = new Set<WeakRef<ToolComponent>>();
189
+ let completed = new Map<string, object>();
190
+ let groups = new WeakMap<ToolComponent, ToolGroup>();
191
+ let layouts = new WeakMap<ToolComponent, { parent: TranscriptComponent; anchor?: TranscriptComponent }>();
192
+ const layoutFor = (component: ToolComponent) => {
193
+ const cached = layouts.get(component);
194
+ if (cached) return cached;
195
+ const parent = toolParent(component);
196
+ if (!parent) return undefined;
197
+ const layout = { parent, anchor: paddingAnchor(component, parent) };
198
+ layouts.set(component, layout);
199
+ return layout;
200
+ };
201
+ const groupFor = (component: ToolComponent, parent?: TranscriptComponent): ToolGroup | undefined => {
202
+ const cached = groups.get(component);
203
+ if (cached) return cached;
204
+ if (!completed.has(component.toolCallId) || component.isPartial || !parent?.children) return undefined;
205
+ let members: ToolComponent[] = [];
206
+ const save = () => {
207
+ if (!members.length) return;
208
+ const group = { members, open: false, expanded: members.filter(member => member.expanded).length };
209
+ for (const member of members) groups.set(member, group);
210
+ members = [];
211
+ };
212
+ for (const child of parent.children) {
213
+ const tool = child as ToolComponent;
214
+ if (seen.has(tool) && completed.has(tool.toolCallId) && !tool.isPartial && !groups.has(tool)) {
215
+ if (members.length && completed.get(members[0].toolCallId) !== completed.get(tool.toolCallId)) save();
216
+ members.push(tool);
217
+ } else if (typeof child.outputPad === "number" && child.lastMessage
218
+ && !["length", "error", "aborted"].includes(child.lastMessage.stopReason ?? "")
219
+ && !child.lastMessage.content.some(content => content.text?.trim() || content.thinking?.trim())) {
220
+ // Tool-only assistant messages occupy no transcript rows and do not split a visual block.
221
+ continue;
222
+ } else save();
223
+ }
224
+ save();
225
+ return groups.get(component);
226
+ };
144
227
  state = {
145
228
  enabled: false,
146
229
  owners: 0,
230
+ completeRun() {
231
+ const run = {};
232
+ for (const reference of components) {
233
+ const component = reference.deref();
234
+ if (component?.result && !component.isPartial && !completed.has(component.toolCallId)) {
235
+ completed.set(component.toolCallId, run);
236
+ if (this.enabled) component.setExpanded(false);
237
+ }
238
+ }
239
+ this.redraw();
240
+ },
241
+ restoreCompletedCalls(ids) {
242
+ const history = {};
243
+ completed = new Map(Array.from(ids, id => [id, history]));
244
+ groups = new WeakMap();
245
+ layouts = new WeakMap();
246
+ this.redraw();
247
+ },
147
248
  redraw(collapse = false) {
148
249
  for (const reference of components) {
149
250
  const component = reference.deref();
@@ -154,7 +255,7 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
154
255
  }
155
256
  },
156
257
  restore() {
157
- for (const name of ["updateDisplay", "getResultRenderer", "getTextOutput", "render"] as const) {
258
+ for (const name of ["updateDisplay", "getResultRenderer", "getTextOutput", "setExpanded", "render"] as const) {
158
259
  // Do not remove another extension's subsequently installed wrapper.
159
260
  if (prototype[name] === wrappers[name]) Object.assign(prototype, { [name]: originals[name] });
160
261
  }
@@ -179,6 +280,14 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
179
280
  this.resultRendererComponent = lastRenderer;
180
281
  }
181
282
  },
283
+ setExpanded(this: ToolComponent, ...args: unknown[]) {
284
+ const group = groups.get(this);
285
+ const wasExpanded = this.expanded;
286
+ if (args[0] === false && !mouseHandling && group) group.open = false;
287
+ const result = originals.setExpanded.apply(this, args);
288
+ if (group) group.expanded += Number(this.expanded) - Number(wasExpanded);
289
+ return result;
290
+ },
182
291
  getResultRenderer(this: ToolComponent, ...args: unknown[]) {
183
292
  return current.enabled && !this.expanded ? emptyResult : originals.getResultRenderer.apply(this, args);
184
293
  },
@@ -186,17 +295,47 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
186
295
  return current.enabled && !this.expanded ? "" : originals.getTextOutput.apply(this, args);
187
296
  },
188
297
  render(this: ToolComponent, width: number): string[] {
189
- return current.enabled && !this.expanded ? compactCall(this, width, getTheme?.()) : originals.render.call(this, width);
298
+ if (!current.enabled || this.expanded) return originals.render.call(this, width);
299
+ const layout = layoutFor(this);
300
+ const padding = layout?.anchor?.outputPad ?? 1;
301
+ const indent = Number.isFinite(padding) ? Math.max(0, Math.floor(padding)) + 2 : 3;
302
+ const group = groupFor(this, layout?.parent);
303
+ if (!group) return compactCall(this, width, getTheme?.(), indent);
304
+ const open = group.open || group.expanded > 0;
305
+ const first = group.members[0] === this;
306
+ const lines: string[] = [];
307
+ if (first && width > 0) {
308
+ const failed = group.members.filter(member => member.result?.isError).length;
309
+ const text = `${open ? "\u25be" : "\u25b8"} ${group.members.length} tool call${group.members.length === 1 ? "" : "s"}`;
310
+ const failure = failed ? `${failed} failed` : "";
311
+ const theme = getTheme?.();
312
+ const styled = theme
313
+ ? `${theme.fg("muted", text)}${failed ? `${theme.fg("dim", " \u00b7 ")}${theme.fg("error", failure)}` : ""}`
314
+ : `${text}${failed ? ` \u00b7 ${failure}` : ""}`;
315
+ lines.push(truncateToWidth(`${" ".repeat(Math.min(indent, Math.max(0, width - 1)))}${styled}`, width));
316
+ }
317
+ if (open) lines.push(...compactCall(this, width, getTheme?.(), indent + 2));
318
+ return lines;
190
319
  },
191
320
  };
192
321
  const mouseWrapper: MouseHandler = function(event) {
193
322
  if (current.enabled && !this.expanded) {
194
- if (event.type !== "click" || event.button !== "left" || event.y !== 0 || event.shift || event.alt || event.ctrl) return undefined;
323
+ if (event.type !== "click" || event.button !== "left" || event.shift || event.alt || event.ctrl) return undefined;
324
+ const group = groupFor(this, layoutFor(this)?.parent);
325
+ if (group?.members[0] === this && event.y === 0) {
326
+ group.open = !(group.open || group.expanded > 0);
327
+ if (!group.open) for (const member of group.members) member.setExpanded(false);
328
+ this.ui.requestRender();
329
+ return { handled: true, render: true };
330
+ }
331
+ if (event.y !== (group?.members[0] === this ? 1 : 0)) return undefined;
195
332
  this.setExpanded(true);
196
333
  this.ui.requestRender();
197
334
  return { handled: true, render: true };
198
335
  }
199
- return originals.handleMouse?.call(this, event);
336
+ mouseHandling = true;
337
+ try { return originals.handleMouse?.call(this, event); }
338
+ finally { mouseHandling = false; }
200
339
  };
201
340
  if (originals.handleMouse) prototype.handleMouse = mouseWrapper;
202
341
  Object.assign(prototype, wrappers);
@@ -206,6 +345,8 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
206
345
  current.owners += 1;
207
346
  let disposed = false;
208
347
  return {
348
+ completeRun() { if (!disposed) current.completeRun(); },
349
+ restoreCompletedCalls(ids) { if (!disposed) current.restoreCompletedCalls(ids); },
209
350
  setEnabled(enabled) {
210
351
  if (disposed) return;
211
352
  current.enabled = enabled;
@@ -7,6 +7,7 @@ const ENTRY = "pi-better-harness-tool-output";
7
7
  export default function minimalOutputExtension(pi: ExtensionAPI): void {
8
8
  let hook: MinimalOutputHook | undefined;
9
9
  let enabled = false;
10
+ let running = false;
10
11
 
11
12
  function status(ctx: ExtensionContext): void {
12
13
  ctx.ui.setStatus(ENTRY, enabled ? "tools: minimal" : undefined);
@@ -44,6 +45,8 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
44
45
  }
45
46
  // Observe transcript components from startup, so the toggle also redraws existing rows.
46
47
  if (await ensureHook(ctx)) {
48
+ hook!.restoreCompletedCalls(ctx.sessionManager.getBranch().flatMap(entry =>
49
+ entry.type === "message" && entry.message.role === "toolResult" ? [entry.message.toolCallId] : []));
47
50
  if (enabled) collapseTools(ctx);
48
51
  hook!.setEnabled(enabled);
49
52
  } else enabled = false;
@@ -59,6 +62,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
59
62
  enabled = data.enabled;
60
63
  if (enabled) collapseTools(ctx);
61
64
  hook!.setEnabled(enabled);
65
+ if (enabled && !running) hook!.completeRun();
62
66
  status(ctx);
63
67
  }
64
68
 
@@ -73,6 +77,12 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
73
77
  register();
74
78
  pi.on("session_start", async (_event, ctx) => { subscribe(); await restore(ctx); register(); });
75
79
  pi.on("session_tree", async (_event, ctx) => { await restore(ctx); });
80
+ pi.on("agent_start", () => { running = true; });
81
+ pi.on("agent_settled", (_event, ctx) => {
82
+ running = false;
83
+ if (enabled) ctx.ui.setToolsExpanded(false);
84
+ hook?.completeRun();
85
+ });
76
86
 
77
87
  pi.on("session_shutdown", () => {
78
88
  stopSettings?.();
@@ -80,6 +90,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
80
90
  hook?.dispose();
81
91
  hook = undefined;
82
92
  enabled = false;
93
+ running = false;
83
94
  });
84
95
 
85
96
  pi.registerCommand("tool-output", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, and structured plans.",
5
5
  "license": "MIT",
6
6
  "type": "module",