pi-better-harness 0.21.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
@@ -64,14 +64,30 @@ need interactive confirmation. Full policy: [pi-better-sandbox](https://github.c
64
64
 
65
65
  ## Minimal Tool Output
66
66
 
67
- The bundled harness also provides `/tool-output minimal`, `/tool-output normal`,
68
- and `/tool-output` (toggle). Minimal mode folds each tool call into a single-line
69
- header, including running calls. Long headers are truncated to the terminal
70
- width; result bodies, images, boxes, and tool spacers are hidden. Built-in,
71
- extension, and MCP calls are included. Execution, sandboxing, and agent-facing
72
- payloads are unchanged. Ctrl+O reveals the original tool blocks; Ctrl+O again
73
- folds them. `/tool-output normal` restores ordinary rendering. Error result
74
- bodies are also hidden in minimal mode and remain available when expanded.
67
+ The bundled harness provides **Tool output: Normal / Minimal** in
68
+ `/harness-settings`. `/tool-output minimal`, `/tool-output normal`, and
69
+ `/tool-output` (toggle) remain compatibility shortcuts to the same preference.
70
+ Minimal mode folds each tool call into a single-line header, including running
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,
78
+ images, boxes, and tool spacers are hidden. Built-in, extension, and MCP calls
79
+ are included. Execution, sandboxing, and agent-facing payloads are unchanged.
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.
89
+ `/tool-output normal` restores ordinary rendering. Error result bodies are also
90
+ hidden in minimal mode and remain available when expanded.
75
91
 
76
92
  Normal mode is the default. The preference is saved in the current session,
77
93
  including resume/reload. This is a version-sensitive internal TUI adapter,
@@ -94,7 +110,9 @@ packages without duplicating their configuration. `/sandbox`, `/subagents
94
110
  settings`, and `/goal settings` remain available in standalone installations.
95
111
  Packages without a settings screen are not listed. Pi's `/settings` is unchanged.
96
112
 
97
- The hub also owns **Completions while busy**, shared by Subagents and Background
113
+ The hub includes **Tool output** when the bundled renderer extension is loaded.
114
+ Changes apply immediately to the current session and do not save a global
115
+ default. The hub also owns **Completions while busy**, shared by Subagents and Background
98
116
  Tasks. Choose **Wait until idle** (the default) or **Steer active run**. Changes
99
117
  apply immediately and autosave to the current session branch, including across
100
118
  reloads. Press **Ctrl+S** to save the current choice as your default for future
@@ -4,22 +4,36 @@ import { createRequire } from "node:module";
4
4
  import { dirname, join } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import { stripVTControlCharacters } from "node:util";
7
+ import type { Theme } from "@earendil-works/pi-coding-agent";
7
8
 
8
9
  interface ToolComponent {
9
10
  toolName: string;
11
+ toolCallId: string;
10
12
  args: unknown;
11
13
  callRendererComponent?: Component;
12
14
  expanded: boolean;
15
+ isPartial?: boolean;
16
+ result?: { isError?: boolean };
13
17
  showImages: boolean;
14
18
  resultRendererComponent?: unknown;
15
- ui: { requestRender(): void };
19
+ ui: { requestRender(): void; children?: TranscriptComponent[] };
16
20
  invalidate(): void;
17
21
  setExpanded(expanded: boolean): void;
18
22
  render(width: number): string[];
19
23
  }
20
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
+
21
32
  type Method = (this: ToolComponent, ...args: unknown[]) => unknown;
33
+ interface ToolMouseEvent { type: string; button: string; y: number; shift?: boolean; alt?: boolean; ctrl?: boolean }
34
+ type MouseHandler = (this: ToolComponent, event: ToolMouseEvent) => unknown;
22
35
  interface ToolPrototype {
36
+ handleMouse?: MouseHandler;
23
37
  updateDisplay: Method;
24
38
  getResultRenderer: Method;
25
39
  getTextOutput: Method;
@@ -33,16 +47,29 @@ interface HookState {
33
47
  owners: number;
34
48
  redraw(collapse?: boolean): void;
35
49
  restore(): void;
50
+ completeRun(): void;
51
+ restoreCompletedCalls(ids: Iterable<string>): void;
36
52
  }
37
53
  export interface MinimalOutputHook {
38
54
  setEnabled(enabled: boolean): void;
55
+ completeRun(): void;
56
+ restoreCompletedCalls(ids: Iterable<string>): void;
39
57
  dispose(): void;
40
58
  }
41
59
 
42
60
  const HOOK = Symbol.for("pi-better-harness.minimal-output-hook");
43
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
+ };
44
71
 
45
- function compactCall(component: ToolComponent, width: number): string[] {
72
+ function compactCall(component: ToolComponent, width: number, theme?: Theme, indent = 3): string[] {
46
73
  if (width <= 0) return [];
47
74
  // Render the call alone at a wider width so terminal wrapping becomes truncation.
48
75
  const header = component.callRendererComponent?.render(Math.max(4096, width + 1))
@@ -50,10 +77,46 @@ function compactCall(component: ToolComponent, width: number): string[] {
50
77
  .find((line) => line.length > 0);
51
78
  let text = header;
52
79
  if (!text) {
53
- const args = JSON.stringify(component.args);
54
- text = `${component.toolName}${args && args !== "{}" ? ` ${args}` : ""}`;
80
+ const args = component.args as Record<string, unknown> | undefined;
81
+ const hint = args?.command ?? args?.path ?? args?.pattern;
82
+ const detail = typeof hint === "string" ? hint.replace(/\r?\n/g, " ") : JSON.stringify(component.args);
83
+ text = `${component.toolName}${detail && detail !== "{}" ? ` ${detail}` : ""}`;
55
84
  }
56
- return [truncateToWidth(text, width)];
85
+ const failed = component.result?.isError === true;
86
+ const running = !component.result || component.isPartial;
87
+ const detail = text.startsWith(`${component.toolName} `) ? text.slice(component.toolName.length).trimStart()
88
+ : text === component.toolName ? "" : text;
89
+ const label = component.toolName === "bash" ? "Shell"
90
+ : component.toolName.replace(/_/g, " ").replace(/^./, letter => letter.toUpperCase());
91
+ const suffix = failed ? " (failed)" : running ? " (running)" : "";
92
+ const icon = TOOL_ICONS[component.toolName] ?? "\u25c7";
93
+ const title = `${icon} ${label}`;
94
+ const styled = theme
95
+ ? `${theme.fg(failed ? "error" : running ? "accent" : "muted", icon)} ${theme.fg("muted", label)}${detail ? ` ${theme.fg("dim", detail)}` : ""}${theme.fg(failed ? "error" : "dim", suffix)}`
96
+ : `${title}${detail ? ` ${detail}` : ""}${suffix}`;
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;
57
120
  }
58
121
 
59
122
  function hostRequire(): NodeRequire {
@@ -104,7 +167,7 @@ function sdkEntry(require: ReturnType<typeof createRequire>): string {
104
167
  }
105
168
 
106
169
  /** Internal TUI adapter: no tools are replaced and no result content is changed. */
107
- export function installMinimalOutputHook(prototype: ToolPrototype): MinimalOutputHook {
170
+ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: () => Theme): MinimalOutputHook {
108
171
  for (const name of ["updateDisplay", "getResultRenderer", "getTextOutput", "setExpanded", "invalidate", "render"] as const) {
109
172
  if (typeof prototype[name] !== "function") {
110
173
  throw new Error(`Pi's tool display API is incompatible: missing ${name}. Normal output remains enabled.`);
@@ -116,13 +179,72 @@ export function installMinimalOutputHook(prototype: ToolPrototype): MinimalOutpu
116
179
  updateDisplay: prototype.updateDisplay,
117
180
  getResultRenderer: prototype.getResultRenderer,
118
181
  getTextOutput: prototype.getTextOutput,
182
+ setExpanded: prototype.setExpanded,
119
183
  render: prototype.render,
184
+ ...(typeof prototype.handleMouse === "function" ? { handleMouse: prototype.handleMouse } : {}),
120
185
  };
186
+ let mouseHandling = false;
121
187
  const seen = new WeakSet<ToolComponent>();
122
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
+ };
123
227
  state = {
124
228
  enabled: false,
125
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
+ },
126
248
  redraw(collapse = false) {
127
249
  for (const reference of components) {
128
250
  const component = reference.deref();
@@ -133,10 +255,11 @@ export function installMinimalOutputHook(prototype: ToolPrototype): MinimalOutpu
133
255
  }
134
256
  },
135
257
  restore() {
136
- for (const name of Object.keys(originals) as Array<keyof typeof originals>) {
258
+ for (const name of ["updateDisplay", "getResultRenderer", "getTextOutput", "setExpanded", "render"] as const) {
137
259
  // Do not remove another extension's subsequently installed wrapper.
138
260
  if (prototype[name] === wrappers[name]) Object.assign(prototype, { [name]: originals[name] });
139
261
  }
262
+ if (originals.handleMouse && prototype.handleMouse === mouseWrapper) prototype.handleMouse = originals.handleMouse;
140
263
  delete prototype[HOOK];
141
264
  components.clear();
142
265
  },
@@ -157,6 +280,14 @@ export function installMinimalOutputHook(prototype: ToolPrototype): MinimalOutpu
157
280
  this.resultRendererComponent = lastRenderer;
158
281
  }
159
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
+ },
160
291
  getResultRenderer(this: ToolComponent, ...args: unknown[]) {
161
292
  return current.enabled && !this.expanded ? emptyResult : originals.getResultRenderer.apply(this, args);
162
293
  },
@@ -164,9 +295,49 @@ export function installMinimalOutputHook(prototype: ToolPrototype): MinimalOutpu
164
295
  return current.enabled && !this.expanded ? "" : originals.getTextOutput.apply(this, args);
165
296
  },
166
297
  render(this: ToolComponent, width: number): string[] {
167
- return current.enabled && !this.expanded ? compactCall(this, width) : 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;
168
319
  },
169
320
  };
321
+ const mouseWrapper: MouseHandler = function(event) {
322
+ if (current.enabled && !this.expanded) {
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;
332
+ this.setExpanded(true);
333
+ this.ui.requestRender();
334
+ return { handled: true, render: true };
335
+ }
336
+ mouseHandling = true;
337
+ try { return originals.handleMouse?.call(this, event); }
338
+ finally { mouseHandling = false; }
339
+ };
340
+ if (originals.handleMouse) prototype.handleMouse = mouseWrapper;
170
341
  Object.assign(prototype, wrappers);
171
342
  prototype[HOOK] = state;
172
343
  }
@@ -174,6 +345,8 @@ export function installMinimalOutputHook(prototype: ToolPrototype): MinimalOutpu
174
345
  current.owners += 1;
175
346
  let disposed = false;
176
347
  return {
348
+ completeRun() { if (!disposed) current.completeRun(); },
349
+ restoreCompletedCalls(ids) { if (!disposed) current.restoreCompletedCalls(ids); },
177
350
  setEnabled(enabled) {
178
351
  if (disposed) return;
179
352
  current.enabled = enabled;
@@ -1,11 +1,13 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { installMinimalOutputHook, loadToolPrototype, type MinimalOutputHook } from "./hook.ts";
3
+ import type { SettingsControl } from "../settings/registry.ts";
3
4
 
4
5
  const ENTRY = "pi-better-harness-tool-output";
5
6
 
6
7
  export default function minimalOutputExtension(pi: ExtensionAPI): void {
7
8
  let hook: MinimalOutputHook | undefined;
8
9
  let enabled = false;
10
+ let running = false;
9
11
 
10
12
  function status(ctx: ExtensionContext): void {
11
13
  ctx.ui.setStatus(ENTRY, enabled ? "tools: minimal" : undefined);
@@ -24,7 +26,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
24
26
  }
25
27
  if (hook) return true;
26
28
  try {
27
- hook = installMinimalOutputHook(await loadToolPrototype());
29
+ hook = installMinimalOutputHook(await loadToolPrototype(), () => ctx.ui.theme);
28
30
  return true;
29
31
  } catch (error) {
30
32
  ctx.ui.notify(`Minimal tool output is unavailable: ${error instanceof Error ? error.message : String(error)}`, "warning");
@@ -32,27 +34,63 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
32
34
  }
33
35
  }
34
36
 
35
- pi.on("session_start", async (_event, ctx) => {
37
+ async function restore(ctx: ExtensionContext): Promise<void> {
36
38
  if (ctx.mode !== "tui") return;
37
39
  enabled = false;
38
40
  for (const entry of ctx.sessionManager.getBranch()) {
39
41
  if (entry.type === "custom" && entry.customType === ENTRY) {
40
- const data = entry.data as { enabled?: unknown } | undefined;
41
- if (typeof data?.enabled === "boolean") enabled = data.enabled;
42
+ const data = entry.data as { version?: unknown; enabled?: unknown } | undefined;
43
+ if (data?.version === 1 && typeof data.enabled === "boolean") enabled = data.enabled;
42
44
  }
43
45
  }
44
46
  // Observe transcript components from startup, so the toggle also redraws existing rows.
45
47
  if (await ensureHook(ctx)) {
48
+ hook!.restoreCompletedCalls(ctx.sessionManager.getBranch().flatMap(entry =>
49
+ entry.type === "message" && entry.message.role === "toolResult" ? [entry.message.toolCallId] : []));
46
50
  if (enabled) collapseTools(ctx);
47
51
  hook!.setEnabled(enabled);
48
52
  } else enabled = false;
49
53
  status(ctx);
54
+ }
55
+
56
+ async function change(value: string, ctx: ExtensionContext): Promise<void> {
57
+ if (value !== "Normal" && value !== "Minimal") throw new Error("Invalid tool output mode.");
58
+ if (!await ensureHook(ctx)) throw new Error("Tool output settings are unavailable in this Pi runtime.");
59
+ const data = { version: 1, enabled: value === "Minimal" };
60
+ try { pi.appendEntry(ENTRY, data); }
61
+ catch (error) { data.version = 0; data.enabled = enabled; throw error; }
62
+ enabled = data.enabled;
63
+ if (enabled) collapseTools(ctx);
64
+ hook!.setEnabled(enabled);
65
+ if (enabled && !running) hook!.completeRun();
66
+ status(ctx);
67
+ }
68
+
69
+ const setting: SettingsControl = {
70
+ id: "tool-output", label: "Tool output", values: ["Normal", "Minimal"],
71
+ get: () => enabled ? "Minimal" : "Normal", change,
72
+ };
73
+ const register = () => pi.events.emit("harness-settings:register", setting);
74
+ let stopSettings: (() => void) | undefined;
75
+ const subscribe = () => { stopSettings ??= pi.events.on("harness-settings:request", register); };
76
+ subscribe();
77
+ register();
78
+ pi.on("session_start", async (_event, ctx) => { subscribe(); await restore(ctx); register(); });
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();
50
85
  });
51
86
 
52
87
  pi.on("session_shutdown", () => {
88
+ stopSettings?.();
89
+ stopSettings = undefined;
53
90
  hook?.dispose();
54
91
  hook = undefined;
55
92
  enabled = false;
93
+ running = false;
56
94
  });
57
95
 
58
96
  pi.registerCommand("tool-output", {
@@ -71,12 +109,8 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
71
109
  ctx.ui.notify("Usage: /tool-output [minimal|normal]", "warning");
72
110
  return;
73
111
  }
74
- if (!await ensureHook(ctx)) return;
75
- enabled = mode ? mode === "minimal" : !enabled;
76
- if (enabled) collapseTools(ctx);
77
- hook!.setEnabled(enabled);
78
- pi.appendEntry(ENTRY, { version: 1, enabled });
79
- status(ctx);
112
+ try { await change((mode ? mode === "minimal" : !enabled) ? "Minimal" : "Normal", ctx); }
113
+ catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning"); return; }
80
114
  ctx.ui.notify(enabled
81
115
  ? "Minimal tool output on. Compact call headers remain visible. Ctrl+O reveals results."
82
116
  : "Normal tool output restored.", "info");
@@ -30,7 +30,7 @@ export default function harnessSettingsExtension(pi: ExtensionAPI): void {
30
30
  get: readSettings,
31
31
  change: mode => changeCallbackSetting(pi, ctx, mode),
32
32
  save: () => saveCallbackDefault(readSettings().mode),
33
- });
33
+ }, registry.controls());
34
34
  } catch (error) {
35
35
  ctx.ui.notify(`Settings unavailable: ${error instanceof Error ? error.message : String(error)}`, "error");
36
36
  return;
@@ -1,6 +1,6 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Container, SettingsList, Text, getKeybindings, setKeybindings, KeybindingsManager, TUI_KEYBINDINGS, matchesKey, type SettingItem } from "@earendil-works/pi-tui";
3
- import type { SettingsLink } from "./registry.ts";
3
+ import type { SettingsControl, SettingsLink } from "./registry.ts";
4
4
 
