pi-better-harness 0.15.1 → 0.15.2

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
@@ -63,11 +63,12 @@ need interactive confirmation. Full policy: [pi-better-sandbox](https://github.c
63
63
  ## Minimal Tool Output
64
64
 
65
65
  The bundled harness also provides `/tool-output minimal`, `/tool-output normal`,
66
- and `/tool-output` (toggle). Minimal mode leaves the current tool run visible,
67
- then folds every finished run between model texts into one line. Built-in,
66
+ and `/tool-output` (toggle). Minimal mode hides tool blocks entirely, including
67
+ running calls, headers, results, images, summary rows, and tool spacers. Built-in,
68
68
  extension, and MCP calls are included. Execution, sandboxing, and agent-facing
69
- payloads are unchanged. Ctrl+O unfolds runs and reveals results. Clickable Pi
70
- builds can also open one folded run by clicking its summary.
69
+ payloads are unchanged. Ctrl+O reveals the original tool blocks; Ctrl+O again
70
+ hides them. `/tool-output normal` restores ordinary rendering. Error results
71
+ are also hidden in minimal mode and remain available when revealed.
71
72
 
72
73
  Normal mode is the default. The preference is saved in the current session,
73
74
  including resume/reload. This is a version-sensitive internal TUI adapter,
@@ -7,10 +7,7 @@ interface ToolComponent {
7
7
  expanded: boolean;
8
8
  showImages: boolean;
9
9
  resultRendererComponent?: unknown;
10
- toolName?: string;
11
- toolCallId?: string;
12
- result?: { isError?: boolean };
13
- ui: { requestRender(): void; children?: ChatContainer[] };
10
+ ui: { requestRender(): void };
14
11
  invalidate(): void;
15
12
  setExpanded(expanded: boolean): void;
16
13
  render(width: number): string[];
@@ -23,43 +20,21 @@ interface ToolPrototype {
23
20
  getTextOutput: Method;
24
21
  setExpanded: Method;
25
22
  invalidate: Method;
23
+ render: ToolComponent["render"];
26
24
  [HOOK]?: HookState;
27
25
  }
28
- interface ChatContainer {
29
- children: unknown[];
30
- render: (width: number) => string[];
31
- mouseLayout?: { width: number; children: Array<{ component: unknown; height: number }> };
32
- [CHAT]?: { original: (width: number) => string[]; wrapper: (width: number) => string[] };
33
- }
34
- interface ContainerPrototype {
35
- addChild: (child: unknown, ...rest: unknown[]) => unknown;
36
- render: (width: number) => string[];
37
- [ADD]?: { original: ContainerPrototype["addChild"]; wrapper: ContainerPrototype["addChild"] };
38
- }
39
26
  interface HookState {
40
27
  enabled: boolean;
41
28
  owners: number;
42
- foldsTurns: boolean;
43
- closeRuns(): void;
44
29
  redraw(collapse?: boolean): void;
45
30
  restore(): void;
46
- installContainer(prototype: ContainerPrototype): void;
47
31
  }
48
32
  export interface MinimalOutputHook {
49
33
  setEnabled(enabled: boolean): void;
50
34
  dispose(): void;
51
- readonly foldsTurns: boolean;
52
- }
53
- interface FoldSummary {
54
- render(width: number): string[];
55
- invalidate(): void;
56
- handleMouse(event: { type?: string; button?: string }): { handled: true } | undefined;
57
- setTools(tools: ToolComponent[]): void;
58
35
  }
59
36
 
60
37
  const HOOK = Symbol.for("pi-better-harness.minimal-output-hook");
61
- const CHAT = Symbol.for("pi-better-harness.minimal-output-chat");
62
- const ADD = Symbol.for("pi-better-harness.minimal-output-add-child");
63
38
  const emptyResult = () => ({ render: () => [] as string[], invalidate() {} });
64
39
 
65
40
  function hostRequire(): NodeRequire {
@@ -96,25 +71,6 @@ export async function loadToolPrototype(): Promise<ToolPrototype> {
96
71
  return module.ToolExecutionComponent.prototype;
97
72
  }
98
73
 
99
- /** Use the actual tool class's Container base, including in bundled hosts. */
100
- export async function loadContainerPrototype(): Promise<ContainerPrototype> {
101
- const prototype = Object.getPrototypeOf(await loadToolPrototype());
102
- if (typeof prototype?.addChild !== "function" || typeof prototype?.render !== "function") {
103
- throw new Error("Pi's chat container API is incompatible: missing Container.addChild. Turn folding is unavailable.");
104
- }
105
- return prototype;
106
- }
107
-
108
- export async function loadMutedText(): Promise<(text: string) => string> {
109
- const entry = sdkEntry(hostRequire());
110
- // The bundled SDK does not export its live theme singleton.
111
- if (bundledHostEntry(entry)) return (text) => text;
112
- const theme = await import(pathToFileURL(join(dirname(entry), "modes/interactive/theme/theme.js")).href);
113
- return (text) => {
114
- try { return theme.theme.fg("muted", text); } catch { return text; }
115
- };
116
- }
117
-
118
74
  // Pi's ESM-only export cannot be resolved with require.resolve on older SDKs.
119
75
  function sdkEntry(require: ReturnType<typeof createRequire>): string {
120
76
  for (const directory of require.resolve.paths("@earendil-works/pi-coding-agent") ?? []) {
@@ -128,38 +84,9 @@ function sdkEntry(require: ReturnType<typeof createRequire>): string {
128
84
  throw new Error("Could not locate the running Pi SDK. Normal output remains enabled.");
129
85
  }
130
86
 
131
- function hasModelText(component: unknown): boolean {
132
- const content = (component as { lastMessage?: { content?: unknown } } | null)?.lastMessage?.content;
133
- if (!Array.isArray(content)) return false;
134
- return content.some((block) => block?.type === "text" && typeof block.text === "string" && block.text.trim().length > 0);
135
- }
136
-
137
- function formatRun(tools: ToolComponent[], open: boolean): string {
138
- const counts = new Map<string, number>();
139
- let errors = 0;
140
- for (const tool of tools) {
141
- const name = tool.toolName || "tool";
142
- counts.set(name, (counts.get(name) ?? 0) + 1);
143
- if (tool.result?.isError) errors += 1;
144
- }
145
- const names = [...counts].map(([name, count]) => count > 1 ? `${name} ×${count}` : name);
146
- const noun = tools.length === 1 ? "tool" : "tools";
147
- const error = errors === 0 ? "" : errors === 1 ? " · 1 error" : ` · ${errors} errors`;
148
- return `${open ? "▾" : "▸"} ${tools.length} ${noun}${error} · ${names.join(", ")}`;
149
- }
150
-
151
- function clip(text: string, width: number): string {
152
- if (width <= 0 || text.length <= width) return text;
153
- return width === 1 ? "…" : `${text.slice(0, width - 1)}…`;
154
- }
155
-
156
87
  /** Internal TUI adapter: no tools are replaced and no result content is changed. */
157
- export function installMinimalOutputHook(
158
- prototype: ToolPrototype,
159
- containerPrototype?: ContainerPrototype,
160
- muted: (text: string) => string = (text) => text,
161
- ): MinimalOutputHook {
162
- for (const name of ["updateDisplay", "getResultRenderer", "getTextOutput", "setExpanded", "invalidate"] as const) {
88
+ export function installMinimalOutputHook(prototype: ToolPrototype): MinimalOutputHook {
89
+ for (const name of ["updateDisplay", "getResultRenderer", "getTextOutput", "setExpanded", "invalidate", "render"] as const) {
163
90
  if (typeof prototype[name] !== "function") {
164
91
  throw new Error(`Pi's tool display API is incompatible: missing ${name}. Normal output remains enabled.`);
165
92
  }
@@ -170,158 +97,35 @@ export function installMinimalOutputHook(
170
97
  updateDisplay: prototype.updateDisplay,
171
98
  getResultRenderer: prototype.getResultRenderer,
172
99
  getTextOutput: prototype.getTextOutput,
100
+ render: prototype.render,
173
101
  };
174
102
  const seen = new WeakSet<ToolComponent>();
175
103
  const components = new Set<WeakRef<ToolComponent>>();
176
- const chats = new Set<WeakRef<ChatContainer>>();
177
- const summaries = new WeakMap<ToolComponent, FoldSummary>();
178
- const openedAt = new WeakMap<ToolComponent, number>();
179
- let generation = 0;
180
- let containerHooked = false;
181
- let installedContainer: ContainerPrototype | undefined;
182
- const isOpen = (tool: ToolComponent) => openedAt.get(tool) === generation;
183
- const laterModelText = (children: unknown[], index: number) => {
184
- for (let i = index; i < children.length; i++) {
185
- if (hasModelText(children[i])) return true;
186
- }
187
- return false;
188
- };
189
- const attachFromTool = (tool: ToolComponent) => {
190
- for (const child of tool.ui?.children ?? []) {
191
- if (child?.children?.includes(tool)) wrapChat(child);
192
- }
193
- };
194
- const wrapChat = (container: ChatContainer) => {
195
- if (!container || seen.has(container as unknown as ToolComponent) || container[CHAT] || typeof container.render !== "function") return;
196
- const original = container.render;
197
- const wrapper = function (this: ChatContainer, width: number) {
198
- if (!current.enabled || !Array.isArray(this.children) || !needsFold(this.children)) return original.call(this, width);
199
- const lines: string[] = [];
200
- const mouseChildren: Array<{ component: unknown; height: number }> = [];
201
- const push = (component: { render(width: number): string[] }) => {
202
- const childLines = component.render(width) ?? [];
203
- mouseChildren.push({ component, height: childLines.length });
204
- lines.push(...childLines);
205
- };
206
- let index = 0;
207
- while (index < this.children.length) {
208
- const child = this.children[index];
209
- if (!seen.has(child as ToolComponent)) {
210
- push(child as { render(width: number): string[] });
211
- index += 1;
212
- continue;
213
- }
214
- let end = index;
215
- while (end < this.children.length && seen.has(this.children[end] as ToolComponent)) end += 1;
216
- const tools = this.children.slice(index, end) as ToolComponent[];
217
- const closed = laterModelText(this.children, end) && tools.every((tool) => !tool.expanded);
218
- if (!closed) {
219
- for (const tool of tools) push(tool);
220
- } else {
221
- const open = isOpen(tools[0]!);
222
- let summary = summaries.get(tools[0]!);
223
- if (!summary) {
224
- summary = createSummary(tools[0]!);
225
- summaries.set(tools[0]!, summary);
226
- }
227
- summary.setTools(tools);
228
- push(summary);
229
- if (open) for (const tool of tools) push(tool);
230
- }
231
- index = end;
232
- }
233
- this.mouseLayout = { width, children: mouseChildren };
234
- return lines;
235
- };
236
- container[CHAT] = { original, wrapper };
237
- container.render = wrapper;
238
- chats.add(new WeakRef(container));
239
- };
240
- const createSummary = (anchor: ToolComponent): FoldSummary => {
241
- let tools = [anchor];
242
- return {
243
- setTools(next) { tools = next; },
244
- invalidate() {},
245
- render(width) {
246
- return ["", muted(clip(formatRun(tools, isOpen(anchor)), width))];
247
- },
248
- handleMouse(event) {
249
- if (event.type !== "click" || (event.button && event.button !== "left")) return undefined;
250
- if (isOpen(anchor)) openedAt.delete(anchor);
251
- else openedAt.set(anchor, generation);
252
- anchor.ui.requestRender();
253
- return { handled: true };
254
- },
255
- };
256
- };
257
- const needsFold = (children: unknown[]) => {
258
- let index = 0;
259
- while (index < children.length) {
260
- if (!seen.has(children[index] as ToolComponent)) { index += 1; continue; }
261
- let end = index;
262
- while (end < children.length && seen.has(children[end] as ToolComponent)) end += 1;
263
- const tools = children.slice(index, end) as ToolComponent[];
264
- if (laterModelText(children, end) && tools.every((tool) => !tool.expanded)) return true;
265
- index = end;
266
- }
267
- return false;
268
- };
269
104
  state = {
270
105
  enabled: false,
271
106
  owners: 0,
272
- foldsTurns: false,
273
- closeRuns() { generation += 1; },
274
107
  redraw(collapse = false) {
275
108
  for (const reference of components) {
276
109
  const component = reference.deref();
277
110
  if (!component) { components.delete(reference); continue; }
278
111
  if (collapse) component.setExpanded(false);
279
112
  else component.invalidate();
280
- attachFromTool(component);
281
113
  component.ui.requestRender();
282
114
  }
283
115
  },
284
- installContainer(next) {
285
- if (containerHooked) return;
286
- if (typeof next.addChild !== "function") {
287
- throw new Error("Pi's chat container API is incompatible: missing Container.addChild. Turn folding is unavailable.");
288
- }
289
- const original = next[ADD]?.original ?? next.addChild;
290
- const wrapper: ContainerPrototype["addChild"] = function (this: ChatContainer, child, ...rest) {
291
- const result = original.call(this, child, ...rest);
292
- if (seen.has(child as ToolComponent)) wrapChat(this);
293
- return result;
294
- };
295
- next.addChild = wrapper;
296
- next[ADD] = { original, wrapper };
297
- containerHooked = true;
298
- installedContainer = next;
299
- state!.foldsTurns = true;
300
- },
301
116
  restore() {
302
117
  for (const name of Object.keys(originals) as Array<keyof typeof originals>) {
303
118
  // Do not remove another extension's subsequently installed wrapper.
304
- if (prototype[name] === wrappers[name]) prototype[name] = originals[name];
119
+ if (prototype[name] === wrappers[name]) Object.assign(prototype, { [name]: originals[name] });
305
120
  }
306
121
  delete prototype[HOOK];
307
122
  components.clear();
308
- if (installedContainer && installedContainer[ADD]?.wrapper === installedContainer.addChild) {
309
- installedContainer.addChild = installedContainer[ADD].original;
310
- delete installedContainer[ADD];
311
- }
312
- for (const reference of chats) {
313
- const container = reference.deref();
314
- if (container?.[CHAT] && container.render === container[CHAT].wrapper) container.render = container[CHAT].original;
315
- if (container) delete container[CHAT];
316
- }
317
- chats.clear();
318
123
  },
319
124
  };
320
125
  const current = state;
321
126
  const wrappers = {
322
127
  updateDisplay(this: ToolComponent, ...args: unknown[]) {
323
128
  if (!seen.has(this)) { seen.add(this); components.add(new WeakRef(this)); }
324
- attachFromTool(this);
325
129
  if (!current.enabled || this.expanded) return originals.updateDisplay.apply(this, args);
326
130
  const showImages = this.showImages;
327
131
  const lastRenderer = this.resultRendererComponent;
@@ -340,20 +144,20 @@ export function installMinimalOutputHook(
340
144
  getTextOutput(this: ToolComponent, ...args: unknown[]) {
341
145
  return current.enabled && !this.expanded ? "" : originals.getTextOutput.apply(this, args);
342
146
  },
147
+ render(this: ToolComponent, width: number): string[] {
148
+ return current.enabled && !this.expanded ? [] : originals.render.call(this, width);
149
+ },
343
150
  };
344
151
  Object.assign(prototype, wrappers);
345
152
  prototype[HOOK] = state;
346
153
  }
347
154
  const current = state;
348
- if (containerPrototype) current.installContainer(containerPrototype);
349
155
  current.owners += 1;
350
156
  let disposed = false;
351
157
  return {
352
- foldsTurns: current.foldsTurns,
353
158
  setEnabled(enabled) {
354
159
  if (disposed) return;
355
160
  current.enabled = enabled;
356
- if (enabled) current.closeRuns();
357
161
  current.redraw(enabled);
358
162
  },
359
163
  dispose() {
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { installMinimalOutputHook, loadContainerPrototype, loadMutedText, loadToolPrototype, type MinimalOutputHook } from "./hook.ts";
2
+ import { installMinimalOutputHook, loadToolPrototype, type MinimalOutputHook } from "./hook.ts";
3
3
 
4
4
  const ENTRY = "pi-better-harness-tool-output";
5
5
 
@@ -24,12 +24,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
24
24
  }
25
25
  if (hook) return true;
26
26
  try {
27
- const [toolPrototype, containerPrototype, muted] = await Promise.all([
28
- loadToolPrototype(),
29
- loadContainerPrototype().catch(() => undefined),
30
- loadMutedText().catch(() => undefined),
31
- ]);
32
- hook = installMinimalOutputHook(toolPrototype, containerPrototype, muted);
27
+ hook = installMinimalOutputHook(await loadToolPrototype());
33
28
  return true;
34
29
  } catch (error) {
35
30
  ctx.ui.notify(`Minimal tool output is unavailable: ${error instanceof Error ? error.message : String(error)}`, "warning");
@@ -61,11 +56,11 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
61
56
  });
62
57
 
63
58
  pi.registerCommand("tool-output", {
64
- description: "[minimal|normal] — Fold finished tool runs, or restore normal output. No argument toggles. Display only.",
59
+ description: "[minimal|normal] — Hide tool blocks entirely, or restore normal output. No argument toggles. Display only.",
65
60
  getArgumentCompletions: (argumentPrefix) => {
66
61
  const prefix = argumentPrefix.trimStart().toLowerCase();
67
62
  const matches = [
68
- { value: "minimal", label: "minimal", description: "Fold finished tool runs after the next model text" },
63
+ { value: "minimal", label: "minimal", description: "Hide all tool blocks, including running tools" },
69
64
  { value: "normal", label: "normal", description: "Restore ordinary tool output" },
70
65
  ].filter((option) => option.value.startsWith(prefix));
71
66
  return matches.length > 0 ? matches : null;
@@ -83,9 +78,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
83
78
  pi.appendEntry(ENTRY, { version: 1, enabled });
84
79
  status(ctx);
85
80
  ctx.ui.notify(enabled
86
- ? hook!.foldsTurns
87
- ? "Minimal tool output on. Finished tool runs fold after the next model text. Ctrl+O reveals them; click a folded run when the terminal supports it."
88
- : "Minimal tool output on, but turn folding is unavailable in this Pi version. Ctrl+O reveals results."
81
+ ? "Minimal tool output on. Tool blocks are hidden. Ctrl+O reveals them."
89
82
  : "Normal tool output restored.", "info");
90
83
  },
91
84
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
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",