pi-better-harness 0.29.1 → 0.29.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
@@ -69,8 +69,8 @@ The bundled harness provides **Tool output: Normal / Minimal** in
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
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.
72
+ running, muted when completed, and error-colored when failed. In fullscreen Pi, a soft highlight travels through the entire visible running tool name and its
73
+ inline command/path details; details stay dim between highlight passes. Scrollback terminals keep static state colors.
74
74
  Choose **Tool animation: Off** in `/harness-settings` for static running headers.
75
75
  Running and failed calls retain text labels so state never depends on color or
76
76
  motion alone. Tool rows are indented two columns past
@@ -174,8 +174,10 @@ function compactCall(component: ToolComponent, width: number, theme?: Theme, ind
174
174
  const suffix = failed ? " (failed)" : running ? " (running)" : "";
175
175
  const icon = TOOL_ICONS[component.toolName] ?? "\u25c7";
176
176
  const title = ` ${icon} ${label}`;
177
+ const visibleDetail = animationTime === undefined ? detail : truncateToWidth(detail,
178
+ Math.max(0, width - Math.min(indent, Math.max(0, width - 1)) - visibleWidth(title) - 2));
177
179
  const styled = theme
178
- ? ` ${toolIdentity(icon, label, failed ? "error" : running ? "accent" : "muted", theme, animationTime)}${detail ? ` ${theme.fg("dim", detail)}` : ""}${theme.fg(failed ? "error" : "dim", suffix)}`
180
+ ? ` ${toolIdentity(icon, label, failed ? "error" : running ? "accent" : "muted", theme, animationTime, visibleDetail)}${theme.fg(failed ? "error" : "dim", suffix)}`
179
181
  : `${title}${detail ? ` ${detail}` : ""}${suffix}`;
180
182
  return [truncateToWidth(`${" ".repeat(Math.min(indent, Math.max(0, width - 1)))}${styled}`, width)];
181
183
  }
