pi-better-harness 0.27.0 → 0.27.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -79,6 +79,11 @@ images, boxes, and tool spacers are hidden. Built-in, extension, and MCP calls
79
79
  are included. Tools without a custom call renderer use a generic icon and a
80
80
  compact argument hint. Compaction summaries also collapse to one quiet row with
81
81
  the pre-compaction token count; Ctrl+O reveals their original summary.
82
+ Custom transcript messages from any extension also fold to a quiet disclosure
83
+ row with the message type and first content line. This includes background
84
+ completion batches and subagent health/timing alerts. Ctrl+O or a fullscreen
85
+ click reveals the original content and any custom renderer. Direct UI notices
86
+ are unchanged.
82
87
  Execution, sandboxing, and agent-facing payloads are unchanged.
83
88
  When a foreground run ends, each consecutive block of tool calls folds further
84
89
  into one disclosure row showing the call count and any failures. Only the failure
@@ -105,7 +110,7 @@ Normal mode is the initial default. Changing tool output saves the choice in
105
110
  global `settings.json` under `piBetterHarness.toolOutput`, as well as the current
106
111
  session. New sessions inherit it; resumed branches retain their own saved choice.
107
112
  This is a version-sensitive internal TUI adapter,
108
- tested with Pi 0.82.1, 0.99.1, and the bundled Pi 1.0.0 CLI; incompatible APIs
113
+ tested with Pi 0.82.1, 0.99.1, and the bundled Pi 1.0.4 CLI; incompatible APIs
109
114
  produce a warning and leave ordinary output enabled. Print/RPC output and
110
115
  exported transcripts are unchanged.
111
116
 