5
5
  export interface CallbackSettingsControl {
6
6
  get(): { mode: "hold" | "steer"; source: "session" | "default" };
@@ -11,7 +11,7 @@ export interface CallbackSettingsControl {
11
11
  const CALLBACK_SETTING_ID = "callbacks:while-busy";
12
12
  const callbackLabel = (mode: "hold" | "steer") => mode === "steer" ? "Steer active run" : "Wait until idle";
13
13
 
14
- export async function chooseHarnessSetting(ctx: any, links: SettingsLink[], selected?: string, callbacks?: CallbackSettingsControl): Promise<string | undefined> {
14
+ export async function chooseHarnessSetting(ctx: any, links: SettingsLink[], selected?: string, callbacks?: CallbackSettingsControl, controls: SettingsControl[] = []): Promise<string | undefined> {
15
15
  return ctx.ui.custom((tui: any, theme: Theme, _kb: unknown, done: (id?: string) => void) => {
16
16
  let callbackMode = callbacks?.get().mode ?? "hold";
17
17
  const callbackSetting: SettingItem | undefined = callbacks ? {
@@ -23,9 +23,11 @@ export async function chooseHarnessSetting(ctx: any, links: SettingsLink[], sele
23
23
  } : undefined;
24
24
  const items: SettingItem[] = [
25
25
  ...(callbackSetting ? [callbackSetting] : []),
26
+ ...controls.map(control => ({ id: `setting:${control.id}`, label: control.label, currentValue: control.get(), description: "Session setting", values: control.values })),
26
27
  ...links.map(link => ({ id: `link:${link.id}`, label: link.label, currentValue: link.command, description: "Open package settings", values: [link.command] })),
27
28
  ];
28
29
  const feedback = new Text("", 0, 0);
30
+ let changing = false;
29
31
  const report = (message: string, error = false) => {
30
32
  feedback.setText(theme.fg(error ? "error" : "muted", message));
31
33
  tui.requestRender?.();
@@ -35,6 +37,20 @@ export async function chooseHarnessSetting(ctx: any, links: SettingsLink[], sele
35
37
  value: (text, active) => active ? theme.inverse(text) : theme.fg("muted", text),
36
38
  description: text => theme.fg("muted", text), cursor: "> ", hint: text => theme.fg("dim", text),
37
39
  }, (id, value) => {
40
+ const control = controls.find(item => `setting:${item.id}` === id);
41
+ if (control) {
42
+ const previous = control.get();
43
+ changing = true;
44
+ report("Saving session setting...");
45
+ Promise.resolve().then(() => control.change(value, ctx)).then(() => {
46
+ list.updateValue(id, control.get());
47
+ report("Session setting saved.");
48
+ }).catch(error => {
49
+ list.updateValue(id, previous);
50
+ report(error instanceof Error ? error.message : String(error), true);
51
+ }).finally(() => { changing = false; tui.requestRender?.(); });
52
+ return;
53
+ }
38
54
  if (id !== CALLBACK_SETTING_ID || !callbacks || !callbackSetting) { done(id); return; }
39
55
  try {
40
56
  const nextMode = value === "Steer active run" ? "steer" : "hold";
@@ -67,6 +83,7 @@ export async function chooseHarnessSetting(ctx: any, links: SettingsLink[], sele
67
83
  render: (width: number) => page.render(width),
68
84
  invalidate: () => page.invalidate(),
69
85
  handleInput(data: string) {
86
+ if (changing) return;
70
87
  if (callbacks && matchesKey(data, "ctrl+s")) {
71
88
  try { callbacks.save(); report("Callback default saved."); }
72
89
  catch (error) { report(error instanceof Error ? error.message : String(error), true); }
@@ -7,21 +7,38 @@ export interface SettingsLink {
7
7
  open(ctx: ExtensionCommandContext): Promise<void> | void;
8
8
  }
9
9
 
10
+ export interface SettingsControl {
11
+ id: string;
12
+ label: string;
13
+ values: string[];
14
+ get(): string;
15
+ change(value: string, ctx: ExtensionCommandContext): Promise<void> | void;
16
+ }
17
+
10
18
  export function createSettingsRegistry(pi: ExtensionAPI) {
11
19
  const links = new Map<string, SettingsLink>();
20
+ const controls = new Map<string, SettingsControl>();
12
21
  const conflicts = new Set<string>();
13
22
  const stop = pi.events.on("harness-settings:register", (candidate: unknown) => {
14
- const link = candidate as Partial<SettingsLink> | null;
15
- if (!link || !/^[a-z][a-z0-9-]*$/.test(link.id ?? "") || typeof link.label !== "string" ||
16
- typeof link.command !== "string" || !link.command.startsWith("/") || typeof link.open !== "function") return;
23
+ const link = candidate as Partial<SettingsLink & SettingsControl> | null;
24
+ if (!link || !/^[a-z][a-z0-9-]*$/.test(link.id ?? "") || typeof link.label !== "string") return;
25
+ const control = Array.isArray(link.values) && link.values.length > 1 && link.values.every(value => typeof value === "string")
26
+ && typeof link.get === "function" && typeof link.change === "function";
27
+ if (!control && (typeof link.command !== "string" || !link.command.startsWith("/") || typeof link.open !== "function")) return;
17
28
  if (conflicts.has(link.id!)) return;
18
29
  const prior = links.get(link.id!);
19
- if (prior && prior.open !== link.open) { links.delete(link.id!); conflicts.add(link.id!); return; }
20
- links.set(link.id!, link as SettingsLink);
30
+ const priorControl = controls.get(link.id!);
31
+ if ((prior && (control || prior.open !== link.open)) ||
32
+ (priorControl && (!control || priorControl.change !== link.change || priorControl.get !== link.get))) {
33
+ links.delete(link.id!); controls.delete(link.id!); conflicts.add(link.id!); return;
34
+ }
35
+ if (control) controls.set(link.id!, link as SettingsControl);
36
+ else links.set(link.id!, link as SettingsLink);
21
37
  });
22
38
  return {
23
- refresh() { links.clear(); conflicts.clear(); pi.events.emit("harness-settings:request", undefined); },
39
+ refresh() { links.clear(); controls.clear(); conflicts.clear(); pi.events.emit("harness-settings:request", undefined); },
24
40
  list() { return [...links.values()].sort((a, b) => a.label.localeCompare(b.label)); },
25
- dispose() { stop(); links.clear(); conflicts.clear(); },
41
+ controls() { return [...controls.values()].sort((a, b) => a.label.localeCompare(b.label)); },
42
+ dispose() { stop(); links.clear(); controls.clear(); conflicts.clear(); },
26
43
  };
27
- }
44
+ }
@@ -45,13 +45,15 @@ For any workflow using the shared task-plan contract (including `resolve-issues`
45
45
  "event": "unit-validated",
46
46
  "revision": 12,
47
47
  "changes": [
48
- { "id": "1201", "set": { "stage": "done", "status": "succeeded", "worker": null, "headSha": "abc123" } },
48
+ { "id": "1201", "set": { "stage": "done", "status": "in-flight", "worker": null, "headSha": "abc123" } },
49
49
  { "id": "C1", "set": { "status": "combining" } }
50
50
  ],
51
51
  "profiling": { "outcome": "succeeded", "wallMs": 540000 }
52
52
  } }
53
53
  ```
54
54
 
55
+ Each issue row shows where it is between code and delivery: `●` while it is being built, `◐ implemented` once `stage` is `done` with a `headSha`, `◑ review passed` while `reviewedHead` equals `headSha`, and `✓` only at `status: succeeded`, when the change has landed on the target branch by a merged PR or a direct push. Only `✓` rows count as complete.
56
+
55
57
  `changes` addresses units, components, and fleet stages by id (a change without an id sets run-level fields); all changes in one call are one transition. For a scope change, a change with `target` `unit` or `component` and an `add` row appends a new row; existing rows cannot be removed or renamed. The tool checks ids, dependencies (including cycles), worker slots, and status/stage values against the shared issue-resolution contract (a fleet status of `n/a` is saved as `not-applicable`), refuses a stale `revision`, appends an optional `decision`, then saves the plan atomically with `planRevision + 1` and a new `updatedAt`, and appends one profiling event with the same revision to the log the run already uses (`profiling/run.jsonl` or `profiling.jsonl`). A rejected update changes nothing. The event is appended before the plan is renamed into place, so a crash between the two can leave the log one revision ahead; the next update notices, reuses that revision, and marks its event with `logAheadRevision`.
56
58
 
57
59
  The binding records the active workflow's name, which is shown in the plan view. A different workflow cannot read or update that binding; a new invocation or ownership release invalidates it. The skill name is not an allowlist: explicit binding opts a workflow into the shared schema and transition rules, while path confinement to `.resolve-issues/rush/<run-id>/task-plan.json`, run identity, size limits, and revision checks still apply. Workflows using other schemas or storage locations retain their own planning and must not bind incompatible state.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-plan",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "description": "Structured execution plans with persistent progress for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -5,7 +5,9 @@ export interface PlanRenderTheme {
5
5
  fg(color: string, value: string): string;
6
6
  }
7
7
 
8
- export type DisplayStatus = "completed" | "in_progress" | "pending" | "blocked" | "failed" | "skipped" | "unknown";
8
+ // `implemented` and `reviewed` are in-progress states a workflow row can show between
9
+ // "code done" and "delivered"; native plans never use them.
10
+ export type DisplayStatus = "completed" | "in_progress" | "implemented" | "reviewed" | "pending" | "blocked" | "failed" | "skipped" | "unknown";
9
11
  export interface PlanRow {
10
12
  label: string;
11
13
  title: string;
@@ -24,6 +26,8 @@ export function statusStyle(status: DisplayStatus): { glyph: string; color: stri
24
26
  switch (status) {
25
27
  case "completed": return { glyph: "✓", color: "success", label: "" };
26
28
  case "in_progress": return { glyph: "●", color: "accent", label: "active" };
29
+ case "implemented": return { glyph: "◐", color: "accent", label: "implemented" };
30
+ case "reviewed": return { glyph: "◑", color: "success", label: "review passed" };
27
31
  case "blocked": return { glyph: "!", color: "warning", label: "blocked" };
28
32
  case "failed": return { glyph: "×", color: "error", label: "failed" };
29
33
  case "skipped": return { glyph: "—", color: "dim", label: "skipped" };
@@ -45,7 +49,8 @@ export function renderPlanPresentation(
45
49
  const count = (status: DisplayStatus) => plan.rows.filter((row) => row.status === status).length;
46
50
  const summary = [`${count("completed")}/${plan.rows.length} complete`];
47
51
  for (const [status, label] of [
48
- ["in_progress", "in progress"], ["blocked", "blocked"], ["failed", "failed"],
52
+ ["in_progress", "in progress"], ["implemented", "implemented"], ["reviewed", "review passed"],
53
+ ["blocked", "blocked"], ["failed", "failed"],
49
54
  ["skipped", "skipped"], ["unknown", "unknown"],
50
55
  ] as const) {
51
56
  if (count(status)) summary.push(`${count(status)} ${label}`);
@@ -67,7 +72,7 @@ export function renderPlanPresentation(
67
72
  theme.fg(style.color, style.glyph) + " " + theme.fg("dim", label) +
68
73
  " ".repeat(Math.max(0, labelWidth - visibleWidth(label)) + 2);
69
74
  const titleColor = row.status === "blocked" ? "warning" : row.status === "failed" ? "error" :
70
- row.status === "in_progress" ? "text" : "muted";
75
+ row.status === "in_progress" || row.status === "implemented" || row.status === "reviewed" ? "text" : "muted";
71
76
  const badge = planText(row.statusLabel ?? style.label);
72
77
  const available = size - visibleWidth(prefix);
73
78
  // At narrow widths use an inline status so it survives title truncation.
@@ -16,6 +16,9 @@ interface RushUnit {
16
16
  dependsOn: string[];
17
17
  worker?: string | number | undefined;
18
18
  note?: string | undefined;
19
+ headSha?: string | undefined;
20
+ reviewedHead?: string | undefined;
21
+ delivery?: string | undefined;
19
22
  }
20
23
 
21
24
  export interface RushPlan {
@@ -78,6 +81,9 @@ export function projectRushPlan(data: unknown, runId: string): RushPlan {
78
81
  dependsOn: Array.isArray(unit.dependsOn) ? unit.dependsOn.filter((id): id is string => typeof id === "string") : [],
79
82
  worker: typeof unit.worker === "string" || typeof unit.worker === "number" ? unit.worker : undefined,
80
83
  note: typeof unit.note === "string" ? unit.note : undefined,
84
+ headSha: typeof unit.headSha === "string" && unit.headSha ? unit.headSha : undefined,
85
+ reviewedHead: typeof unit.reviewedHead === "string" && unit.reviewedHead ? unit.reviewedHead : undefined,
86
+ delivery: typeof unit.delivery === "string" && unit.delivery ? unit.delivery : undefined,
81
87
  };
82
88
  });
83
89
  const fleet: RushPlan["fleet"] = {};
@@ -133,6 +139,15 @@ function workflowStatus(status: string): DisplayStatus {
133
139
  }
134
140
  }
135
141
 
142
+ // A unit is delivered (completed) only at `status: succeeded`, whether its change landed by a
143
+ // merged PR or a direct push. Between code done and delivery, a unit with a HEAD shows
144
+ // whether that HEAD passed review; without one it shows plain in-progress, as before.
145
+ function unitStatus(unit: RushUnit): DisplayStatus {
146
+ const status = workflowStatus(unit.status);
147
+ if (status !== "in_progress" || unit.stage !== "done" || !unit.headSha) return status;
148
+ return unit.reviewedHead === unit.headSha ? "reviewed" : "implemented";
149
+ }
150
+
136
151
  function presentRushPlan(plan: RushPlan, owner: string): PlanPresentation {
137
152
  // The warehouse canary only applies to data work; hide it otherwise.
138
153
  const stages = ["explore", "implement", "review", "ci", ...(plan.warehouseCanaryRequired ? ["canary"] : [])];
@@ -147,8 +162,8 @@ function presentRushPlan(plan: RushPlan, owner: string): PlanPresentation {
147
162
  return {
148
163
  metadata,
149
164
  rows: plan.issues.map((unit) => {
150
- const status = workflowStatus(unit.status);
151
- const details = [`${unit.stage} · ${unit.status}${unit.worker !== undefined ? ` · worker ${unit.worker}` : ""}`];
165
+ const status = unitStatus(unit);
166
+ const details = [`${unit.stage} · ${unit.status}${unit.delivery ? ` · ${unit.delivery}` : ""}${unit.worker !== undefined ? ` · worker ${unit.worker}` : ""}`];
152
167
  if (unit.dependsOn.length) details.push(`after: ${unit.dependsOn.map((id) => `#${id}`).join(", ")}`);
153
168
  if (unit.note) details.push(unit.note);
154
169
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.21.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",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "pi-better-background-tasks": "0.7.0",
57
57
  "pi-better-goal": "0.8.1",
58
- "pi-better-plan": "0.5.4",
58
+ "pi-better-plan": "0.6.0",
59
59
  "pi-better-sandbox": "0.11.0",
60
60
  "pi-better-ssh": "0.1.2",
61
61
  "pi-better-subagents": "0.15.0",