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 +10 -7
- package/extensions/minimal-output/hook.ts +72 -4
- package/extensions/minimal-output/index.ts +28 -1
- package/extensions/minimal-output/tool-identity.ts +66 -0
- package/extensions/minimal-output/tool-visibility.ts +61 -0
- package/node_modules/pi-better-plan/README.md +36 -74
- package/node_modules/pi-better-plan/REFERENCE.md +111 -0
- package/node_modules/pi-better-plan/package.json +3 -2
- package/node_modules/pi-better-plan/src/index.ts +85 -29
- package/node_modules/pi-better-plan/src/plan-presentation.ts +32 -2
- package/node_modules/pi-better-plan/src/plan-render.ts +2 -2
- package/node_modules/pi-better-plan/src/plan-state.ts +6 -2
- package/node_modules/pi-better-plan/src/plan-widget.ts +41 -0
- package/node_modules/pi-better-plan/src/types.ts +2 -1
- package/node_modules/pi-better-plan/src/workflow-plan.ts +8 -4
- package/package.json +2 -2
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.
|
|
72
|
-
muted when completed, and error-colored when failed.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
134
|
-
Changes apply immediately and
|
|
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
|
-
? ` ${
|
|
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
|
|
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
|
-
|
|
3
|
+
See what Pi is working on, what is done, and what comes next.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
7
|
+
```sh
|
|
8
|
+
pi install npm:pi-better-plan
|
|
9
|
+
```
|
|
17
10
|
|
|
18
|
-
##
|
|
11
|
+
## Using the plan
|
|
19
12
|
|
|
20
|
-
|
|
13
|
+
Ask Pi to make a plan for your task. The checklist appears above the editor and updates as Pi works.
|
|
21
14
|
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
+
## Examples
|
|
42
24
|
|
|
43
|
-
|
|
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
|
-
|
|
27
|
+
### Just started
|
|
51
28
|
|
|
52
|
-
|
|
29
|
+
The first five steps are visible.
|
|
53
30
|
|
|
54
|
-
|
|
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
|
+

|
|
65
32
|
|
|
66
|
-
|
|
33
|
+
### In the middle
|
|
67
34
|
|
|
68
|
-
|
|
35
|
+
The active step has nearby context, with hidden steps counted on both sides.
|
|
69
36
|
|
|
70
|
-
|
|
37
|
+

|
|
71
38
|
|
|
72
|
-
|
|
39
|
+
### Near the end
|
|
73
40
|
|
|
74
|
-
|
|
41
|
+
The view shifts to the last five steps.
|
|
75
42
|
|
|
76
|
-
|
|
43
|
+

|
|
77
44
|
|
|
78
|
-
|
|
45
|
+
### Expanded
|
|
79
46
|
|
|
80
|
-
|
|
47
|
+
See every step. Click again or run `/plan collapse` to return to the compact view.
|
|
81
48
|
|
|
82
|
-
|
|
83
|
-
pi install npm:pi-better-plan
|
|
84
|
-
```
|
|
49
|
+

|
|
85
50
|
|
|
86
51
|
## Commands
|
|
87
52
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
62
|
+
## For developers
|
|
99
63
|
|
|
100
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
230
|
-
|
|
259
|
+
const widget = createPlanWidget(
|
|
260
|
+
(width: number): string[] => {
|
|
231
261
|
if (displayMode === "hidden" || displayedWorkflowOwner !== workflowPlanOwner(ctx)) return [];
|
|
232
|
-
if (displayedWorkflowOwner) {
|
|
233
|
-
if (rushBinding
|
|
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 ?
|
|
268
|
+
return rushPlan ? renderRushPlan(rushPlan, width, false, fg, rushBinding.owner, !displayedWorkflowOwner, expanded) : [];
|
|
238
269
|
}
|
|
239
270
|
if (!currentPlan) return [];
|
|
240
|
-
return
|
|
271
|
+
return renderCompactPlan(currentPlan, width, theme as never, expanded);
|
|
241
272
|
},
|
|
242
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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.
|
|
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.
|
|
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",
|