@@ -42,6 +42,18 @@ interface CompactionComponent extends Component {
42
42
  interface CompactionPrototype {
43
43
  render: CompactionComponent["render"];
44
44
  }
45
+ interface CustomMessageComponent extends Component {
46
+ _expanded: boolean;
47
+ outputPad?: number;
48
+ message: { customType: string; content: string | { type: string; text?: string }[] };
49
+ setExpanded(expanded: boolean): void;
50
+ }
51
+ interface CustomMessagePrototype {
52
+ render: CustomMessageComponent["render"];
53
+ setExpanded: CustomMessageComponent["setExpanded"];
54
+ invalidate(): void;
55
+ handleMouse?(event: ToolMouseEvent): unknown;
56
+ }
45
57
 
46
58
  type Method = (this: ToolComponent, ...args: unknown[]) => unknown;
47
59
  interface ToolMouseEvent { type: string; button: string; x: number; y: number; screenX: number; screenY: number; width: number; shift?: boolean; alt?: boolean; ctrl?: boolean }
@@ -229,6 +241,17 @@ export async function loadCompactionPrototype(): Promise<CompactionPrototype> {
229
241
  return module.CompactionSummaryMessageComponent.prototype;
230
242
  }
231
243
 
244
+ export async function loadCustomMessagePrototype(): Promise<CustomMessagePrototype> {
245
+ const entry = sdkEntry(hostRequire());
246
+ const path = bundledHostEntry(entry) ?? join(dirname(entry), "modes/interactive/components/custom-message.js");
247
+ const module = await import(pathToFileURL(path).href);
248
+ const prototype = module.CustomMessageComponent?.prototype;
249
+ if (!prototype || ["render", "setExpanded", "invalidate"].some(name => typeof prototype[name] !== "function")) {
250
+ throw new Error("Pi's runtime does not expose its expandable custom-message display class.");
251
+ }
252
+ return prototype;
253
+ }
254
+
232
255
  // Pi's ESM-only export cannot be resolved with require.resolve on older SDKs.
233
256
  function sdkEntry(require: ReturnType<typeof createRequire>): string {
234
257
  for (const directory of require.resolve.paths("@earendil-works/pi-coding-agent") ?? []) {
@@ -243,7 +266,7 @@ function sdkEntry(require: ReturnType<typeof createRequire>): string {
243
266
  }
244
267
 
245
268
  /** Internal TUI adapter: no tools are replaced and no result content is changed. */
246
- export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: () => Theme, compactionPrototype?: CompactionPrototype): MinimalOutputHook {
269
+ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: () => Theme, compactionPrototype?: CompactionPrototype, customMessagePrototype?: CustomMessagePrototype): MinimalOutputHook {
247
270
  for (const name of ["updateDisplay", "getResultRenderer", "getTextOutput", "setExpanded", "invalidate", "render"] as const) {
248
271
  if (typeof prototype[name] !== "function") {
249
272
  throw new Error(`Pi's tool display API is incompatible: missing ${name}. Normal output remains enabled.`);
@@ -409,6 +432,60 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
409
432
  restore.call(this);
410
433
  };
411
434
  }
435
+ if (customMessagePrototype) {
436
+ const originalRender = customMessagePrototype.render;
437
+ const originalMouse = customMessagePrototype.handleMouse;
438
+ const renderDescriptor = Object.getOwnPropertyDescriptor(customMessagePrototype, "render");
439
+ const mouseDescriptor = Object.getOwnPropertyDescriptor(customMessagePrototype, "handleMouse");
440
+ const messages = new Set<WeakRef<CustomMessageComponent>>();
441
+ const seenMessages = new WeakSet<CustomMessageComponent>();
442
+ const render = function(this: CustomMessageComponent, width: number): string[] {
443
+ if (!seenMessages.has(this)) { seenMessages.add(this); messages.add(new WeakRef(this)); }
444
+ if (!current.enabled || this._expanded) return originalRender.call(this, width);
445
+ if (width <= 0) return [];
446
+ const label = stripVTControlCharacters(this.message.customType).replace(/[-_\s]+/g, " ").trim().replace(/^./, letter => letter.toUpperCase());
447
+ const content = typeof this.message.content === "string" ? this.message.content
448
+ : this.message.content.filter(block => block.type === "text").map(block => block.text ?? "").join("\n");
449
+ const detail = stripVTControlCharacters(content).split(/\r?\n/).map(line => line.replace(/\s+/g, " ").trim()).find(Boolean) ?? "";
450
+ const theme = getTheme?.();
451
+ const text = theme
452
+ ? ` ${theme.fg("muted", "\u25b8")} ${theme.fg("muted", label)}${detail ? ` ${theme.fg("dim", detail)}` : ""}`
453
+ : ` \u25b8 ${label}${detail ? ` ${detail}` : ""}`;
454
+ const padding = this.outputPad ?? 1;
455
+ const indent = Number.isFinite(padding) ? Math.max(0, Math.floor(padding)) + 2 : 3;
456
+ return [truncateToWidth(`${" ".repeat(Math.min(indent, width - 1))}${text}`, width)];
457
+ };
458
+ const mouse = function(this: CustomMessageComponent, event: ToolMouseEvent): unknown {
459
+ if (current.enabled && !this._expanded && event.type === "click" && event.button === "left"
460
+ && event.y === 0 && !event.shift && !event.alt && !event.ctrl) {
461
+ this.setExpanded(true);
462
+ return { handled: true, render: true };
463
+ }
464
+ return originalMouse?.call(this, event);
465
+ };
466
+ customMessagePrototype.render = render;
467
+ customMessagePrototype.handleMouse = mouse;
468
+ const redraw = current.redraw;
469
+ current.redraw = function(collapse) {
470
+ for (const reference of messages) {
471
+ const component = reference.deref();
472
+ if (!component) { messages.delete(reference); continue; }
473
+ if (collapse) component.setExpanded(false);
474
+ else component.invalidate();
475
+ }
476
+ redraw.call(this, collapse);
477
+ };
478
+ const restore = current.restore;
479
+ current.restore = function() {
480
+ for (const [name, wrapper, descriptor] of [["render", render, renderDescriptor], ["handleMouse", mouse, mouseDescriptor]] as const) {
481
+ if (customMessagePrototype[name] !== wrapper) continue;
482
+ if (descriptor) Object.defineProperty(customMessagePrototype, name, descriptor);
483
+ else Reflect.deleteProperty(customMessagePrototype, name);
484
+ }
485
+ messages.clear();
486
+ restore.call(this);
487
+ };
488
+ }
412
489
  const wrappers = {
413
490
  updateDisplay(this: ToolComponent, ...args: unknown[]) {
414
491
  if (typeof this.ui.handleViewportInput === "function" && !inputWrappers.has(this.ui)) {
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { installMinimalOutputHook, loadToolPrototype, loadCompactionPrototype, type MinimalOutputHook } from "./hook.ts";
2
+ import { installMinimalOutputHook, loadToolPrototype, loadCompactionPrototype, loadCustomMessagePrototype, type MinimalOutputHook } from "./hook.ts";
3
3
  import type { SettingsControl } from "../settings/registry.ts";
4
4
  import { readHarnessSetting, updateHarnessSetting } from "../shared-harness-settings.ts";
5
5
 
@@ -37,7 +37,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
37
37
  }
38
38
  if (hook) return true;
39
39
  try {
40
- hook = installMinimalOutputHook(await loadToolPrototype(), () => ctx.ui.theme, await loadCompactionPrototype());
40
+ hook = installMinimalOutputHook(await loadToolPrototype(), () => ctx.ui.theme, await loadCompactionPrototype(), await loadCustomMessagePrototype());
41
41
  return true;
42
42
  } catch (error) {
43
43
  ctx.ui.notify(`Minimal tool output is unavailable: ${error instanceof Error ? error.message : String(error)}`, "warning");
@@ -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.0",
3
+ "version": "0.27.2",
4
4
  "description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, and structured plans.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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",