pi-better-harness 0.27.1 → 0.28.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,11 +68,12 @@ 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. 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
71
+ calls. The tool-specific icon and tool name share a state color: accent while
72
+ running, muted when completed, and error-colored when failed. In fullscreen Pi, a soft highlight travels through visible running names; inline
73
+ command/path arguments stay dim. Scrollback terminals keep static state colors.
74
+ Choose **Tool animation: Off** in `/harness-settings` for static running headers.
75
+ Running and failed calls retain text labels so state never depends on color or
76
+ motion alone. Tool rows are indented two columns past
76
77
  the assistant text padding. Long
77
78
  headers are truncated to the terminal width; result bodies,
78
79
  images, boxes, and tool spacers are hidden. Built-in, extension, and MCP calls
@@ -130,8 +131,10 @@ to the `/agents` catalog. `/sandbox`, `/subagents settings`, `/agents`, and
130
131
  `/goal settings` remain available in standalone installations.
131
132
  Packages without a settings screen are not listed. Pi's `/settings` is unchanged.
132
133
 
133
- The hub includes **Tool output** when the bundled renderer extension is loaded.
134
- Changes apply immediately and also save the default for future sessions.
134
+ The hub includes **Tool output** and **Tool animation** when the bundled renderer
135
+ extension is loaded. Changes apply immediately and save the default for future
136
+ sessions. The animation preference is stored in `piBetterHarness.toolAnimation`;
137
+ normal output and expanded tool details are never animated.
135
138
  The hub also owns **Completions while busy**, shared by Subagents and Background
136
139
  Tasks. Choose **Wait until idle** (the default) or **Steer active run**. Changes
137
140
  apply immediately and autosave to the current session branch, including across
@@ -5,8 +5,10 @@ import { dirname, join } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import { stripVTControlCharacters } from "node:util";
7
7
  import type { Theme } from "@earendil-works/pi-coding-agent";
8
+ import { supportsToolShimmer, toolIdentity } from "./tool-identity.ts";
9
+ import { toolVisibility, type ViewportUI } from "./tool-visibility.ts";
8
10
 