@@ -6,7 +6,7 @@ const concreteColors = (theme: Theme) => (theme as Theme & { colors?: Record<str
6
6
  const PERIOD = 2400;
7
7
  const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
8
8
 
9
- function themeRgb(theme: Theme, token: "accent" | "text"): RGB | undefined {
9
+ function themeRgb(theme: Theme, token: "accent" | "text" | "dim"): RGB | undefined {
10
10
  const color = concreteColors(theme)?.[token];
11
11
  if (color?.kind === "rgb" && color.r !== undefined && color.g !== undefined && color.b !== undefined) {
12
12
  return [color.r, color.g, color.b];
@@ -43,23 +43,27 @@ function foreground(color: RGB, mode: "truecolor" | "256color"): string {
43
43
  return `\x1b[38;5;${index}m`;
44
44
  }
45
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}`);
46
+ export function toolIdentity(icon: string, label: string, tone: "accent" | "muted" | "error", theme: Theme, time?: number, detail = ""): string {
47
+ const staticHeader = () => theme.fg(tone, `${icon} ${label}`) + (detail ? ` ${theme.fg("dim", detail)}` : "");
48
+ if (tone !== "accent" || time === undefined || !supportsToolShimmer(theme)) return staticHeader();
48
49
  const base = themeRgb(theme, "accent");
49
50
  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);
51
+ const letters = Array.from(graphemes.segment(label + (detail ? ` ${detail}` : "")), part => part.segment);
52
+ const nameLength = Array.from(graphemes.segment(label)).length;
52
53
  const radius = Math.max(2, Math.min(4, letters.length / 3));
53
54
  const position = (time % PERIOD) / PERIOD * (letters.length + radius * 2) - radius;
54
55
  const mode = theme.getColorMode();
56
+ const detailBase = detail ? themeRgb(theme, "dim") : undefined;
55
57
  const name = letters.map((letter, index) => {
58
+ const token = index < nameLength ? "accent" : "dim";
59
+ const letterBase = token === "accent" ? base : detailBase;
56
60
  const distance = Math.abs(index - position) / radius;
57
61
  const shine = distance < 1 ? (1 + Math.cos(distance * Math.PI)) * 0.48 : 0;
58
- if (!base || !light) {
62
+ if (!base || !light || !letterBase) {
59
63
  const intensity = shine > 0.72 ? "\x1b[1m" : shine > 0.18 ? "" : "\x1b[2m";
60
- return `\x1b[22m${intensity}${letter}`;
64
+ return `${theme.getFgAnsi(token)}\x1b[22m${intensity}${letter}`;
61
65
  }
62
- const color = base.map((value, channel) => Math.round(value + (light[channel] - value) * shine)) as RGB;
66
+ const color = letterBase.map((value, channel) => Math.round(value + (light[channel] - value) * shine)) as RGB;
63
67
  return `${foreground(color, mode)}${letter}`;
64
68
  }).join("");
65
69
  return theme.fg(tone, `${icon} \x1b[22m${name}\x1b[22m`);
@@ -16,7 +16,7 @@ By default, you see up to five steps around the current work. The view starts at
16
16
 
17
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`.
18
18
 
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.
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. The completion count is completed items / total eligible items: canceled workflow items (`cancelled` or `canceled`) are excluded from the total, while skipped and not-applicable items remain in it. Canceled items remain visible for 30 seconds after the cancellation is first observed, then disappear from the widget and plan lists without deleting their workflow records. This delay survives session reloads; reactivating an item makes it visible again. An all-canceled plan shows `0/0` complete.
20
20
 
21
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.
22
22
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-plan",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Structured execution plans with persistent progress for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,36 @@
1
+ import { CANCELLED_ROW_RETENTION_MS, isRecord, type RushPlan, type WorkflowPlanBinding } from "./workflow-plan.js";
2
+
3
+ export const CANCELLED_ROWS_ENTRY = "pi-better-plan-cancelled-rows";
4
+
5
+ export function observeCancelledRows(
6
+ plan: RushPlan,
7
+ binding: WorkflowPlanBinding,
8
+ entries: Iterable<{ type: string; customType?: string; data?: unknown }>,
9
+ append: (type: string, data: unknown) => void,
10
+ now = Date.now(),
11
+ ): void {
12
+ // Keep display deadlines in branch history, not in the workflow-owned task plan.
13
+ let previous: Record<string, unknown> = {};
14
+ for (const entry of entries) {
15
+ const data = entry.data;
16
+ if (entry.type === "custom" && entry.customType === CANCELLED_ROWS_ENTRY && isRecord(data) &&
17
+ data.version === 1 && data.path === binding.path && data.runId === binding.runId &&
18
+ data.owner === binding.owner && isRecord(data.rows)) previous = data.rows;
19
+ }
20
+ const rows: Record<string, number> = Object.create(null);
21
+ for (const unit of plan.issues) {
22
+ if (unit.status !== "cancelled" && unit.status !== "canceled") continue;
23
+ const saved = previous[unit.id];
24
+ unit.cancelledAt = typeof saved === "number" && Number.isFinite(saved) && saved >= 0 ? saved : now;
25
+ rows[unit.id] = unit.cancelledAt;
26
+ }
27
+ if (JSON.stringify(rows) !== JSON.stringify(previous)) {
28
+ append(CANCELLED_ROWS_ENTRY, { version: 1, ...binding, rows });
29
+ }
30
+ }
31
+
32
+ export function cancelledRowsClearDelay(plan: RushPlan, now = Date.now()): number | null {
33
+ const deadlines = plan.issues.flatMap(unit => unit.cancelledAt === undefined ? [] : [unit.cancelledAt + CANCELLED_ROW_RETENTION_MS])
34
+ .filter(deadline => deadline > now);
35
+ return deadlines.length ? Math.min(...deadlines) - now : null;
36
+ }
@@ -18,8 +18,9 @@ import { createPlanWidget } from "./plan-widget.js";
18
18
  import { COMPACT_PLAN_ROWS } from "./plan-presentation.js";
19
19
  import {
20
20
  createRushPlanComponent, readRushPlan, renderRushPlan, workflowBinding,
21
- WORKFLOW_PLAN_ENTRY, type RushPlan, type WorkflowPlanBinding,
21
+ WORKFLOW_PLAN_ENTRY, visibleRushUnits, type RushPlan, type WorkflowPlanBinding,
22
22
  } from "./workflow-plan.js";
23
+ import { cancelledRowsClearDelay, observeCancelledRows } from "./cancelled-rows.js";
23
24
  import { applyRushPlanUpdate, type RushPlanUpdate } from "./workflow-plan-update.js";
24
25
  import {
25
26
  EXTENSION_NAME,
@@ -131,18 +132,45 @@ export default function planExtension(pi: ExtensionAPI): void {
131
132
  let rushError: string | null = null;
132
133
  let refreshWidget: ((force?: boolean) => void) | undefined;
133
134
  let completedPlanClearTimer: ReturnType<typeof setTimeout> | undefined;
135
+ let cancelledRowsTimer: ReturnType<typeof setTimeout> | undefined;
136
+ let refreshFullPlan: (() => void) | undefined;
137
+ const cancelCancelledRowsTimer = (): void => {
138
+ if (cancelledRowsTimer !== undefined) clearTimeout(cancelledRowsTimer);
139
+ cancelledRowsTimer = undefined;
140
+ };
141
+ const scheduleCancelledRowsClear = (): void => {
142
+ cancelCancelledRowsTimer();
143
+ if (!rushPlan) return;
144
+ const delay = cancelledRowsClearDelay(rushPlan);
145
+ if (delay === null) return;
146
+ const timer = setTimeout(() => {
147
+ if (cancelledRowsTimer !== timer) return;
148
+ cancelledRowsTimer = undefined;
149
+ refresh(true);
150
+ scheduleCancelledRowsClear();
151
+ }, delay);
152
+ cancelledRowsTimer = timer;
153
+ timer.unref?.();
154
+ };
155
+ const observeCancellations = (plan: RushPlan, binding: WorkflowPlanBinding, ctx: ExtensionContext): void => {
156
+ observeCancelledRows(plan, binding, ctx.sessionManager.getBranch(), (type, data) => pi.appendEntry(type, data));
157
+ };
134
158
  const unsubscribeWorkflow = pi.events.on("pi-better-workflow:changed", (owner) => {
135
159
  displayedWorkflowOwner = owner && typeof owner === "object" && "name" in owner && typeof owner.name === "string"
136
160
  ? owner.name : null;
137
161
  if (displayedWorkflowOwner) {
162
+ cancelCancelledRowsTimer();
138
163
  rushBinding = null;
139
164
  rushPlan = null;
140
165
  rushError = null;
141
166
  }
142
- refreshWidget?.(true);
167
+ refresh(true);
143
168
  });
144
169
 
145
- const refresh = (force = false): void => refreshWidget?.(force);
170
+ const refresh = (force = false): void => {
171
+ refreshWidget?.(force);
172
+ refreshFullPlan?.();
173
+ };
146
174
 
147
175
  const loadRushPlan = (ctx: ExtensionContext): RushPlan | null => {
148
176
  const owner = workflowPlanOwner(ctx);
@@ -152,33 +180,39 @@ export default function planExtension(pi: ExtensionAPI): void {
152
180
  if (!rushBinding) {
153
181
  rushPlan = null;
154
182
  rushError = null;
183
+ cancelCancelledRowsTimer();
155
184
  return null;
156
185
  }
157
186
  try {
158
187
  const plan = readRushPlan(rushBinding.path, ctx.cwd);
159
188
  if (plan.runId !== rushBinding.runId) throw new Error("Rush run identity changed.");
189
+ observeCancellations(plan, rushBinding, ctx);
160
190
  rushPlan = plan;
161
191
  rushError = null;
162
192
  } catch (error) {
163
193
  rushPlan = null;
164
194
  rushError = error instanceof Error ? error.message : String(error);
165
195
  }
196
+ scheduleCancelledRowsClear();
166
197
  refresh(true);
167
198
  return rushPlan;
168
199
  };
169
200
 
170
- const displayWorkflowPlan = (plan: RushPlan, binding: WorkflowPlanBinding, readOnly = false): void => {
201
+ const displayWorkflowPlan = (plan: RushPlan, binding: WorkflowPlanBinding, ctx: ExtensionContext, readOnly = false): void => {
171
202
  if (!rushBinding || rushBinding.owner !== binding.owner || rushBinding.path !== binding.path || rushBinding.runId !== binding.runId) {
172
203
  pi.appendEntry(WORKFLOW_PLAN_ENTRY, { version: 1, kind: "set", ...binding });
173
204
  }
174
205
  displayedWorkflowOwner = readOnly ? null : binding.owner;
175
206
  rushBinding = binding;
207
+ observeCancellations(plan, binding, ctx);
176
208
  rushPlan = plan;
177
209
  rushError = null;
210
+ scheduleCancelledRowsClear();
178
211
  refresh(true);
179
212
  };
180
213
 
181
214
  const clearWorkflowPlan = (): void => {
215
+ cancelCancelledRowsTimer();
182
216
  if (rushBinding) pi.appendEntry(WORKFLOW_PLAN_ENTRY, { version: 1, kind: "clear" });
183
217
  rushBinding = null;
184
218
  rushPlan = null;
@@ -215,6 +249,7 @@ export default function planExtension(pi: ExtensionAPI): void {
215
249
 
216
250
  const restore = (ctx: ExtensionContext): void => {
217
251
  cancelCompletedPlanClear();
252
+ cancelCancelledRowsTimer();
218
253
  const state = reconstructPlanState(ctx.sessionManager.getBranch());
219
254
  currentPlan = state.plan;
220
255
  displayedWorkflowOwner = workflowPlanOwner(ctx);
@@ -271,7 +306,7 @@ export default function planExtension(pi: ExtensionAPI): void {
271
306
  if (!currentPlan) return [];
272
307
  return renderCompactPlan(currentPlan, width, theme as never, expanded);
273
308
  },
274
- () => (rushBinding ? rushPlan?.issues.length ?? 0 : currentPlan?.steps.length ?? 0) > COMPACT_PLAN_ROWS,
309
+ () => (rushBinding && rushPlan ? visibleRushUnits(rushPlan).length : currentPlan?.steps.length ?? 0) > COMPACT_PLAN_ROWS,
275
310
  () => expanded,
276
311
  () => setExpanded(!expanded),
277
312
  );
@@ -300,16 +335,21 @@ export default function planExtension(pi: ExtensionAPI): void {
300
335
  ctx.ui.notify(renderRushPlan(plan, 160, true, undefined, planOwner, !owner).join("\n"), "info");
301
336
  return;
302
337
  }
303
- await ctx.ui.custom<void>((tui, theme, _keys, done) => {
304
- const fg = typeof theme?.fg === "function"
305
- ? (color: string, value: string) => theme.fg(color as never, value)
306
- : undefined;
307
- const component = createRushPlanComponent(plan, () => done(), fg, planOwner, !owner);
308
- return { render: (width) => component.render(width), handleInput(data) {
309
- component.handleInput?.(data);
310
- tui.requestRender();
311
- }, invalidate: () => component.invalidate() };
312
- });
338
+ try {
339
+ await ctx.ui.custom<void>((tui, theme, _keys, done) => {
340
+ refreshFullPlan = () => tui.requestRender();
341
+ const fg = typeof theme?.fg === "function"
342
+ ? (color: string, value: string) => theme.fg(color as never, value)
343
+ : undefined;
344
+ const component = createRushPlanComponent(() => rushPlan ?? { ...plan, issues: [] }, () => done(), fg, planOwner, !owner);
345
+ return { render: (width) => component.render(width), handleInput(data) {
346
+ component.handleInput?.(data);
347
+ tui.requestRender();
348
+ }, invalidate: () => component.invalidate() };
349
+ });
350
+ } finally {
351
+ refreshFullPlan = undefined;
352
+ }
313
353
  return;
314
354
  }
315
355
  if (!currentPlan) {
@@ -384,14 +424,14 @@ export default function planExtension(pi: ExtensionAPI): void {
384
424
  }
385
425
  binding = { owner: owner ?? binding!.owner, path, runId: plan.runId };
386
426
  if (transition.event === undefined) {
387
- displayWorkflowPlan(plan, binding, !owner);
427
+ displayWorkflowPlan(plan, binding, ctx, !owner);
388
428
  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 } : {}) } };
389
429
  }
390
430
  }
391
431
  if (!binding) throw new Error(`No ${owner} plan is bound; send workflow.path and workflow.revision to update_plan first.`);
392
432
  if (transition.event === undefined) throw new Error("workflow.path is required to bind or reload a plan; otherwise send workflow.event and changes.");
393
433
  const result = applyRushPlanUpdate(binding.path, ctx.cwd, binding.runId, { ...transition, event: transition.event });
394
- displayWorkflowPlan(result.plan, binding);
434
+ displayWorkflowPlan(result.plan, binding, ctx);
395
435
  const rows = result.changed.map(({ target, id, added }) =>
396
436
  `${added ? "+" : ""}${id === null ? "run" : target === "unit" ? `#${id}` : id}`);
397
437
  const logNote = result.logAheadRevision === undefined ? ""
@@ -521,6 +561,8 @@ export default function planExtension(pi: ExtensionAPI): void {
521
561
  });
522
562
  pi.on("session_shutdown", async (_event, ctx) => {
523
563
  cancelCompletedPlanClear();
564
+ cancelCancelledRowsTimer();
565
+ refreshFullPlan = undefined;
524
566
  if (typeof unsubscribeWorkflow === "function") unsubscribeWorkflow();
525
567
  refreshWidget = undefined;
526
568
  try {
@@ -12,6 +12,8 @@ export interface PlanRow {
12
12
  label: string;
13
13
  title: string;
14
14
  status: DisplayStatus;
15
+ // Workflow cancellations keep skipped styling but are excluded from the completion total.
16
+ cancelled?: boolean;
15
17
  statusLabel?: string;
16
18
  details?: string[];
17
19
  }
@@ -65,7 +67,8 @@ export function renderPlanPresentation(
65
67
  ): string[] {
66
68
  const size = Math.max(1, Math.floor(width));
67
69
  const count = (status: DisplayStatus) => plan.rows.filter((row) => row.status === status).length;
68
- const summary = [`${count("completed")}/${plan.rows.length} complete`];
70
+ const total = plan.rows.filter((row) => !row.cancelled).length;
71
+ const summary = [`${count("completed")}/${total} complete`];
69
72
  for (const [status, label] of [
70
73
  ["in_progress", "in progress"], ["implemented", "implemented"], ["reviewed", "review passed"],
71
74
  ["blocked", "blocked"], ["failed", "failed"],
@@ -129,16 +132,22 @@ export function renderPlanPresentation(
129
132
  }
130
133
 
131
134
  export function createPlanPresentationComponent(
132
- plan: PlanPresentation, theme: PlanRenderTheme, onClose: () => void,
135
+ source: PlanPresentation | (() => PlanPresentation), theme: PlanRenderTheme, onClose: () => void,
133
136
  ): Component {
137
+ const presentation = () => typeof source === "function" ? source() : source;
138
+ const plan = presentation();
134
139
  let selected = plan.rows.findIndex((row) => row.status === "in_progress");
135
140
  if (selected < 0) selected = plan.rows.findIndex((row) => row.status !== "completed");
136
141
  if (selected < 0) selected = Math.max(0, plan.rows.length - 1);
137
142
  return {
138
- render: (width) => renderPlanPresentation(plan, width, theme, true, selected),
143
+ render: (width) => {
144
+ const current = presentation();
145
+ selected = Math.min(selected, Math.max(0, current.rows.length - 1));
146
+ return renderPlanPresentation(current, width, theme, true, selected);
147
+ },
139
148
  handleInput(data) {
140
149
  if (matchesKey(data, "up")) selected = Math.max(0, selected - 1);
141
- else if (matchesKey(data, "down")) selected = Math.min(Math.max(0, plan.rows.length - 1), selected + 1);
150
+ else if (matchesKey(data, "down")) selected = Math.min(Math.max(0, presentation().rows.length - 1), selected + 1);
142
151
  else if (matchesKey(data, "escape") || matchesKey(data, "left") || matchesKey(data, "ctrl+c")) onClose();
143
152
  },
144
153
  invalidate() {},
@@ -7,12 +7,14 @@ import {
7
7
  } from "./plan-presentation.js";
8
8
 
9
9
  export const WORKFLOW_PLAN_ENTRY = "pi-better-workflow-plan";
10
+ export const CANCELLED_ROW_RETENTION_MS = 30_000;
10
11
 
11
12
  interface RushUnit {
12
13
  id: string;
13
14
  title: string;
14
15
  stage: string;
15
16
  status: string;
17
+ cancelledAt?: number;
16
18
  dependsOn: string[];
17
19
  worker?: string | number | undefined;
18
20
  note?: string | undefined;
@@ -134,7 +136,7 @@ function workflowStatus(status: string): DisplayStatus {
134
136
  case "pending": return "pending";
135
137
  case "blocked": return "blocked";
136
138
  case "failed": return "failed";
137
- case "skipped": case "not-applicable": case "cancelled": return "skipped";
139
+ case "skipped": case "not-applicable": case "cancelled": case "canceled": return "skipped";
138
140
  default: return "unknown";
139
141
  }
140
142
  }
@@ -162,19 +164,25 @@ function presentRushPlan(plan: RushPlan, owner: string, readOnly = false): PlanP
162
164
  if (plan.spec?.title) metadata.push([{ color: "dim", text: plan.spec.title }]);
163
165
  return {
164
166
  metadata,
165
- rows: plan.issues.map((unit) => {
167
+ rows: visibleRushUnits(plan).map((unit) => {
166
168
  const status = unitStatus(unit);
169
+ const cancelled = unit.status === "cancelled" || unit.status === "canceled";
167
170
  const details = [`${unit.stage} · ${unit.status}${unit.delivery ? ` · ${unit.delivery}` : ""}${unit.worker !== undefined ? ` · worker ${unit.worker}` : ""}`];
168
171
  if (unit.dependsOn.length) details.push(`after: ${unit.dependsOn.map((id) => `#${id}`).join(", ")}`);
169
172
  if (unit.note) details.push(unit.note);
170
173
  return {
171
- label: `#${unit.id}`, title: unit.title, status, details,
172
- ...(status === "unknown" || unit.status === "cancelled" ? { statusLabel: unit.status } : {}),
174
+ label: `#${unit.id}`, title: unit.title, status, cancelled, details,
175
+ ...(status === "unknown" || cancelled ? { statusLabel: unit.status } : {}),
173
176
  };
174
177
  }),
175
178
  };
176
179
  }
177
180
 
181
+ export function visibleRushUnits(plan: RushPlan, now = Date.now()): RushPlan["issues"] {
182
+ return plan.issues.filter(unit => (unit.status !== "cancelled" && unit.status !== "canceled") ||
183
+ unit.cancelledAt === undefined || now < unit.cancelledAt + CANCELLED_ROW_RETENTION_MS);
184
+ }
185
+
178
186
  export function renderRushPlan(
179
187
  plan: RushPlan,
180
188
  width: number,
@@ -189,12 +197,12 @@ export function renderRushPlan(
189
197
  }
190
198
 
191
199
  export function createRushPlanComponent(
192
- plan: RushPlan,
200
+ source: RushPlan | (() => RushPlan),
193
201
  onClose: () => void,
194
202
  fg?: (color: string, value: string) => string,
195
203
  owner = "rush-issues",
196
204
  readOnly = false,
197
205
  ): Component {
198
206
  const theme = fg ? { fg } : plainPlanTheme;
199
- return createPlanPresentationComponent(presentRushPlan(plan, owner, readOnly), theme, onClose);
207
+ return createPlanPresentationComponent(() => presentRushPlan(typeof source === "function" ? source() : source, owner, readOnly), theme, onClose);
200
208
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.29.1",
3
+ "version": "0.29.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.9.0",
57
57
  "pi-better-goal": "0.9.0",
58
- "pi-better-plan": "0.9.0",
58
+ "pi-better-plan": "0.9.1",
59
59
  "pi-better-sandbox": "0.12.0",
60
60
  "pi-better-ssh": "0.1.2",
61
61
  "pi-better-subagents": "0.18.0",