pi-better-harness 0.15.0 → 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,16 +63,18 @@ 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,
74
- tested with Pi 0.82.1 and 0.99.1; incompatible APIs produce a warning and leave
75
- ordinary output enabled. Print/RPC output and exported transcripts are unchanged.
75
+ tested with Pi 0.82.1, 0.99.1, and the bundled Pi 1.0.0 CLI; incompatible APIs
76
+ produce a warning and leave ordinary output enabled. Print/RPC output and
77
+ exported transcripts are unchanged.
76
78
 
77
79
  The standalone-package installer does not install this bundled extension.
78
80
  Load the bundled harness or run it directly from a checkout:
@@ -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 {
@@ -74,32 +49,26 @@ function hostRequire(): NodeRequire {
74
49
  return require;
75
50
  }
76
51
 
52
+ /** Bundled CLI classes are distinct from the SDK's unbundled deep imports. */
53
+ function bundledHostEntry(entry: string): string | undefined {
54
+ if (!process.argv[1]) return undefined;
55
+ try {
56
+ const directory = dirname(realpathSync(process.argv[1]));
57
+ if (directory === join(dirname(entry), "bundle")) return join(directory, "index.js");
58
+ } catch { /* An embedded host may not have a filesystem launcher. */ }
59
+ return undefined;
60
+ }
61
+
77
62
  /** Resolve the running host's SDK, not a second SDK bundled beside the extension. */
78
63
  export async function loadToolPrototype(): Promise<ToolPrototype> {
79
64
  const entry = sdkEntry(hostRequire());
80
- const path = join(dirname(entry), "modes/interactive/components/tool-execution.js");
65
+ const bundle = bundledHostEntry(entry);
66
+ const path = bundle ?? join(dirname(entry), "modes/interactive/components/tool-execution.js");
81
67
  const module = await import(pathToFileURL(path).href);
82
- return module.ToolExecutionComponent.prototype;
83
- }
84
-
85
- /** Chat rows are Container children. Patch that host class, not a second copy. */
86
- export async function loadContainerPrototype(): Promise<ContainerPrototype> {
87
- const entry = sdkEntry(hostRequire());
88
- const tuiPath = createRequire(entry).resolve("@earendil-works/pi-tui");
89
- const tui = await import(pathToFileURL(tuiPath).href);
90
- const prototype = tui.Container?.prototype;
91
- if (typeof prototype?.addChild !== "function" || typeof prototype?.render !== "function") {
92
- throw new Error("Pi's chat container API is incompatible: missing Container.addChild. Turn folding is unavailable.");
68
+ if (!module.ToolExecutionComponent?.prototype) {
69
+ throw new Error("Pi's runtime does not expose its tool display class. Normal output remains enabled.");
93
70
  }
94
- return prototype;
95
- }
96
-
97
- export async function loadMutedText(): Promise<(text: string) => string> {
98
- const entry = sdkEntry(hostRequire());
99
- const theme = await import(pathToFileURL(join(dirname(entry), "modes/interactive/theme/theme.js")).href);
100
- return (text) => {
101
- try { return theme.theme.fg("muted", text); } catch { return text; }
102
- };
71
+ return module.ToolExecutionComponent.prototype;
103
72
  }
104
73
 
105
74
  // Pi's ESM-only export cannot be resolved with require.resolve on older SDKs.
@@ -115,38 +84,9 @@ function sdkEntry(require: ReturnType<typeof createRequire>): string {
115
84
  throw new Error("Could not locate the running Pi SDK. Normal output remains enabled.");
116
85
  }
117
86
 
118
- function hasModelText(component: unknown): boolean {
119
- const content = (component as { lastMessage?: { content?: unknown } } | null)?.lastMessage?.content;
120
- if (!Array.isArray(content)) return false;
121
- return content.some((block) => block?.type === "text" && typeof block.text === "string" && block.text.trim().length > 0);
122
- }
123
-
124
- function formatRun(tools: ToolComponent[], open: boolean): string {
125
- const counts = new Map<string, number>();
126
- let errors = 0;
127
- for (const tool of tools) {
128
- const name = tool.toolName || "tool";
129
- counts.set(name, (counts.get(name) ?? 0) + 1);
130
- if (tool.result?.isError) errors += 1;
131
- }
132
- const names = [...counts].map(([name, count]) => count > 1 ? `${name} ×${count}` : name);
133
- const noun = tools.length === 1 ? "tool" : "tools";
134
- const error = errors === 0 ? "" : errors === 1 ? " · 1 error" : ` · ${errors} errors`;
135
- return `${open ? "▾" : "▸"} ${tools.length} ${noun}${error} · ${names.join(", ")}`;
136
- }
137
-
138
- function clip(text: string, width: number): string {
139
- if (width <= 0 || text.length <= width) return text;
140
- return width === 1 ? "…" : `${text.slice(0, width - 1)}…`;
141
- }
142
-
143
87
  /** Internal TUI adapter: no tools are replaced and no result content is changed. */
144
- export function installMinimalOutputHook(
145
- prototype: ToolPrototype,
146
- containerPrototype?: ContainerPrototype,
147
- muted: (text: string) => string = (text) => text,
148
- ): MinimalOutputHook {
149
- 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) {
150
90
  if (typeof prototype[name] !== "function") {
151
91
  throw new Error(`Pi's tool display API is incompatible: missing ${name}. Normal output remains enabled.`);
152
92
  }
@@ -157,158 +97,35 @@ export function installMinimalOutputHook(
157
97
  updateDisplay: prototype.updateDisplay,
158
98
  getResultRenderer: prototype.getResultRenderer,
159
99
  getTextOutput: prototype.getTextOutput,
100
+ render: prototype.render,
160
101
  };
161
102
  const seen = new WeakSet<ToolComponent>();
162
103
  const components = new Set<WeakRef<ToolComponent>>();
163
- const chats = new Set<WeakRef<ChatContainer>>();
164
- const summaries = new WeakMap<ToolComponent, FoldSummary>();
165
- const openedAt = new WeakMap<ToolComponent, number>();
166
- let generation = 0;
167
- let containerHooked = false;
168
- let installedContainer: ContainerPrototype | undefined;
169
- const isOpen = (tool: ToolComponent) => openedAt.get(tool) === generation;
170
- const laterModelText = (children: unknown[], index: number) => {
171
- for (let i = index; i < children.length; i++) {
172
- if (hasModelText(children[i])) return true;
173
- }
174
- return false;
175
- };
176
- const attachFromTool = (tool: ToolComponent) => {
177
- for (const child of tool.ui?.children ?? []) {
178
- if (child?.children?.includes(tool)) wrapChat(child);
179
- }
180
- };
181
- const wrapChat = (container: ChatContainer) => {
182
- if (!container || seen.has(container as unknown as ToolComponent) || container[CHAT] || typeof container.render !== "function") return;
183
- const original = container.render;
184
- const wrapper = function (this: ChatContainer, width: number) {
185
- if (!current.enabled || !Array.isArray(this.children) || !needsFold(this.children)) return original.call(this, width);
186
- const lines: string[] = [];
187
- const mouseChildren: Array<{ component: unknown; height: number }> = [];
188
- const push = (component: { render(width: number): string[] }) => {
189
- const childLines = component.render(width) ?? [];
190
- mouseChildren.push({ component, height: childLines.length });
191
- lines.push(...childLines);
192
- };
193
- let index = 0;
194
- while (index < this.children.length) {
195
- const child = this.children[index];
196
- if (!seen.has(child as ToolComponent)) {
197
- push(child as { render(width: number): string[] });
198
- index += 1;
199
- continue;
200
- }
201
- let end = index;
202
- while (end < this.children.length && seen.has(this.children[end] as ToolComponent)) end += 1;
203
- const tools = this.children.slice(index, end) as ToolComponent[];
204
- const closed = laterModelText(this.children, end) && tools.every((tool) => !tool.expanded);
205
- if (!closed) {
206
- for (const tool of tools) push(tool);
207
- } else {
208
- const open = isOpen(tools[0]!);
209
- let summary = summaries.get(tools[0]!);
210
- if (!summary) {
211
- summary = createSummary(tools[0]!);
212
- summaries.set(tools[0]!, summary);
213
- }
214
- summary.setTools(tools);
215
- push(summary);
216
- if (open) for (const tool of tools) push(tool);
217
- }
218
- index = end;
219
- }
220
- this.mouseLayout = { width, children: mouseChildren };
221
- return lines;
222
- };
223
- container[CHAT] = { original, wrapper };
224
- container.render = wrapper;
225
- chats.add(new WeakRef(container));
226
- };
227
- const createSummary = (anchor: ToolComponent): FoldSummary => {
228
- let tools = [anchor];
229
- return {
230
- setTools(next) { tools = next; },
231
- invalidate() {},
232
- render(width) {
233
- return ["", muted(clip(formatRun(tools, isOpen(anchor)), width))];
234
- },
235
- handleMouse(event) {
236
- if (event.type !== "click" || (event.button && event.button !== "left")) return undefined;
237
- if (isOpen(anchor)) openedAt.delete(anchor);
238
- else openedAt.set(anchor, generation);
239
- anchor.ui.requestRender();
240
- return { handled: true };
241
- },
242
- };
243
- };
244
- const needsFold = (children: unknown[]) => {
245
- let index = 0;
246
- while (index < children.length) {
247
- if (!seen.has(children[index] as ToolComponent)) { index += 1; continue; }
248
- let end = index;
249
- while (end < children.length && seen.has(children[end] as ToolComponent)) end += 1;
250
- const tools = children.slice(index, end) as ToolComponent[];
251
- if (laterModelText(children, end) && tools.every((tool) => !tool.expanded)) return true;
252
- index = end;
253
- }
254
- return false;
255
- };
256
104
  state = {
257
105
  enabled: false,
258
106
  owners: 0,
259
- foldsTurns: false,
260
- closeRuns() { generation += 1; },
261
107
  redraw(collapse = false) {
262
108
  for (const reference of components) {
263
109
  const component = reference.deref();
264
110
  if (!component) { components.delete(reference); continue; }
265
111
  if (collapse) component.setExpanded(false);
266
112
  else component.invalidate();
267
- attachFromTool(component);
268
113
  component.ui.requestRender();
269
114
  }
270
115
  },
271
- installContainer(next) {
272
- if (containerHooked) return;
273
- if (typeof next.addChild !== "function") {
274
- throw new Error("Pi's chat container API is incompatible: missing Container.addChild. Turn folding is unavailable.");
275
- }
276
- const original = next[ADD]?.original ?? next.addChild;
277
- const wrapper: ContainerPrototype["addChild"] = function (this: ChatContainer, child, ...rest) {
278
- const result = original.call(this, child, ...rest);
279
- if (seen.has(child as ToolComponent)) wrapChat(this);
280
- return result;
281
- };
282
- next.addChild = wrapper;
283
- next[ADD] = { original, wrapper };
284
- containerHooked = true;
285
- installedContainer = next;
286
- state!.foldsTurns = true;
287
- },
288
116
  restore() {
289
117
  for (const name of Object.keys(originals) as Array<keyof typeof originals>) {
290
118
  // Do not remove another extension's subsequently installed wrapper.
291
- if (prototype[name] === wrappers[name]) prototype[name] = originals[name];
119
+ if (prototype[name] === wrappers[name]) Object.assign(prototype, { [name]: originals[name] });
292
120
  }
293
121
  delete prototype[HOOK];
294
122
  components.clear();
295
- if (installedContainer && installedContainer[ADD]?.wrapper === installedContainer.addChild) {
296
- installedContainer.addChild = installedContainer[ADD].original;
297
- delete installedContainer[ADD];
298
- }
299
- for (const reference of chats) {
300
- const container = reference.deref();
301
- if (container?.[CHAT] && container.render === container[CHAT].wrapper) container.render = container[CHAT].original;
302
- if (container) delete container[CHAT];
303
- }
304
- chats.clear();
305
123
  },
306
124
  };
307
125
  const current = state;
308
126
  const wrappers = {
309
127
  updateDisplay(this: ToolComponent, ...args: unknown[]) {
310
128
  if (!seen.has(this)) { seen.add(this); components.add(new WeakRef(this)); }
311
- attachFromTool(this);
312
129
  if (!current.enabled || this.expanded) return originals.updateDisplay.apply(this, args);
313
130
  const showImages = this.showImages;
314
131
  const lastRenderer = this.resultRendererComponent;
@@ -327,20 +144,20 @@ export function installMinimalOutputHook(
327
144
  getTextOutput(this: ToolComponent, ...args: unknown[]) {
328
145
  return current.enabled && !this.expanded ? "" : originals.getTextOutput.apply(this, args);
329
146
  },
147
+ render(this: ToolComponent, width: number): string[] {
148
+ return current.enabled && !this.expanded ? [] : originals.render.call(this, width);
149
+ },
330
150
  };
331
151
  Object.assign(prototype, wrappers);
332
152
  prototype[HOOK] = state;
333
153
  }
334
154
  const current = state;
335
- if (containerPrototype) current.installContainer(containerPrototype);
336
155
  current.owners += 1;
337
156
  let disposed = false;
338
157
  return {
339
- foldsTurns: current.foldsTurns,
340
158
  setEnabled(enabled) {
341
159
  if (disposed) return;
342
160
  current.enabled = enabled;
343
- if (enabled) current.closeRuns();
344
161
  current.redraw(enabled);
345
162
  },
346
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.0",
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",