9
- interface ToolUI {
11
+ interface ToolUI extends ViewportUI {
10
12
  requestRender(): void;
11
13
  children?: TranscriptComponent[];
12
14
  handleViewportInput?(data: string): unknown;
@@ -70,6 +72,8 @@ interface ToolPrototype {
70
72
  }
71
73
  interface HookState {
72
74
  enabled: boolean;
75
+ animationEnabled: boolean;
76
+ stopAnimation(): void;
73
77
  owners: number;
74
78
  redraw(collapse?: boolean): void;
75
79
  restore(): void;
@@ -79,6 +83,7 @@ interface HookState {
79
83
  }
80
84
  export interface MinimalOutputHook {
81
85
  setEnabled(enabled: boolean): void;
86
+ setAnimationEnabled(enabled: boolean): void;
82
87
  completeRun(): void;
83
88
  restoreCompletedCalls(ids: Iterable<string>): void;
84
89
  dispose(): void;
@@ -146,7 +151,7 @@ function harnessCallDetail(name: string, input: unknown): string | undefined {
146
151
  }
147
152
  }
148
153
 
149
- function compactCall(component: ToolComponent, width: number, theme?: Theme, indent = 3): string[] {
154
+ function compactCall(component: ToolComponent, width: number, theme?: Theme, indent = 3, animationTime?: number): string[] {
150
155
  if (width <= 0) return [];
151
156
  // Render the call alone at a wider width so terminal wrapping becomes truncation.
152
157
  const header = Boolean(component.getCallRenderer?.()) && component.callRendererComponent?.render(Math.max(4096, width + 1))
@@ -170,7 +175,7 @@ function compactCall(component: ToolComponent, width: number, theme?: Theme, ind
170
175
  const icon = TOOL_ICONS[component.toolName] ?? "\u25c7";
171
176
  const title = ` ${icon} ${label}`;
172
177
  const styled = theme
173
- ? ` ${theme.fg(failed ? "error" : running ? "accent" : "muted", icon)} ${theme.fg("muted", label)}${detail ? ` ${theme.fg("dim", detail)}` : ""}${theme.fg(failed ? "error" : "dim", suffix)}`
178
+ ? ` ${toolIdentity(icon, label, failed ? "error" : running ? "accent" : "muted", theme, animationTime)}${detail ? ` ${theme.fg("dim", detail)}` : ""}${theme.fg(failed ? "error" : "dim", suffix)}`
174
179
  : `${title}${detail ? ` ${detail}` : ""}${suffix}`;
175
180
  return [truncateToWidth(`${" ".repeat(Math.min(indent, Math.max(0, width - 1)))}${styled}`, width)];
176
181
  }
@@ -304,6 +309,50 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
304
309
  };
305
310
  const seen = new WeakSet<ToolComponent>();
306
311
  const components = new Set<WeakRef<ToolComponent>>();
312
+ const animationReferences = new WeakMap<ToolComponent, WeakRef<ToolComponent>>();
313
+ const animated = new Map<WeakRef<ToolComponent>, number>();
314
+ const settled = new WeakSet<ToolComponent>();
315
+ let animationTimer: ReturnType<typeof setInterval> | undefined;
316
+ const stopAnimation = () => {
317
+ if (animationTimer !== undefined) clearInterval(animationTimer);
318
+ animationTimer = undefined;
319
+ animated.clear();
320
+ };
321
+ const canAnimate = (component: ToolComponent) => {
322
+ const theme = getTheme?.();
323
+ return state!.enabled && state!.animationEnabled && component.ui.mode === "fullscreen"
324
+ && !!theme && supportsToolShimmer(theme)
325
+ && !component.expanded && !component.result?.isError && (!component.result || component.isPartial)
326
+ && !settled.has(component);
327
+ };
328
+ const pruneAnimation = () => {
329
+ for (const [reference, lastRender] of animated) {
330
+ const component = reference.deref();
331
+ if (!component || !canAnimate(component) || Date.now() - lastRender > 400
332
+ || toolVisibility(component.ui, component) !== true) animated.delete(reference);
333
+ }
334
+ if (!animated.size) stopAnimation();
335
+ };
336
+ const animationTime = (component: ToolComponent, width: number, indent: number) => {
337
+ if (width <= indent + 3 || !canAnimate(component) || toolVisibility(component.ui, component) === false) return undefined;
338
+ let reference = animationReferences.get(component);
339
+ if (!reference) { reference = new WeakRef(component); animationReferences.set(component, reference); }
340
+ animated.set(reference, Date.now());
341
+ if (animationTimer === undefined) {
342
+ animationTimer = setInterval(() => {
343
+ pruneAnimation();
344
+ const uis = new Set<ToolUI>();
345
+ for (const reference of animated.keys()) {
346
+ const component = reference.deref();
347
+ if (!component) continue;
348
+ uis.add(component.ui);
349
+ }
350
+ for (const ui of uis) ui.requestRender();
351
+ }, 80);
352
+ animationTimer.unref?.();
353
+ }
354
+ return toolVisibility(component.ui, component) === true ? Date.now() : undefined;
355
+ };
307
356
  let completed = new Map<string, object>();
308
357
  let groups = new WeakMap<ToolComponent, ToolGroup>();
309
358
  let layouts = new WeakMap<ToolComponent, { parent: TranscriptComponent; anchor?: TranscriptComponent }>();
@@ -344,11 +393,15 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
344
393
  };
345
394
  state = {
346
395
  enabled: false,
396
+ animationEnabled: true,
397
+ stopAnimation,
347
398
  owners: 0,
348
399
  completeRun() {
400
+ stopAnimation();
349
401
  const run = {};
350
402
  for (const reference of components) {
351
403
  const component = reference.deref();
404
+ if (component) settled.add(component);
352
405
  if (component?.result && !component.isPartial && !completed.has(component.toolCallId)) {
353
406
  completed.set(component.toolCallId, run);
354
407
  if (this.enabled) component.setExpanded(false);
@@ -357,6 +410,11 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
357
410
  this.redraw();
358
411
  },
359
412
  restoreCompletedCalls(ids) {
413
+ stopAnimation();
414
+ for (const reference of components) {
415
+ const component = reference.deref();
416
+ if (component) settled.add(component);
417
+ }
360
418
  const history = {};
361
419
  completed = new Map(Array.from(ids, id => [id, history]));
362
420
  groups = new WeakMap();
@@ -372,8 +430,10 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
372
430
  else component.invalidate();
373
431
  component.ui.requestRender();
374
432
  }
433
+ pruneAnimation();
375
434
  },
376
435
  restore() {
436
+ stopAnimation();
377
437
  clearHover();
378
438
  for (const [ui, { original, wrapper }] of inputWrappers) {
379
439
  if (ui.handleViewportInput === wrapper) ui.handleViewportInput = original;
@@ -498,6 +558,7 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
498
558
  this.ui.handleViewportInput = wrapper;
499
559
  }
500
560
  if (!seen.has(this)) { seen.add(this); components.add(new WeakRef(this)); }
561
+ pruneAnimation();
501
562
  if (!current.enabled || this.expanded) return originals.updateDisplay.apply(this, args);
502
563
  const showImages = this.showImages;
503
564
  const lastRenderer = this.resultRendererComponent;
@@ -517,6 +578,7 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
517
578
  if (args[0] === false && !mouseHandling && group) group.open = false;
518
579
  const result = originals.setExpanded.apply(this, args);
519
580
  if (group) group.expanded += Number(this.expanded) - Number(wasExpanded);
581
+ pruneAnimation();
520
582
  return result;
521
583
  },
522
584
  getResultRenderer(this: ToolComponent, ...args: unknown[]) {
@@ -531,7 +593,7 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
531
593
  const padding = layout?.anchor?.outputPad ?? 1;
532
594
  const indent = Number.isFinite(padding) ? Math.max(0, Math.floor(padding)) + 2 : 3;
533
595
  const group = groupFor(this, layout?.parent);
534
- if (!group) return highlight(this, compactCall(this, width, getTheme?.(), indent), width);
596
+ if (!group) return highlight(this, compactCall(this, width, getTheme?.(), indent, animationTime(this, width, indent)), width);
535
597
  const open = group.open || group.expanded > 0;
536
598
  const first = group.members[0] === this;
537
599
  const lines: string[] = [];
@@ -591,6 +653,12 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
591
653
  current.enabled = enabled;
592
654
  current.redraw(enabled);
593
655
  },
656
+ setAnimationEnabled(enabled) {
657
+ if (disposed) return;
658
+ current.animationEnabled = enabled;
659
+ if (!enabled) current.stopAnimation();
660
+ current.redraw();
661
+ },
594
662
  dispose() {
595
663
  if (disposed) return;
596
664
  disposed = true;
@@ -10,6 +10,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
10
10
  let enabled = false;
11
11
  let running = false;
12
12
  let initialDefault: boolean | undefined;
13
+ let motionEnabled = true;
13
14
 
14
15
  function defaultEnabled(): boolean {
15
16
  if (initialDefault !== undefined) return initialDefault;
@@ -52,6 +53,16 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
52
53
  enabled = false;
53
54
  ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
54
55
  }
56
+ try {
57
+ const motion = readHarnessSetting<{ version?: unknown; enabled?: unknown }>("toolAnimation");
58
+ if (motion !== undefined && (motion?.version !== 1 || typeof motion.enabled !== "boolean")) {
59
+ throw new Error("Invalid tool-animation default in global settings.json.");
60
+ }
61
+ motionEnabled = motion?.enabled !== false;
62
+ } catch (error) {
63
+ motionEnabled = false;
64
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
65
+ }
55
66
  for (const entry of ctx.sessionManager.getBranch()) {
56
67
  if (entry.type === "custom" && entry.customType === ENTRY) {
57
68
  const data = entry.data as { version?: unknown; enabled?: unknown } | undefined;
@@ -60,6 +71,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
60
71
  }
61
72
  // Observe transcript components from startup, so the toggle also redraws existing rows.
62
73
  if (await ensureHook(ctx)) {
74
+ hook!.setAnimationEnabled(motionEnabled);
63
75
  hook!.restoreCompletedCalls(ctx.sessionManager.getBranch().flatMap(entry =>
64
76
  entry.type === "message" && entry.message.role === "toolResult" ? [entry.message.toolCallId] : []));
65
77
  if (enabled) collapseTools(ctx);
@@ -93,7 +105,21 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
93
105
  id: "tool-output", label: "Tool output", values: ["Normal", "Minimal"],
94
106
  get: () => enabled ? "Minimal" : "Normal", change,
95
107
  };
96
- const register = () => pi.events.emit("harness-settings:register", setting);
108
+ const animationSetting: SettingsControl = {
109
+ id: "tool-animation", label: "Tool animation", values: ["Shimmer", "Off"],
110
+ get: () => motionEnabled ? "Shimmer" : "Off",
111
+ async change(value, ctx) {
112
+ if (value !== "Shimmer" && value !== "Off") throw new Error("Invalid tool animation mode.");
113
+ if (!await ensureHook(ctx)) throw new Error("Tool animation settings are unavailable in this Pi runtime.");
114
+ updateHarnessSetting("toolAnimation", () => ({ version: 1, enabled: value === "Shimmer" }));
115
+ motionEnabled = value === "Shimmer";
116
+ hook!.setAnimationEnabled(motionEnabled);
117
+ },
118
+ };
119
+ const register = () => {
120
+ pi.events.emit("harness-settings:register", setting);
121
+ pi.events.emit("harness-settings:register", animationSetting);
122
+ };
97
123
  let stopSettings: (() => void) | undefined;
98
124
  const subscribe = () => { stopSettings ??= pi.events.on("harness-settings:request", register); };
99
125
  subscribe();
@@ -115,6 +141,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
115
141
  enabled = false;
116
142
  running = false;
117
143
  initialDefault = undefined;
144
+ motionEnabled = true;
118
145
  });
119
146
 
120
147
  pi.registerCommand("tool-output", {
@@ -0,0 +1,66 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+
3
+ type RGB = [number, number, number];
4
+ interface ConcreteColor { kind: string; index?: number; r?: number; g?: number; b?: number }
5
+ const concreteColors = (theme: Theme) => (theme as Theme & { colors?: Record<string, ConcreteColor> }).colors;
6
+ const PERIOD = 2400;
7
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
8
+
9
+ function themeRgb(theme: Theme, token: "accent" | "text"): RGB | undefined {
10
+ const color = concreteColors(theme)?.[token];
11
+ if (color?.kind === "rgb" && color.r !== undefined && color.g !== undefined && color.b !== undefined) {
12
+ return [color.r, color.g, color.b];
13
+ }
14
+ return rgb(theme.getFgAnsi(token));
15
+ }
16
+
17
+ export function supportsToolShimmer(theme: Theme): boolean {
18
+ const base = themeRgb(theme, "accent");
19
+ const light = themeRgb(theme, "text");
20
+ const accent = concreteColors(theme)?.accent;
21
+ return (!!base && !!light && base.some((value, index) => value !== light[index]))
22
+ || (accent?.kind === "indexed" && accent.index !== undefined && accent.index >= 0 && accent.index < 16);
23
+ }
24
+
25
+ function rgb(ansi: string): RGB | undefined {
26
+ const direct = /\x1b\[38;2;(\d+);(\d+);(\d+)m/.exec(ansi);
27
+ if (direct) return [Number(direct[1]), Number(direct[2]), Number(direct[3])];
28
+ const indexed = /\x1b\[38;5;(\d+)m/.exec(ansi);
29
+ if (!indexed) return undefined;
30
+ const index = Number(indexed[1]);
31
+ // The first 16 colors are terminal-defined; keep them untouched rather than guess.
32
+ if (index < 16 || index > 255) return undefined;
33
+ if (index >= 232) { const gray = 8 + (index - 232) * 10; return [gray, gray, gray]; }
34
+ const cube = index - 16;
35
+ const channel = (value: number) => value === 0 ? 0 : 55 + value * 40;
36
+ return [channel(Math.floor(cube / 36)), channel(Math.floor(cube / 6) % 6), channel(cube % 6)];
37
+ }
38
+
39
+ function foreground(color: RGB, mode: "truecolor" | "256color"): string {
40
+ if (mode === "truecolor") return `\x1b[38;2;${color.join(";")}m`;
41
+ const channel = (value: number) => Math.max(0, Math.min(5, Math.round((value - 55) / 40)));
42
+ const index = 16 + channel(color[0]) * 36 + channel(color[1]) * 6 + channel(color[2]);
43
+ return `\x1b[38;5;${index}m`;
44
+ }
45
+
46
+ export function toolIdentity(icon: string, label: string, tone: "accent" | "muted" | "error", theme: Theme, time?: number): string {
47
+ if (tone !== "accent" || time === undefined) return theme.fg(tone, `${icon} ${label}`);
48
+ const base = themeRgb(theme, "accent");
49
+ const light = themeRgb(theme, "text");
50
+ if (!supportsToolShimmer(theme)) return theme.fg(tone, `${icon} ${label}`);
51
+ const letters = Array.from(graphemes.segment(label), part => part.segment);
52
+ const radius = Math.max(2, Math.min(4, letters.length / 3));
53
+ const position = (time % PERIOD) / PERIOD * (letters.length + radius * 2) - radius;
54
+ const mode = theme.getColorMode();
55
+ const name = letters.map((letter, index) => {
56
+ const distance = Math.abs(index - position) / radius;
57
+ const shine = distance < 1 ? (1 + Math.cos(distance * Math.PI)) * 0.48 : 0;
58
+ if (!base || !light) {
59
+ const intensity = shine > 0.72 ? "\x1b[1m" : shine > 0.18 ? "" : "\x1b[2m";
60
+ return `\x1b[22m${intensity}${letter}`;
61
+ }
62
+ const color = base.map((value, channel) => Math.round(value + (light[channel] - value) * shine)) as RGB;
63
+ return `${foreground(color, mode)}${letter}`;
64
+ }).join("");
65
+ return theme.fg(tone, `${icon} \x1b[22m${name}\x1b[22m`);
66
+ }
@@ -0,0 +1,61 @@
1
+ interface Rect { x: number; y: number; width: number; height: number }
2
+ interface LegacyComponent {
3
+ mouseLayout?: { children: { component: LegacyComponent; height: number }[] };
4
+ paddingX?: number;
5
+ paddingY?: number;
6
+ }
7
+ interface LayoutBox {
8
+ component: LegacyComponent;
9
+ rect: Rect;
10
+ clip: Rect;
11
+ children: LayoutBox[];
12
+ lineOffset?: number;
13
+ }
14
+ export interface ViewportUI { mode?: string; currentLayout?: { root: LayoutBox }; hasOverlay?(): boolean; hasActiveSelection?(): boolean }
15
+ const frames = new WeakMap<ViewportUI, { root: LayoutBox; known: Set<object>; visible: Set<object> }>();
16
+
17
+ function intersects(a: Rect, b: Rect): boolean {
18
+ return a.width > 0 && a.height > 0 && b.width > 0 && b.height > 0
19
+ && a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
20
+ }
21
+
22
+ // The guarded fullscreen adapter uses the last painted layout, never calls render()
23
+ // to measure visibility, and leaves scrollback hosts on static state colors.
24
+ export function toolVisibility(ui: ViewportUI, component: object): boolean | undefined {
25
+ if (ui.mode !== "fullscreen" || ui.hasOverlay?.() || ui.hasActiveSelection?.()) return false;
26
+ const root = ui.currentLayout?.root;
27
+ if (!root) return undefined;
28
+ if (!root.rect || !root.clip || !Array.isArray(root.children)) return false;
29
+ let frame = frames.get(ui);
30
+ if (frame?.root !== root) {
31
+ frame = { root, known: new Set(), visible: new Set() };
32
+ const current = frame;
33
+ const legacy = (child: LegacyComponent, rect: Rect, clip: Rect) => {
34
+ if (!child || typeof child !== "object") return;
35
+ current.known.add(child);
36
+ if (!intersects(rect, clip)) return;
37
+ current.visible.add(child);
38
+ const children = child.mouseLayout?.children;
39
+ if (!children) return;
40
+ const paddingY = child.paddingY ?? 0;
41
+ const paddingX = child.paddingX ?? 0;
42
+ if (children.reduce((height, entry) => height + entry.height, 0) + paddingY * 2 !== rect.height) return;
43
+ let y = rect.y + paddingY;
44
+ for (const entry of children) {
45
+ legacy(entry.component, { x: rect.x + paddingX, y, width: Math.max(0, rect.width - paddingX * 2), height: entry.height }, clip);
46
+ y += entry.height;
47
+ }
48
+ };
49
+ const visit = (box: LayoutBox) => {
50
+ if (!box?.component || !box.rect || !box.clip || !Array.isArray(box.children)) return;
51
+ current.known.add(box.component);
52
+ if (!intersects(box.rect, box.clip)) return;
53
+ current.visible.add(box.component);
54
+ if (box.children.length) for (const child of box.children) visit(child);
55
+ else legacy(box.component, { ...box.rect, y: box.rect.y - (box.lineOffset ?? 0) }, box.clip);
56
+ };
57
+ visit(root);
58
+ frames.set(ui, frame);
59
+ }
60
+ return frame.known.has(component) ? frame.visible.has(component) : undefined;
61
+ }
@@ -1,104 +1,66 @@
1
1
  # pi-better-plan
2
2
 
3
- `pi-better-plan` keeps a structured execution plan visible while Pi works.
3
+ See what Pi is working on, what is done, and what comes next.
4
4
 
5
- ![Pi Better Plan package preview](https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/pi-better-plan.png)
6
-
7
- ## What It Does
8
-
9
- - Gives models one `update_plan` interface for checklist updates, workflow binding, and workflow transitions, plus `get_plan` for inspection.
10
- - Supports dependency edges (`id` and `dependsOn`) so independent ready steps can run concurrently.
11
- - Shows the complete checklist of completed, active, pending, and blocked steps above the editor.
12
- - Persists plan state and display preferences on the active Pi session branch.
13
- - Keeps a completed plan visible for 30 seconds, then clears it automatically.
14
- - Opens the complete plan with `/plan`.
5
+ ## Install
15
6
 
16
- Plan progress is checklist progress, not an estimate of effort. The extension never infers completion from prose or successful tool calls.
7
+ ```sh
8
+ pi install npm:pi-better-plan
9
+ ```
17
10
 
18
- ## Coordinating Delegated Work
11
+ ## Using the plan
19
12
 
20
- Use the plan as the foreground coordinator's milestone ledger. Delegate independent, sufficiently substantial work early with subagents, and use background tasks for long-running processes or repeated checks. Keep doing unblocked foreground work after launch; do not poll workers.
13
+ Ask Pi to make a plan for your task. The checklist appears above the editor and updates as Pi works.
21
14
 
22
- Before the first implementation milestone, check for independent work and follow
23
- the active subagent delegation mode. Without `pi-better-subagents`, the plan uses
24
- adaptive guidance. Manual keeps planned work in the foreground unless the user or
25
- an active workflow explicitly requires delegation. Adaptive delegates substantial
26
- independent work when useful. Coordinator consults current catalog roles and
27
- delegates nontrivial role-owned work while retaining integration and final
28
- verification in the foreground.
15
+ By default, you see up to five steps around the current work. The view starts at the beginning and moves toward the end as work progresses. Ellipses show how many steps are hidden; the progress count always covers the whole plan.
29
16
 
30
- Independent foreground and delegated milestones may both be `in_progress` only
31
- when both are actually underway. Use steps for distinct deliverables, not
32
- individual worker processes; worker tools and the background-work navigator own
33
- run status. Complete verification and the plan only after every relevant
34
- delegated task is terminal and its result or failure has been inspected and
35
- integrated.
17
+ Click the plan to expand or collapse it in Pi's fullscreen mode. In regular terminal mode, use `/plan expand` or `/plan collapse`. For the full plan with details, run `/plan`.
36
18
 
37
- For a DAG, assign stable ids to prerequisite steps and list those ids in dependent steps' `dependsOn`. Dependencies must exist in the same plan; cycles and starting or completing a step before its prerequisites are complete are rejected. `get_plan` reports pending steps whose prerequisites are complete as ready. Plans without edges keep their existing behavior.
19
+ Rows show `✓` for done, `●` for active, `○` for pending, and `!` for blocked. Pi marks steps complete explicitly; the count is not an estimate of effort.
38
20
 
39
- When an explicitly invoked skill declares `workflow-role: coordinator` in its metadata, that skill's task plan takes precedence. The generic checklist stays persisted but is hidden, and `update_plan` refuses generic checklist updates until workflow ownership is released. `/plan` never opens a stale generic checklist during workflow ownership.
21
+ Your plan and display settings survive reload and follow the session branch. A completed checklist clears after 30 seconds. A released workflow plan stays visible for reference, but cannot be edited until the workflow resumes.
40
22
 
41
- For any workflow using the shared task-plan contract (including `resolve-issues` and `rush-issues`), bind its plan through `update_plan` with the absolute `.resolve-issues/rush/<run-id>/task-plan.json` path and its persisted `planRevision`:
23
+ ## Examples
42
24
 
43
- ```json
44
- { "workflow": {
45
- "path": "/absolute/project/.resolve-issues/rush/run-1/task-plan.json",
46
- "revision": 12
47
- } }
48
- ```
25
+ These images show the same eight-step plan, rendered by the actual widget with demonstration data.
49
26
 
50
- This binds the run to the session and shows its fleet stages and units in the widget, `/plan`, and `get_plan`. It does not rewrite the plan, increment its revision, or append a profiling event. The binding survives Pi session resume, and ownership release restores the prior generic checklist. Repeat this form to reload an externally saved checkpoint at its exact revision. `sync_workflow_plan` has been removed; migrate its old `{path, revision}` arguments into `update_plan`'s `workflow` object.
27
+ ### Just started
51
28
 
52
- After binding, record every transition with `update_plan` and a `workflow` object instead of editing `task-plan.json` or the profiling log by hand:
29
+ The first five steps are visible.
53
30
 
54
- ```json
55
- { "workflow": {
56
- "event": "unit-validated",
57
- "revision": 12,
58
- "changes": [
59
- { "id": "1201", "set": { "stage": "done", "status": "in-flight", "worker": null, "headSha": "abc123" } },
60
- { "id": "C1", "set": { "status": "combining" } }
61
- ],
62
- "profiling": { "outcome": "succeeded", "wallMs": 540000 }
63
- } }
64
- ```
31
+ ![Plan at the start, showing steps 1 through 5](https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/plan-examples/start.png)
65
32
 
66
- You may include `path` and `revision` alongside `event` and `changes` in the first call to bind and save a transition together. A rejected transition does not activate a new binding. Every successful binding or transition requests a TUI refresh; there is no separate synchronization tool to call afterward.
33
+ ### In the middle
67
34
 
68
- 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.
35
+ The active step has nearby context, with hidden steps counted on both sides.
69
36
 
70
- `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`.
37
+ ![Plan in the middle, showing steps 2 through 6 with step 4 active](https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/plan-examples/middle.png)
71
38
 
72
- 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.
39
+ ### Near the end
73
40
 
74
- ## Plan Display
41
+ The view shifts to the last five steps.
75
42
 
76
- Native and workflow-synced plans share a `plan` heading, completion counts, aligned identifiers and titles, and status colors. Completed rows use `✓`, active rows `●`, pending rows `○`, and blocked rows `!`. Active and blocked rows also carry text labels; failed workflow rows use `×` and `failed` rather than an active indicator.
43
+ ![Plan near completion, showing steps 4 through 8 with step 7 active](https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/plan-examples/near-end.png)
77
44
 
78
- Workflow identity, revision, and fleet progress sit on a secondary line that wraps on narrow terminals. `/plan` uses the same styling and shows workflow stage, raw status, worker, dependencies, and notes beneath each issue. Use ↑/↓ to move the selection and Escape or ← to return. The passive widget never captures editor arrow keys.
45
+ ### Expanded
79
46
 
80
- ## Install
47
+ See every step. Click again or run `/plan collapse` to return to the compact view.
81
48
 
82
- ```sh
83
- pi install npm:pi-better-plan
84
- ```
49
+ ![Expanded plan showing all eight steps](https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/plan-examples/expanded.png)
85
50
 
86
51
  ## Commands
87
52
 
88
- ```text
89
- /plan
90
- /plan clear
91
- /plan hide
92
- /plan show
93
- /plan pin auto
94
- /plan pin on
95
- /plan pin off
96
- ```
53
+ | Command | What it does |
54
+ | --- | --- |
55
+ | `/plan` | Open the full plan. Use Up/Down to select a step and Escape to return. |
56
+ | `/plan expand` | Show all steps above the editor. |
57
+ | `/plan collapse` | Return to the five-step view. |
58
+ | `/plan hide` | Hide the plan without deleting it. |
59
+ | `/plan show` | Show it again. |
60
+ | `/plan clear` | Remove the displayed plan. |
97
61
 
98
- `pin auto` and `pin on` currently use the compact widget until Pi exposes a reserved right-rail extension interface. A floating overlay is intentionally not used as a substitute because it would cover transcript content.
62
+ ## For developers
99
63
 
100
- ## Development
64
+ Pi uses `update_plan` to create or update a plan and `get_plan` to inspect it. Plans can include dependencies and work running in parallel.
101
65
 
102
- ```sh
103
- npm run verify
104
- ```
66
+ See the [technical reference](./REFERENCE.md) for workflow integration, delegation rules, display settings, tests, and screenshot generation.
@@ -0,0 +1,111 @@
1
+ # Plan reference
2
+
3
+ Technical details for workflow authors and contributors. For installation, screenshots, and everyday use, see the [README](./README.md).
4
+
5
+ ## Coordinating Delegated Work
6
+
7
+ Use the plan as the foreground coordinator's milestone ledger. Delegate independent, sufficiently substantial work early with subagents, and use background tasks for long-running processes or repeated checks. Keep doing unblocked foreground work after launch; do not poll workers.
8
+
9
+ Before the first implementation milestone, check for independent work and follow
10
+ the active subagent delegation mode. Without `pi-better-subagents`, the plan uses
11
+ adaptive guidance. Manual keeps planned work in the foreground unless the user or
12
+ an active workflow explicitly requires delegation. Adaptive delegates substantial
13
+ independent work when useful. Coordinator consults current catalog roles and
14
+ delegates nontrivial role-owned work while retaining integration and final
15
+ verification in the foreground.
16
+
17
+ Independent foreground and delegated milestones may both be `in_progress` only
18
+ when both are actually underway. Use steps for distinct deliverables, not
19
+ individual worker processes; worker tools and the background-work navigator own
20
+ run status. Complete verification and the plan only after every relevant
21
+ delegated task is terminal and its result or failure has been inspected and
22
+ integrated.
23
+
24
+ For a DAG, assign stable ids to prerequisite steps and list those ids in dependent steps' `dependsOn`. Dependencies must exist in the same plan; cycles and starting or completing a step before its prerequisites are complete are rejected. `get_plan` reports pending steps whose prerequisites are complete as ready. Plans without edges keep their existing behavior.
25
+
26
+ When an explicitly invoked skill declares `workflow-role: coordinator` in its metadata, that skill's task plan takes precedence. The generic checklist stays persisted but is hidden, and `update_plan` refuses generic checklist updates until workflow ownership is released. `/plan` never opens a stale generic checklist during workflow ownership.
27
+
28
+ For any workflow using the shared task-plan contract (including `resolve-issues` and `rush-issues`), bind its plan through `update_plan` with the absolute `.resolve-issues/rush/<run-id>/task-plan.json` path and its persisted `planRevision`:
29
+
30
+ ```json
31
+ { "workflow": {
32
+ "path": "/absolute/project/.resolve-issues/rush/run-1/task-plan.json",
33
+ "revision": 12
34
+ } }
35
+ ```
36
+
37
+ This binds the run to the session and shows its fleet stages and units in the widget, `/plan`, and `get_plan`. It does not rewrite the plan, increment its revision, or append a profiling event. The binding survives Pi session resume. Ownership release keeps the last bound plan visible as a read-only handoff; a generic checklist update or `/plan clear` replaces or dismisses it. Repeat this form to reload an externally saved checkpoint at its exact revision. `sync_workflow_plan` has been removed; migrate its old `{path, revision}` arguments into `update_plan`'s `workflow` object.
38
+
39
+ After binding, record every transition with `update_plan` and a `workflow` object instead of editing `task-plan.json` or the profiling log by hand:
40
+
41
+ ```json
42
+ { "workflow": {
43
+ "event": "unit-validated",
44
+ "revision": 12,
45
+ "changes": [
46
+ { "id": "1201", "set": { "stage": "done", "status": "in-flight", "worker": null, "headSha": "abc123" } },
47
+ { "id": "C1", "set": { "status": "combining" } }
48
+ ],
49
+ "profiling": { "outcome": "succeeded", "wallMs": 540000 }
50
+ } }
51
+ ```
52
+
53
+ You may include `path` and `revision` alongside `event` and `changes` in the first call to bind and save a transition together. A rejected transition does not activate a new binding. Every successful binding or transition requests a TUI refresh; there is no separate synchronization tool to call afterward.
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
+
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`.
58
+
59
+ The binding records the active workflow's name, which is shown in the plan view. A different active workflow cannot read or update that binding; a new invocation invalidates it. After ownership release, the prior binding remains available for read-only inspection but cannot authorize writes. 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.
60
+
61
+ ## Plan Display
62
+
63
+ Native and workflow-synced plans share a `plan` heading, completion counts, aligned identifiers and titles, and status colors. Completed rows use `✓`, active rows `●`, pending rows `○`, and blocked rows `!`. Active and blocked rows also carry text labels; failed workflow rows use `×` and `failed` rather than an active indicator.
64
+
65
+ Workflow identity, revision, and fleet progress sit on a secondary line that wraps on narrow terminals. `/plan` uses the same styling and shows workflow stage, raw status, worker, dependencies, and notes beneath each issue. Use ↑/↓ to move the selection and Escape or ← to return. The passive widget never captures editor arrow keys.
66
+
67
+ The folded widget shows at most five steps. Active steps take priority, with nearby steps filling the window; if more than five are active, the last five in plan order are shown. Early progress shows the head, late or completed progress shows the tail. Ellipses count omitted rows, while summary counts always cover the entire plan. Expanding reveals all steps without the detailed notes from `/plan`.
68
+
69
+ In Pi's fullscreen TUI, a plain left click on the visible plan toggles expansion without taking editor focus. Regular terminal mode keeps mouse events for terminal selection and scrollback; use `/plan expand` or `/plan collapse` there. Expansion and display preferences survive reload and follow the active session branch.
70
+
71
+ ## Commands
72
+
73
+ ```text
74
+ /plan
75
+ /plan clear
76
+ /plan hide
77
+ /plan show
78
+ /plan expand
79
+ /plan collapse
80
+ /plan pin auto
81
+ /plan pin on
82
+ /plan pin off
83
+ ```
84
+
85
+ `pin auto` and `pin on` currently use the compact widget until Pi exposes a reserved right-rail extension interface. A floating overlay is intentionally not used as a substitute because it would cover transcript content.
86
+
87
+ ## Development
88
+
89
+ ```sh
90
+ npm run verify
91
+ ```
92
+
93
+ From the repository root, run the real terminal journey with tmux:
94
+
95
+ ```sh
96
+ node --import tsx --import ./scripts/isolate-registry.mjs --test scripts/plan-navigation.tui.e2e.test.mjs
97
+ ```
98
+
99
+ Regenerate only the example screenshots, then check the gallery assets:
100
+
101
+ ```sh
102
+ npm run gallery:render -- --plan-examples
103
+ npm run gallery:check
104
+ ```
105
+
106
+ To also exercise fullscreen mouse dispatch on a mouse-capable Pi build:
107
+
108
+ ```sh
109
+ PI_PLAN_TUI_CLI=/absolute/path/to/pi PI_PLAN_TUI_MOUSE=1 \
110
+ node --import tsx --import ./scripts/isolate-registry.mjs --test scripts/plan-navigation.tui.e2e.test.mjs
111
+ ```
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "pi-better-plan",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Structured execution plans with persistent progress for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "src/index.ts",
8
8
  "files": [
9
9
  "src",
10
- "README.md"
10
+ "README.md",
11
+ "REFERENCE.md"
11
12
  ],
12
13
  "keywords": [
13
14
  "pi-package",
@@ -13,6 +13,8 @@ import {
13
13
  replacePlan,
14
14
  } from "./plan-state.js";
15
15
  import { createFullPlanComponent, renderCompactPlan } from "./plan-render.js";
16
+ import { createPlanWidget } from "./plan-widget.js";
17
+ import { COMPACT_PLAN_ROWS } from "./plan-presentation.js";
16
18
  import {
17
19
  createRushPlanComponent, readRushPlan, renderRushPlan, workflowBinding,
18
20
  WORKFLOW_PLAN_ENTRY, type RushPlan, type WorkflowPlanBinding,
@@ -85,6 +87,8 @@ const PLAN_ACTIONS: readonly AutocompleteItem[] = [
85
87
  { value: "clear", label: "clear", description: "Remove the current plan" },
86
88
  { value: "hide", label: "hide", description: "Hide the plan widget" },
87
89
  { value: "show", label: "show", description: "Restore automatic plan display" },
90
+ { value: "expand", label: "expand", description: "Show all steps in the plan widget" },
91
+ { value: "collapse", label: "collapse", description: "Show up to five relevant steps" },
88
92
  { value: "pin auto", label: "pin auto", description: "Use automatic plan pinning" },
89
93
  { value: "pin on", label: "pin on", description: "Keep the plan pinned" },
90
94
  { value: "pin off", label: "pin off", description: "Keep the plan unpinned" },
@@ -119,6 +123,7 @@ function workflowPlanOwner(ctx: ExtensionContext): string | null {
119
123
  export default function planExtension(pi: ExtensionAPI): void {
120
124
  let currentPlan: PlanSnapshot | null = null;
121
125
  let displayMode: PlanDisplayMode = "auto";
126
+ let expanded = false;
122
127
  let displayedWorkflowOwner: string | null = null;
123
128
  let rushBinding: WorkflowPlanBinding | null = null;
124
129
  let rushPlan: RushPlan | null = null;
@@ -128,16 +133,26 @@ export default function planExtension(pi: ExtensionAPI): void {
128
133
  const unsubscribeWorkflow = pi.events.on("pi-better-workflow:changed", (owner) => {
129
134
  displayedWorkflowOwner = owner && typeof owner === "object" && "name" in owner && typeof owner.name === "string"
130
135
  ? owner.name : null;
131
- rushBinding = null;
132
- rushPlan = null;
133
- rushError = null;
136
+ if (displayedWorkflowOwner) {
137
+ rushBinding = null;
138
+ rushPlan = null;
139
+ rushError = null;
140
+ }
134
141
  refreshWidget?.(true);
135
142
  });
136
143
 
137
144
  const refresh = (force = false): void => refreshWidget?.(force);
138
145
 
139
146
  const loadRushPlan = (ctx: ExtensionContext): RushPlan | null => {
140
- if (!rushBinding || workflowPlanOwner(ctx) !== rushBinding.owner) return null;
147
+ const owner = workflowPlanOwner(ctx);
148
+ const binding = workflowBinding(ctx.sessionManager.getBranch());
149
+ displayedWorkflowOwner = owner;
150
+ rushBinding = binding && (!owner || binding.owner === owner) ? binding : null;
151
+ if (!rushBinding) {
152
+ rushPlan = null;
153
+ rushError = null;
154
+ return null;
155
+ }
141
156
  try {
142
157
  const plan = readRushPlan(rushBinding.path, ctx.cwd);
143
158
  if (plan.runId !== rushBinding.runId) throw new Error("Rush run identity changed.");
@@ -151,17 +166,24 @@ export default function planExtension(pi: ExtensionAPI): void {
151
166
  return rushPlan;
152
167
  };
153
168
 
154
- const displayWorkflowPlan = (plan: RushPlan, binding: WorkflowPlanBinding): void => {
169
+ const displayWorkflowPlan = (plan: RushPlan, binding: WorkflowPlanBinding, readOnly = false): void => {
155
170
  if (!rushBinding || rushBinding.owner !== binding.owner || rushBinding.path !== binding.path || rushBinding.runId !== binding.runId) {
156
171
  pi.appendEntry(WORKFLOW_PLAN_ENTRY, { version: 1, kind: "set", ...binding });
157
172
  }
158
- displayedWorkflowOwner = binding.owner;
173
+ displayedWorkflowOwner = readOnly ? null : binding.owner;
159
174
  rushBinding = binding;
160
175
  rushPlan = plan;
161
176
  rushError = null;
162
177
  refresh(true);
163
178
  };
164
179
 
180
+ const clearWorkflowPlan = (): void => {
181
+ if (rushBinding) pi.appendEntry(WORKFLOW_PLAN_ENTRY, { version: 1, kind: "clear" });
182
+ rushBinding = null;
183
+ rushPlan = null;
184
+ rushError = null;
185
+ };
186
+
165
187
  const cancelCompletedPlanClear = (): void => {
166
188
  if (completedPlanClearTimer) clearTimeout(completedPlanClearTimer);
167
189
  completedPlanClearTimer = undefined;
@@ -196,17 +218,19 @@ export default function planExtension(pi: ExtensionAPI): void {
196
218
  currentPlan = state.plan;
197
219
  displayedWorkflowOwner = workflowPlanOwner(ctx);
198
220
  const binding = workflowBinding(ctx.sessionManager.getBranch());
199
- rushBinding = binding?.owner === displayedWorkflowOwner ? binding : null;
221
+ rushBinding = binding && (!displayedWorkflowOwner || binding.owner === displayedWorkflowOwner) ? binding : null;
200
222
  rushPlan = null;
201
223
  rushError = null;
202
224
  if (rushBinding) loadRushPlan(ctx);
203
225
  displayMode = state.displayMode;
226
+ expanded = state.expanded;
204
227
  if (ctx.hasUI) ctx.ui.setStatus(LEGACY_PLAN_NAV_STATUS_KEY, undefined);
205
228
  refresh(true);
206
229
  scheduleCompletedPlanClear();
207
230
  };
208
231
 
209
232
  const persistPlan = (plan: PlanSnapshot): void => {
233
+ clearWorkflowPlan();
210
234
  currentPlan = plan;
211
235
  pi.appendEntry(EXTENSION_NAME, planSetEntry(plan));
212
236
  refresh(true);
@@ -219,6 +243,12 @@ export default function planExtension(pi: ExtensionAPI): void {
219
243
  refresh(true);
220
244
  };
221
245
 
246
+ const setExpanded = (value: boolean): void => {
247
+ expanded = value;
248
+ pi.appendEntry(EXTENSION_NAME, { version: 1, kind: "expansion", expanded, at: Math.floor(Date.now() / 1_000) });
249
+ refresh(true);
250
+ };
251
+
222
252
  const installWidget = (ctx: ExtensionContext): void => {
223
253
  if (!ctx.hasUI) return;
224
254
  ctx.ui.setWidget(
@@ -226,20 +256,26 @@ export default function planExtension(pi: ExtensionAPI): void {
226
256
  (tui, theme) => {
227
257
  const localRefresh = (force = false): void => tui.requestRender(force);
228
258
  refreshWidget = localRefresh;
229
- return {
230
- render(width: number): string[] {
259
+ const widget = createPlanWidget(
260
+ (width: number): string[] => {
231
261
  if (displayMode === "hidden" || displayedWorkflowOwner !== workflowPlanOwner(ctx)) return [];
232
- if (displayedWorkflowOwner) {
233
- if (rushBinding?.owner !== displayedWorkflowOwner) return [];
262
+ if (displayedWorkflowOwner || rushBinding) {
263
+ if (!rushBinding || (displayedWorkflowOwner && rushBinding.owner !== displayedWorkflowOwner) ||
264
+ !workflowBinding(ctx.sessionManager.getBranch())) return [];
234
265
  const fg = typeof theme?.fg === "function"
235
266
  ? (color: string, value: string) => theme.fg(color as never, value)
236
267
  : undefined;
237
- return rushPlan ? ["", ...renderRushPlan(rushPlan, width, false, fg, displayedWorkflowOwner)] : [];
268
+ return rushPlan ? renderRushPlan(rushPlan, width, false, fg, rushBinding.owner, !displayedWorkflowOwner, expanded) : [];
238
269
  }
239
270
  if (!currentPlan) return [];
240
- return ["", ...renderCompactPlan(currentPlan, width, theme as never)];
271
+ return renderCompactPlan(currentPlan, width, theme as never, expanded);
241
272
  },
242
- invalidate() {},
273
+ () => (rushBinding ? rushPlan?.issues.length ?? 0 : currentPlan?.steps.length ?? 0) > COMPACT_PLAN_ROWS,
274
+ () => expanded,
275
+ () => setExpanded(!expanded),
276
+ );
277
+ return {
278
+ ...widget,
243
279
  dispose() {
244
280
  if (refreshWidget === localRefresh) refreshWidget = undefined;
245
281
  },
@@ -251,21 +287,23 @@ export default function planExtension(pi: ExtensionAPI): void {
251
287
 
252
288
  const showFullPlan = async (ctx: ExtensionContext): Promise<void> => {
253
289
  const owner = workflowPlanOwner(ctx);
254
- if (owner) {
290
+ const binding = workflowBinding(ctx.sessionManager.getBranch());
291
+ if (owner || binding) {
255
292
  const plan = loadRushPlan(ctx);
293
+ const planOwner = owner ?? binding!.owner;
256
294
  if (!plan) {
257
- ctx.ui.notify(`${owner} plan unavailable: ${rushError ?? "not bound"}`, "warning");
295
+ ctx.ui.notify(`${planOwner}${owner ? " plan" : " read-only handoff"} unavailable: ${rushError ?? "not bound"}`, "warning");
258
296
  return;
259
297
  }
260
298
  if (ctx.mode !== "tui") {
261
- ctx.ui.notify(renderRushPlan(plan, 160, true, undefined, owner).join("\n"), "info");
299
+ ctx.ui.notify(renderRushPlan(plan, 160, true, undefined, planOwner, !owner).join("\n"), "info");
262
300
  return;
263
301
  }
264
302
  await ctx.ui.custom<void>((tui, theme, _keys, done) => {
265
303
  const fg = typeof theme?.fg === "function"
266
304
  ? (color: string, value: string) => theme.fg(color as never, value)
267
305
  : undefined;
268
- const component = createRushPlanComponent(plan, () => done(), fg, owner);
306
+ const component = createRushPlanComponent(plan, () => done(), fg, planOwner, !owner);
269
307
  return { render: (width) => component.render(width), handleInput(data) {
270
308
  component.handleInput?.(data);
271
309
  tui.requestRender();
@@ -315,16 +353,26 @@ export default function planExtension(pi: ExtensionAPI): void {
315
353
  const owner = workflowPlanOwner(ctx);
316
354
  const input = params as { explanation?: string; plan?: PlanStepInput[]; workflow?: WorkflowPlanInput };
317
355
  if (input.workflow !== undefined) {
318
- if (!owner) throw new Error("No workflow owns the task plan; send plan instead of workflow.");
356
+ const savedBinding = workflowBinding(ctx.sessionManager.getBranch());
357
+ if (!owner && (input.workflow.event !== undefined || input.workflow.changes !== undefined ||
358
+ input.workflow.decision !== undefined || input.workflow.profiling !== undefined || !savedBinding)) {
359
+ throw new Error(savedBinding
360
+ ? `${savedBinding.owner} plan is a read-only handoff. Reinvoke /skill:${savedBinding.owner} before updating its workflow plan.`
361
+ : "No workflow owns the task plan; send plan instead of workflow.");
362
+ }
319
363
  if (input.plan !== undefined) throw new Error("Send either plan or workflow, not both.");
320
364
  const { path, ...transition } = input.workflow;
321
365
  if (transition.event === undefined && (transition.changes !== undefined || transition.decision !== undefined || transition.profiling !== undefined)) {
322
366
  throw new Error("workflow.event is required when sending changes, decision, or profiling.");
323
367
  }
324
- let binding = rushBinding?.owner === owner ? rushBinding : null;
368
+ let binding = savedBinding && (!owner || savedBinding.owner === owner) ? savedBinding : null;
325
369
  if (path !== undefined) {
370
+ if (!owner && path !== binding?.path) {
371
+ throw new Error(`${binding!.owner} plan is a read-only handoff; only its bound path can be reloaded. Reinvoke /skill:${binding!.owner} to bind a workflow plan.`);
372
+ }
326
373
  if (transition.revision === undefined) throw new Error("workflow.revision is required with workflow.path.");
327
374
  const plan = readRushPlan(path, ctx.cwd);
375
+ if (!owner && plan.runId !== binding!.runId) throw new Error("Rush run identity changed.");
328
376
  if (plan.planRevision !== transition.revision) {
329
377
  if (transition.event === undefined) {
330
378
  rushPlan = null;
@@ -333,10 +381,10 @@ export default function planExtension(pi: ExtensionAPI): void {
333
381
  }
334
382
  throw new Error(`Rush plan revision mismatch: expected ${transition.revision}, found ${plan.planRevision}.`);
335
383
  }
336
- binding = { owner, path, runId: plan.runId };
384
+ binding = { owner: owner ?? binding!.owner, path, runId: plan.runId };
337
385
  if (transition.event === undefined) {
338
- displayWorkflowPlan(plan, binding);
339
- return { content: [{ type: "text", text: `Showing ${owner} rev ${plan.planRevision}: ${plan.issues.length} units.` }], details: { ok: true, runId: plan.runId, revision: plan.planRevision } };
386
+ displayWorkflowPlan(plan, binding, !owner);
387
+ return { content: [{ type: "text", text: `Showing ${binding.owner} rev ${plan.planRevision}: ${plan.issues.length} units.${owner ? "" : " Read-only handoff."}` }], details: { ok: true, runId: plan.runId, revision: plan.planRevision, ...(!owner ? { readOnly: true } : {}) } };
340
388
  }
341
389
  }
342
390
  if (!binding) throw new Error(`No ${owner} plan is bound; send workflow.path and workflow.revision to update_plan first.`);
@@ -390,13 +438,16 @@ export default function planExtension(pi: ExtensionAPI): void {
390
438
  parameters: Type.Object({}),
391
439
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
392
440
  const owner = workflowPlanOwner(ctx);
393
- if (owner) {
441
+ const binding = workflowBinding(ctx.sessionManager.getBranch());
442
+ if (owner || binding) {
394
443
  const workflowPlan = loadRushPlan(ctx);
444
+ const planOwner = owner ?? binding!.owner;
395
445
  return {
396
446
  content: [{ type: "text", text: workflowPlan
397
- ? renderRushPlan(workflowPlan, 160, true, undefined, owner).join("\n")
398
- : `${owner} owns the task plan; ${rushError ?? "consult its persisted workflow state"}.` }],
399
- details: { hasPlan: !!workflowPlan, plan: workflowPlan, progress: null, workflowOwner: owner },
447
+ ? renderRushPlan(workflowPlan, 160, true, undefined, planOwner, !owner).join("\n")
448
+ : owner ? `${owner} owns the task plan; ${rushError ?? "consult its persisted workflow state"}.`
449
+ : `${planOwner} read-only handoff unavailable: ${rushError ?? "not bound"}.` }],
450
+ details: { hasPlan: !!workflowPlan, plan: workflowPlan, progress: null, workflowOwner: planOwner, ...(!owner ? { readOnly: true } : {}) },
400
451
  };
401
452
  }
402
453
  if (!currentPlan) {
@@ -424,6 +475,7 @@ export default function planExtension(pi: ExtensionAPI): void {
424
475
  ctx.ui.notify("The workflow owns its plan; /plan clear cannot change it.", "warning");
425
476
  return;
426
477
  }
478
+ clearWorkflowPlan();
427
479
  clearPlan();
428
480
  ctx.ui.notify("Plan cleared.", "info");
429
481
  return;
@@ -436,13 +488,17 @@ export default function planExtension(pi: ExtensionAPI): void {
436
488
  setDisplayMode("auto");
437
489
  return;
438
490
  }
491
+ if (input === "expand" || input === "collapse") {
492
+ setExpanded(input === "expand");
493
+ return;
494
+ }
439
495
  const pinMode = input.match(/^pin\s+(auto|on|off)$/)?.[1] as PlanDisplayMode | undefined;
440
496
  if (pinMode) {
441
497
  setDisplayMode(pinMode);
442
498
  ctx.ui.notify(`Plan pin mode: ${pinMode}.`, "info");
443
499
  return;
444
500
  }
445
- ctx.ui.notify("Usage: /plan [clear|hide|show|pin auto|pin on|pin off]", "warning");
501
+ ctx.ui.notify("Usage: /plan [clear|hide|show|expand|collapse|pin auto|pin on|pin off]", "warning");
446
502
  },
447
503
  });
448
504
 
@@ -454,7 +510,7 @@ export default function planExtension(pi: ExtensionAPI): void {
454
510
  restore(ctx);
455
511
  });
456
512
  pi.on("before_agent_start", async (event, ctx) => {
457
- if (workflowPlanOwner(ctx)) return;
513
+ if (workflowPlanOwner(ctx) || workflowBinding(ctx.sessionManager.getBranch())) return;
458
514
  if (!currentPlan) return;
459
515
  return {
460
516
  systemPrompt: `${event.systemPrompt}\n\n${planPrompt(currentPlan, activeDelegationMode(pi))}`,
@@ -22,6 +22,24 @@ export interface PlanPresentation {
22
22
 
23
23
  export const plainPlanTheme: PlanRenderTheme = { fg: (_color, value) => value };
24
24
 
25
+ export const COMPACT_PLAN_ROWS = 5;
26
+
27
+ function compactRowIndices(rows: PlanRow[]): number[] {
28
+ if (rows.length <= COMPACT_PLAN_ROWS) return rows.map((_, index) => index);
29
+ let active = rows.flatMap((row, index) =>
30
+ ["in_progress", "implemented", "reviewed"].includes(row.status) ? [index] : []);
31
+ if (!active.length) active = rows.flatMap((row, index) =>
32
+ row.status === "blocked" || row.status === "failed" ? [index] : []);
33
+ const firstPending = rows.findIndex((row) => row.status === "pending");
34
+ const anchor = active.at(-1) ?? (firstPending < 0 ? rows.length - 1 : firstPending);
35
+ const start = Math.max(0, Math.min(anchor - 2, rows.length - COMPACT_PLAN_ROWS));
36
+ const indices = new Set(active.slice(-COMPACT_PLAN_ROWS));
37
+ for (let index = start; index < start + COMPACT_PLAN_ROWS && indices.size < COMPACT_PLAN_ROWS; index++) {
38
+ indices.add(index);
39
+ }
40
+ return [...indices].sort((a, b) => a - b);
41
+ }
42
+
25
43
  export function statusStyle(status: DisplayStatus): { glyph: string; color: string; label: string } {
26
44
  switch (status) {
27
45
  case "completed": return { glyph: "✓", color: "success", label: "" };
@@ -43,7 +61,7 @@ export function planText(value: string): string {
43
61
 
44
62
  export function renderPlanPresentation(
45
63
  plan: PlanPresentation, width: number, theme: PlanRenderTheme,
46
- full = false, selectedIndex = -1,
64
+ full = false, selectedIndex = -1, expanded = false,
47
65
  ): string[] {
48
66
  const size = Math.max(1, Math.floor(width));
49
67
  const count = (status: DisplayStatus) => plan.rows.filter((row) => row.status === status).length;
@@ -65,7 +83,15 @@ export function renderPlanPresentation(
65
83
  const labelWidth = Math.min(12, Math.max(4, ...plan.rows.map((row) => visibleWidth(planText(row.label)))));
66
84
  const statusWidth = Math.min(24, Math.max(0, ...plan.rows.map((row) =>
67
85
  visibleWidth(planText(row.statusLabel ?? statusStyle(row.status).label)))));
68
- for (const [index, row] of plan.rows.entries()) {
86
+ const indices = full || expanded ? plan.rows.map((_, index) => index) : compactRowIndices(plan.rows);
87
+ let previous = -1;
88
+ for (const index of indices) {
89
+ if (index > previous + 1) {
90
+ const omitted = index - previous - 1;
91
+ lines.push(theme.fg("dim", ` ... ${omitted} ${previous < 0 ? "earlier " : ""}${omitted === 1 ? "step" : "steps"}`));
92
+ }
93
+ previous = index;
94
+ const row = plan.rows[index]!;
69
95
  const style = statusStyle(row.status);
70
96
  const label = truncateToWidth(planText(row.label), labelWidth);
71
97
  const prefix = (index === selectedIndex ? theme.fg("accent", "› ") : " ") +
@@ -94,6 +120,10 @@ export function renderPlanPresentation(
94
120
  }
95
121
  }
96
122
  }
123
+ if (previous < plan.rows.length - 1) {
124
+ const omitted = plan.rows.length - previous - 1;
125
+ lines.push(theme.fg("dim", ` ... ${omitted} more ${omitted === 1 ? "step" : "steps"}`));
126
+ }
97
127
  if (full) lines.push("", theme.fg("dim", "↑↓ navigate · esc / ← back"));
98
128
  return lines.map((line) => truncateToWidth(line, size));
99
129
  }
@@ -15,8 +15,8 @@ function presentPlan(plan: PlanSnapshot): PlanPresentation {
15
15
  };
16
16
  }
17
17
 
18
- export function renderCompactPlan(plan: PlanSnapshot, width: number, theme: PlanRenderTheme): string[] {
19
- return renderPlanPresentation(presentPlan(plan), width, theme);
18
+ export function renderCompactPlan(plan: PlanSnapshot, width: number, theme: PlanRenderTheme, expanded = false): string[] {
19
+ return renderPlanPresentation(presentPlan(plan), width, theme, false, -1, expanded);
20
20
  }
21
21
 
22
22
  export function renderFullPlan(plan: PlanSnapshot, width: number, theme: PlanRenderTheme, selectedIndex = -1): string[] {
@@ -16,6 +16,7 @@ interface SessionEntryLike {
16
16
  export interface ReconstructedPlanState {
17
17
  plan: PlanSnapshot | null;
18
18
  displayMode: PlanDisplayMode;
19
+ expanded: boolean;
19
20
  }
20
21
 
21
22
  function nowSeconds(): number {
@@ -176,13 +177,15 @@ export function planDisplayEntry(mode: PlanDisplayMode, at = nowSeconds()): Plan
176
177
  export function reconstructPlanState(entries: Iterable<SessionEntryLike>): ReconstructedPlanState {
177
178
  let plan: PlanSnapshot | null = null;
178
179
  let displayMode: PlanDisplayMode = "auto";
180
+ let expanded = false;
179
181
  for (const entry of entries) {
180
182
  if (entry.type !== "custom" || entry.customType !== EXTENSION_NAME || !isPlanEntry(entry.data)) continue;
181
183
  if (entry.data.kind === "set") plan = entry.data.plan;
182
184
  else if (entry.data.kind === "clear") plan = null;
183
- else displayMode = entry.data.mode;
185
+ else if (entry.data.kind === "display") displayMode = entry.data.mode;
186
+ else expanded = entry.data.expanded;
184
187
  }
185
- return { plan, displayMode };
188
+ return { plan, displayMode, expanded };
186
189
  }
187
190
 
188
191
  function isPlanEntry(value: unknown): value is PlanEntry {
@@ -190,6 +193,7 @@ function isPlanEntry(value: unknown): value is PlanEntry {
190
193
  const candidate = value as Partial<PlanEntry>;
191
194
  if (candidate.version !== 1) return false;
192
195
  if (candidate.kind === "clear") return true;
196
+ if (candidate.kind === "expansion") return typeof candidate.expanded === "boolean";
193
197
  if (candidate.kind === "display") {
194
198
  return candidate.mode === "auto" || candidate.mode === "on" || candidate.mode === "off" || candidate.mode === "hidden";
195
199
  }
@@ -0,0 +1,41 @@
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+
4
+ // Structural typing keeps keyboard-only Pi versions compatible with the fullscreen mouse API.
5
+ interface PlanMouseEvent {
6
+ type: string;
7
+ button: string;
8
+ x: number;
9
+ y: number;
10
+ width: number;
11
+ shift?: boolean;
12
+ alt?: boolean;
13
+ ctrl?: boolean;
14
+ }
15
+
16
+ export function createPlanWidget(
17
+ render: (width: number) => string[],
18
+ isFoldable: () => boolean,
19
+ isExpanded: () => boolean,
20
+ toggle: () => void,
21
+ ): Component & { handleMouse(event: PlanMouseEvent): { handled: true; render: boolean } | undefined } {
22
+ let lines: string[] = [];
23
+ return {
24
+ render(width) {
25
+ lines = render(width);
26
+ if (lines.length && isFoldable()) {
27
+ lines = [truncateToWidth(`${isExpanded() ? "▾" : "▸"} ${lines[0]}`, width), ...lines.slice(1)];
28
+ }
29
+ return lines.length ? ["", ...lines] : [];
30
+ },
31
+ handleMouse(event) {
32
+ if (!isFoldable() || (event.type !== "click" && event.type !== "press") || event.button !== "left" || event.shift || event.alt || event.ctrl ||
33
+ event.x < 0 || event.x >= event.width || event.y < 1 || event.y > lines.length) return undefined;
34
+ // Pi synthesizes click only for a component that handled the initial press.
35
+ if (event.type === "press") return { handled: true, render: false };
36
+ toggle();
37
+ return { handled: true, render: true };
38
+ },
39
+ invalidate() { lines = []; },
40
+ };
41
+ }
@@ -39,4 +39,5 @@ export interface PlanProgress {
39
39
  export type PlanEntry =
40
40
  | { version: 1; kind: "set"; plan: PlanSnapshot; at: number }
41
41
  | { version: 1; kind: "clear"; at: number }
42
- | { version: 1; kind: "display"; mode: PlanDisplayMode; at: number };
42
+ | { version: 1; kind: "display"; mode: PlanDisplayMode; at: number }
43
+ | { version: 1; kind: "expansion"; expanded: boolean; at: number };
@@ -113,7 +113,7 @@ export function workflowBinding(entries: Iterable<{ type: string; customType?: s
113
113
  for (const entry of entries) {
114
114
  if (entry.type === "custom" && entry.customType === "pi-better-workflow") {
115
115
  const data = entry.data;
116
- if (isRecord(data) && data.version === 1 && (data.kind === "set" || data.kind === "clear")) binding = null;
116
+ if (isRecord(data) && data.version === 1 && data.kind === "set") binding = null;
117
117
  }
118
118
  if (entry.type !== "custom" || entry.customType !== WORKFLOW_PLAN_ENTRY) continue;
119
119
  const data = entry.data;
@@ -148,7 +148,7 @@ function unitStatus(unit: RushUnit): DisplayStatus {
148
148
  return unit.reviewedHead === unit.headSha ? "reviewed" : "implemented";
149
149
  }
150
150
 
151
- function presentRushPlan(plan: RushPlan, owner: string): PlanPresentation {
151
+ function presentRushPlan(plan: RushPlan, owner: string, readOnly = false): PlanPresentation {
152
152
  // The warehouse canary only applies to data work; hide it otherwise.
153
153
  const stages = ["explore", "implement", "review", "ci", ...(plan.warehouseCanaryRequired ? ["canary"] : [])];
154
154
  const fleet = stages.map((stage) => {
@@ -158,6 +158,7 @@ function presentRushPlan(plan: RushPlan, owner: string): PlanPresentation {
158
158
  return { color: style.color, text: ` ${stage} ${style.glyph}${display === "unknown" ? ` ${planText(status)}` : ""}` };
159
159
  });
160
160
  const metadata = [[{ color: "dim", text: `${planText(owner)} · rev ${plan.planRevision} ·` }, ...fleet]];
161
+ if (readOnly) metadata.push([{ color: "warning", text: "read-only handoff" }]);
161
162
  if (plan.spec?.title) metadata.push([{ color: "dim", text: plan.spec.title }]);
162
163
  return {
163
164
  metadata,
@@ -180,9 +181,11 @@ export function renderRushPlan(
180
181
  full = false,
181
182
  fg?: (color: string, value: string) => string,
182
183
  owner = "rush-issues",
184
+ readOnly = false,
185
+ expanded = false,
183
186
  ): string[] {
184
187
  const theme = fg ? { fg } : plainPlanTheme;
185
- return renderPlanPresentation(presentRushPlan(plan, owner), width, theme, full);
188
+ return renderPlanPresentation(presentRushPlan(plan, owner, readOnly), width, theme, full, -1, expanded);
186
189
  }
187
190
 
188
191
  export function createRushPlanComponent(
@@ -190,7 +193,8 @@ export function createRushPlanComponent(
190
193
  onClose: () => void,
191
194
  fg?: (color: string, value: string) => string,
192
195
  owner = "rush-issues",
196
+ readOnly = false,
193
197
  ): Component {
194
198
  const theme = fg ? { fg } : plainPlanTheme;
195
- return createPlanPresentationComponent(presentRushPlan(plan, owner), theme, onClose);
199
+ return createPlanPresentationComponent(presentRushPlan(plan, owner, readOnly), theme, onClose);
196
200
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.27.1",
3
+ "version": "0.28.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.8.0",
57
57
  "pi-better-goal": "0.8.2",
58
- "pi-better-plan": "0.7.0",
58
+ "pi-better-plan": "0.8.0",
59
59
  "pi-better-sandbox": "0.12.0",
60
60
  "pi-better-ssh": "0.1.2",
61
61
  "pi-better-subagents": "0.17.0",