pi-better-harness 0.3.4 → 0.3.6
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/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/shared-navigator.ts +3 -36
- package/node_modules/pi-better-plan/README.md +1 -1
- package/node_modules/pi-better-plan/package.json +2 -2
- package/node_modules/pi-better-plan/src/index.ts +39 -143
- package/node_modules/pi-better-plan/src/plan-render.ts +1 -4
- package/node_modules/pi-better-plan/src/plan-state.ts +15 -1
- package/node_modules/pi-better-plan/src/types.ts +1 -0
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/shared-navigator.ts +3 -36
- package/package.json +4 -4
|
@@ -97,7 +97,6 @@ type NavigatorState = {
|
|
|
97
97
|
};
|
|
98
98
|
|
|
99
99
|
const GLOBAL_KEY = Symbol.for("pi-better-harness.navigator.state");
|
|
100
|
-
const PLAN_NAVIGATION_KEY = Symbol.for("pi-better-harness.plan-navigation.state");
|
|
101
100
|
const FACTORY_MARK = "__piBetterHarnessNavigatorFactory";
|
|
102
101
|
const FACTORY_REFRESH = "__piBetterHarnessNavigatorRefresh";
|
|
103
102
|
|
|
@@ -121,23 +120,6 @@ function state(): NavigatorState {
|
|
|
121
120
|
return g[GLOBAL_KEY]!;
|
|
122
121
|
}
|
|
123
122
|
|
|
124
|
-
type PlanNavigationState = {
|
|
125
|
-
visible: boolean;
|
|
126
|
-
progressLabel?: string;
|
|
127
|
-
releaseWorkFocus?: () => void;
|
|
128
|
-
refreshNavigationHint?: () => void;
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
function planNavigationState(): PlanNavigationState {
|
|
132
|
-
const global = globalThis as typeof globalThis & { [PLAN_NAVIGATION_KEY]?: PlanNavigationState };
|
|
133
|
-
if (!global[PLAN_NAVIGATION_KEY]) global[PLAN_NAVIGATION_KEY] = { visible: false };
|
|
134
|
-
return global[PLAN_NAVIGATION_KEY]!;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function refreshPlanNavigationHint(): void {
|
|
138
|
-
refreshBackgroundWorkNavigator();
|
|
139
|
-
}
|
|
140
|
-
|
|
141
123
|
export function registerBackgroundWorkProvider(provider: BackgroundWorkProvider): () => void {
|
|
142
124
|
const s = state();
|
|
143
125
|
const previousUnsub = s.unsubscribers.get(provider.id);
|
|
@@ -179,12 +161,7 @@ export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boole
|
|
|
179
161
|
}
|
|
180
162
|
|
|
181
163
|
export function navigatorFooterHint(count: number): string | null {
|
|
182
|
-
|
|
183
|
-
const navigation = planNavigationState();
|
|
184
|
-
const plan = navigation.visible
|
|
185
|
-
? `→ plan${navigation.progressLabel ? ` · ${navigation.progressLabel}` : ""}`
|
|
186
|
-
: null;
|
|
187
|
-
return [work, plan].filter((hint): hint is string => hint !== null).join(" ") || null;
|
|
164
|
+
return count > 0 ? `← work · ${count}` : null;
|
|
188
165
|
}
|
|
189
166
|
|
|
190
167
|
export function applyNavigatorFooter(ui: { setStatus(key: string, value: string | undefined): void }, count: number): string | null {
|
|
@@ -208,10 +185,6 @@ export function ensureBackgroundWorkNavigator(ctx: ExtensionContext, deps: HostD
|
|
|
208
185
|
s.mainListRequestRender = undefined;
|
|
209
186
|
s.mainListDeadlineScheduler?.dispose();
|
|
210
187
|
s.mainListDeadlineScheduler = createRenderScheduler(() => refreshMainListWidget());
|
|
211
|
-
const planNavigation = planNavigationState();
|
|
212
|
-
planNavigation.releaseWorkFocus = unfocusMainList;
|
|
213
|
-
planNavigation.refreshNavigationHint = refreshPlanNavigationHint;
|
|
214
|
-
try { (ctx.ui as any).setStatus?.("pi-better-plan-nav", undefined); } catch { /* ignore */ }
|
|
215
188
|
installNavigatorEditor(ctx.ui as any, deps);
|
|
216
189
|
s.lastHint = undefined;
|
|
217
190
|
refreshBackgroundWorkNavigator(ctx);
|
|
@@ -240,11 +213,6 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
|
240
213
|
s.mainListRequestRender = undefined;
|
|
241
214
|
s.editorComponent = undefined;
|
|
242
215
|
s.detailOverlayRows = undefined;
|
|
243
|
-
const planNavigation = planNavigationState();
|
|
244
|
-
planNavigation.releaseWorkFocus = undefined;
|
|
245
|
-
if (planNavigation.refreshNavigationHint === refreshPlanNavigationHint) {
|
|
246
|
-
planNavigation.refreshNavigationHint = undefined;
|
|
247
|
-
}
|
|
248
216
|
s.mainListSelectedId = undefined;
|
|
249
217
|
s.mainListFocused = false;
|
|
250
218
|
}
|
|
@@ -492,10 +460,9 @@ function buildMainListLines(
|
|
|
492
460
|
}
|
|
493
461
|
|
|
494
462
|
function shortcutsLine(focused: boolean, fg: (color: string, value: string) => string): string {
|
|
495
|
-
const plan = planNavigationState().visible ? " · → plan" : "";
|
|
496
463
|
const keys = focused
|
|
497
|
-
?
|
|
498
|
-
:
|
|
464
|
+
? "↑↓ switch · Enter detail · x stop · Esc unfocus"
|
|
465
|
+
: "← work navigator";
|
|
499
466
|
return dim(keys, fg);
|
|
500
467
|
}
|
|
501
468
|
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
- Gives models `update_plan` and `get_plan` tools for atomic, explicit progress updates.
|
|
8
8
|
- Shows the complete checklist of completed, active, pending, and blocked steps above the editor.
|
|
9
|
-
- Uses `→` from an empty editor to focus the plan; normal cursor movement is preserved when the editor contains text.
|
|
10
9
|
- Persists plan state and display preferences on the active Pi session branch.
|
|
10
|
+
- Keeps a completed plan visible for 30 seconds, then clears it automatically.
|
|
11
11
|
- Opens the complete plan with `/plan`.
|
|
12
12
|
|
|
13
13
|
Plan progress is checklist progress, not an estimate of effort. The extension never infers completion from prose or successful tool calls.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-plan",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Structured execution plans with persistent progress for Pi.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -70,4 +70,4 @@
|
|
|
70
70
|
"engines": {
|
|
71
71
|
"node": ">=22.0.0"
|
|
72
72
|
}
|
|
73
|
-
}
|
|
73
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
|
+
completedPlanClearDelay,
|
|
7
8
|
planClearEntry,
|
|
8
9
|
planDisplayEntry,
|
|
9
10
|
planProgress,
|
|
@@ -36,74 +37,64 @@ const UpdatePlanSchema = Type.Object({
|
|
|
36
37
|
plan: Type.Array(PlanStepSchema, { minItems: 1, maxItems: 50 }),
|
|
37
38
|
});
|
|
38
39
|
|
|
39
|
-
const
|
|
40
|
-
const PLAN_EDITOR_FACTORY_MARK = "__piBetterPlanFactory";
|
|
41
|
-
const PLAN_EDITOR_FACTORY_REFRESH = "__piBetterPlanRefresh";
|
|
42
|
-
const PLAN_NAVIGATION_KEY = Symbol.for("pi-better-harness.plan-navigation.state");
|
|
43
|
-
|
|
44
|
-
interface SharedPlanNavigationState {
|
|
45
|
-
visible: boolean;
|
|
46
|
-
progressLabel?: string;
|
|
47
|
-
releaseWorkFocus?: () => void;
|
|
48
|
-
refreshNavigationHint?: () => void;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function sharedPlanNavigationState(): SharedPlanNavigationState {
|
|
52
|
-
const global = globalThis as typeof globalThis & { [PLAN_NAVIGATION_KEY]?: SharedPlanNavigationState };
|
|
53
|
-
if (!global[PLAN_NAVIGATION_KEY]) global[PLAN_NAVIGATION_KEY] = { visible: false };
|
|
54
|
-
return global[PLAN_NAVIGATION_KEY]!;
|
|
55
|
-
}
|
|
40
|
+
const LEGACY_PLAN_NAV_STATUS_KEY = "pi-better-plan-nav";
|
|
56
41
|
|
|
57
42
|
export default function planExtension(pi: ExtensionAPI): void {
|
|
58
43
|
let currentPlan: PlanSnapshot | null = null;
|
|
59
44
|
let displayMode: PlanDisplayMode = "auto";
|
|
60
|
-
let selectedIndex = 0;
|
|
61
|
-
let focused = false;
|
|
62
45
|
let refreshWidget: ((force?: boolean) => void) | undefined;
|
|
63
|
-
let
|
|
46
|
+
let completedPlanClearTimer: ReturnType<typeof setTimeout> | undefined;
|
|
64
47
|
|
|
65
48
|
const refresh = (force = false): void => refreshWidget?.(force);
|
|
66
49
|
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
50
|
+
const cancelCompletedPlanClear = (): void => {
|
|
51
|
+
if (completedPlanClearTimer) clearTimeout(completedPlanClearTimer);
|
|
52
|
+
completedPlanClearTimer = undefined;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const clearPlan = (): void => {
|
|
56
|
+
cancelCompletedPlanClear();
|
|
57
|
+
currentPlan = null;
|
|
58
|
+
pi.appendEntry(EXTENSION_NAME, planClearEntry());
|
|
59
|
+
refresh(true);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const scheduleCompletedPlanClear = (): void => {
|
|
63
|
+
cancelCompletedPlanClear();
|
|
64
|
+
if (!currentPlan) return;
|
|
65
|
+
const delay = completedPlanClearDelay(currentPlan);
|
|
66
|
+
if (delay === null) return;
|
|
67
|
+
const completedPlan = currentPlan;
|
|
68
|
+
const timer = setTimeout(() => {
|
|
69
|
+
if (completedPlanClearTimer !== timer) return;
|
|
70
|
+
completedPlanClearTimer = undefined;
|
|
71
|
+
if (currentPlan?.planId !== completedPlan.planId || currentPlan.revision !== completedPlan.revision) return;
|
|
72
|
+
clearPlan();
|
|
73
|
+
}, delay);
|
|
74
|
+
completedPlanClearTimer = timer;
|
|
75
|
+
timer.unref?.();
|
|
82
76
|
};
|
|
83
77
|
|
|
84
78
|
const restore = (ctx: ExtensionContext): void => {
|
|
79
|
+
cancelCompletedPlanClear();
|
|
85
80
|
const state = reconstructPlanState(ctx.sessionManager.getBranch());
|
|
86
81
|
currentPlan = state.plan;
|
|
87
82
|
displayMode = state.displayMode;
|
|
88
|
-
|
|
89
|
-
focused = false;
|
|
90
|
-
updateNavigationHint(ctx);
|
|
83
|
+
if (ctx.hasUI) ctx.ui.setStatus(LEGACY_PLAN_NAV_STATUS_KEY, undefined);
|
|
91
84
|
refresh(true);
|
|
85
|
+
scheduleCompletedPlanClear();
|
|
92
86
|
};
|
|
93
87
|
|
|
94
88
|
const persistPlan = (plan: PlanSnapshot): void => {
|
|
95
89
|
currentPlan = plan;
|
|
96
|
-
selectedIndex = preferredIndex(plan);
|
|
97
|
-
focused = false;
|
|
98
90
|
pi.appendEntry(EXTENSION_NAME, planSetEntry(plan));
|
|
99
|
-
updateNavigationHint();
|
|
100
91
|
refresh(true);
|
|
92
|
+
scheduleCompletedPlanClear();
|
|
101
93
|
};
|
|
102
94
|
|
|
103
95
|
const setDisplayMode = (mode: PlanDisplayMode): void => {
|
|
104
96
|
displayMode = mode;
|
|
105
97
|
pi.appendEntry(EXTENSION_NAME, planDisplayEntry(mode));
|
|
106
|
-
updateNavigationHint();
|
|
107
98
|
refresh(true);
|
|
108
99
|
};
|
|
109
100
|
|
|
@@ -117,7 +108,7 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
117
108
|
return {
|
|
118
109
|
render(width: number): string[] {
|
|
119
110
|
if (!currentPlan || displayMode === "hidden") return [];
|
|
120
|
-
return renderCompactPlan(currentPlan, width, theme as never
|
|
111
|
+
return renderCompactPlan(currentPlan, width, theme as never);
|
|
121
112
|
},
|
|
122
113
|
invalidate() {},
|
|
123
114
|
dispose() {
|
|
@@ -139,8 +130,6 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
139
130
|
return;
|
|
140
131
|
}
|
|
141
132
|
const snapshot = currentPlan;
|
|
142
|
-
focused = false;
|
|
143
|
-
refresh();
|
|
144
133
|
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
145
134
|
const component = createFullPlanComponent(snapshot, theme as never, () => done());
|
|
146
135
|
return {
|
|
@@ -154,80 +143,6 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
154
143
|
});
|
|
155
144
|
};
|
|
156
145
|
|
|
157
|
-
const handlePlanNavigation = (data: string, ctx: ExtensionContext): boolean => {
|
|
158
|
-
if (!currentPlan || displayMode === "hidden") return false;
|
|
159
|
-
if (!focused) {
|
|
160
|
-
if (!matchesKey(data, Key.right)) return false;
|
|
161
|
-
sharedPlanNavigationState().releaseWorkFocus?.();
|
|
162
|
-
focused = true;
|
|
163
|
-
selectedIndex = preferredIndex(currentPlan);
|
|
164
|
-
refresh();
|
|
165
|
-
return true;
|
|
166
|
-
}
|
|
167
|
-
if (matchesKey(data, Key.up)) {
|
|
168
|
-
selectedIndex = Math.max(0, selectedIndex - 1);
|
|
169
|
-
refresh();
|
|
170
|
-
return true;
|
|
171
|
-
}
|
|
172
|
-
if (matchesKey(data, Key.down)) {
|
|
173
|
-
selectedIndex = Math.min(currentPlan.steps.length - 1, selectedIndex + 1);
|
|
174
|
-
refresh();
|
|
175
|
-
return true;
|
|
176
|
-
}
|
|
177
|
-
if (matchesKey(data, Key.enter) || matchesKey(data, Key.right)) {
|
|
178
|
-
void showFullPlan(ctx);
|
|
179
|
-
return true;
|
|
180
|
-
}
|
|
181
|
-
if (matchesKey(data, Key.left) || matchesKey(data, Key.escape)) {
|
|
182
|
-
focused = false;
|
|
183
|
-
refresh();
|
|
184
|
-
return true;
|
|
185
|
-
}
|
|
186
|
-
focused = false;
|
|
187
|
-
refresh();
|
|
188
|
-
return false;
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
const installEditorNavigation = (ctx: ExtensionContext): void => {
|
|
192
|
-
if (ctx.mode !== "tui") return;
|
|
193
|
-
const ui = ctx.ui as typeof ctx.ui & {
|
|
194
|
-
getEditorComponent?(): ((tui: unknown, theme: unknown, keybindings: unknown) => unknown) | undefined;
|
|
195
|
-
setEditorComponent(factory: (tui: unknown, theme: unknown, keybindings: unknown) => unknown): void;
|
|
196
|
-
};
|
|
197
|
-
const previous = ui.getEditorComponent?.() as
|
|
198
|
-
| (((tui: unknown, theme: unknown, keybindings: unknown) => unknown) & Record<string, unknown>)
|
|
199
|
-
| undefined;
|
|
200
|
-
const handlers = { handle: (data: string) => handlePlanNavigation(data, ctx) };
|
|
201
|
-
if (previous?.[PLAN_EDITOR_FACTORY_MARK] === true) {
|
|
202
|
-
const replaceHandlers = previous[PLAN_EDITOR_FACTORY_REFRESH];
|
|
203
|
-
if (typeof replaceHandlers === "function") replaceHandlers(handlers);
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
let currentHandlers = handlers;
|
|
207
|
-
const factory = ((tui: unknown, theme: unknown, keybindings: unknown) => {
|
|
208
|
-
const inner = (previous
|
|
209
|
-
? previous(tui, theme, keybindings)
|
|
210
|
-
: new CustomEditor(tui as never, theme as never, keybindings as never)) as {
|
|
211
|
-
getText?(): string;
|
|
212
|
-
handleInput(data: string): void;
|
|
213
|
-
};
|
|
214
|
-
return new Proxy(inner, {
|
|
215
|
-
get(target, property) {
|
|
216
|
-
if (property === "handleInput") {
|
|
217
|
-
return (data: string) => {
|
|
218
|
-
if (target.getText?.() === "" && currentHandlers.handle(data)) return;
|
|
219
|
-
target.handleInput(data);
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
const value = Reflect.get(target, property);
|
|
223
|
-
return typeof value === "function" ? value.bind(target) : value;
|
|
224
|
-
},
|
|
225
|
-
});
|
|
226
|
-
}) as ((tui: unknown, theme: unknown, keybindings: unknown) => unknown) & Record<string, unknown>;
|
|
227
|
-
factory[PLAN_EDITOR_FACTORY_MARK] = true;
|
|
228
|
-
factory[PLAN_EDITOR_FACTORY_REFRESH] = (next: typeof handlers) => { currentHandlers = next; };
|
|
229
|
-
ui.setEditorComponent(factory);
|
|
230
|
-
};
|
|
231
146
|
|
|
232
147
|
pi.registerTool({
|
|
233
148
|
name: "update_plan",
|
|
@@ -289,11 +204,7 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
289
204
|
const input = args.trim().toLowerCase();
|
|
290
205
|
if (!input) return showFullPlan(ctx);
|
|
291
206
|
if (input === "clear") {
|
|
292
|
-
|
|
293
|
-
focused = false;
|
|
294
|
-
pi.appendEntry(EXTENSION_NAME, planClearEntry());
|
|
295
|
-
updateNavigationHint(ctx);
|
|
296
|
-
refresh(true);
|
|
207
|
+
clearPlan();
|
|
297
208
|
ctx.ui.notify("Plan cleared.", "info");
|
|
298
209
|
return;
|
|
299
210
|
}
|
|
@@ -316,13 +227,10 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
316
227
|
});
|
|
317
228
|
|
|
318
229
|
pi.on("session_start", async (_event, ctx) => {
|
|
319
|
-
currentCtx = ctx;
|
|
320
230
|
installWidget(ctx);
|
|
321
|
-
installEditorNavigation(ctx);
|
|
322
231
|
restore(ctx);
|
|
323
232
|
});
|
|
324
233
|
pi.on("session_tree", async (_event, ctx) => {
|
|
325
|
-
currentCtx = ctx;
|
|
326
234
|
restore(ctx);
|
|
327
235
|
});
|
|
328
236
|
pi.on("before_agent_start", async (event) => {
|
|
@@ -332,27 +240,15 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
332
240
|
};
|
|
333
241
|
});
|
|
334
242
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
243
|
+
cancelCompletedPlanClear();
|
|
335
244
|
refreshWidget = undefined;
|
|
336
|
-
currentCtx = undefined;
|
|
337
|
-
const navigation = sharedPlanNavigationState();
|
|
338
|
-
navigation.visible = false;
|
|
339
|
-
delete navigation.progressLabel;
|
|
340
|
-
navigation.refreshNavigationHint?.();
|
|
341
245
|
try {
|
|
342
|
-
ctx.ui.setStatus(
|
|
246
|
+
ctx.ui.setStatus(LEGACY_PLAN_NAV_STATUS_KEY, undefined);
|
|
343
247
|
ctx.ui.setWidget(EXTENSION_NAME, undefined);
|
|
344
248
|
} catch { /* best-effort cleanup */ }
|
|
345
249
|
});
|
|
346
250
|
}
|
|
347
251
|
|
|
348
|
-
function preferredIndex(plan: PlanSnapshot | null): number {
|
|
349
|
-
if (!plan) return 0;
|
|
350
|
-
const progress = planProgress(plan);
|
|
351
|
-
if (progress.activeIndex !== null) return progress.activeIndex;
|
|
352
|
-
const next = plan.steps.findIndex((item) => item.status !== "completed");
|
|
353
|
-
return next >= 0 ? next : Math.max(0, plan.steps.length - 1);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
252
|
function formatProgress(plan: PlanSnapshot): string {
|
|
357
253
|
const progress = planProgress(plan);
|
|
358
254
|
const current = progress.activeIndex === null ? null : plan.steps[progress.activeIndex];
|
|
@@ -12,10 +12,8 @@ export function renderCompactPlan(
|
|
|
12
12
|
plan: PlanSnapshot,
|
|
13
13
|
width: number,
|
|
14
14
|
theme: PlanRenderTheme,
|
|
15
|
-
options: { focused?: boolean; selectedIndex?: number } = {},
|
|
16
15
|
): string[] {
|
|
17
16
|
const progress = planProgress(plan);
|
|
18
|
-
const selected = clampIndex(options.selectedIndex ?? progress.activeIndex ?? firstIncompleteIndex(plan), plan.steps.length);
|
|
19
17
|
const stateLabel = progress.blocked > 0
|
|
20
18
|
? `${progress.blocked} blocked`
|
|
21
19
|
: progress.state === "complete"
|
|
@@ -28,8 +26,7 @@ export function renderCompactPlan(
|
|
|
28
26
|
|
|
29
27
|
for (let index = 0; index < plan.steps.length; index += 1) {
|
|
30
28
|
const item = plan.steps[index]!;
|
|
31
|
-
|
|
32
|
-
lines.push(`${selectedPrefix}${stepGlyph(item, theme)} ${index + 1} ${stepText(item, theme)}`);
|
|
29
|
+
lines.push(` ${stepGlyph(item, theme)} ${index + 1} ${stepText(item, theme)}`);
|
|
33
30
|
}
|
|
34
31
|
lines.push("");
|
|
35
32
|
return lines.map((line) => truncateToWidth(line, Math.max(1, width)));
|
|
@@ -4,6 +4,7 @@ import { EXTENSION_NAME } from "./types.js";
|
|
|
4
4
|
const MAX_STEPS = 50;
|
|
5
5
|
const MAX_STEP_CHARS = 500;
|
|
6
6
|
const MAX_EXPLANATION_CHARS = 2_000;
|
|
7
|
+
export const COMPLETED_PLAN_RETENTION_MS = 30_000;
|
|
7
8
|
|
|
8
9
|
interface SessionEntryLike {
|
|
9
10
|
type: string;
|
|
@@ -54,6 +55,7 @@ export function replacePlan(
|
|
|
54
55
|
steps: readonly PlanStepInput[],
|
|
55
56
|
explanation?: string,
|
|
56
57
|
now = nowSeconds(),
|
|
58
|
+
completedAtMs = Date.now(),
|
|
57
59
|
): PlanSnapshot {
|
|
58
60
|
const error = validatePlanInput(steps, explanation);
|
|
59
61
|
if (error) throw new Error(error);
|
|
@@ -68,6 +70,8 @@ export function replacePlan(
|
|
|
68
70
|
status: item.status,
|
|
69
71
|
};
|
|
70
72
|
});
|
|
73
|
+
const isComplete = normalizedSteps.every((item) => item.status === "completed");
|
|
74
|
+
const currentIsComplete = current?.steps.every((item) => item.status === "completed") === true;
|
|
71
75
|
|
|
72
76
|
return {
|
|
73
77
|
version: 1,
|
|
@@ -77,9 +81,18 @@ export function replacePlan(
|
|
|
77
81
|
steps: normalizedSteps,
|
|
78
82
|
createdAt: current?.createdAt ?? now,
|
|
79
83
|
updatedAt: now,
|
|
84
|
+
...(isComplete
|
|
85
|
+
? { completedAtMs: currentIsComplete ? current.completedAtMs ?? current.updatedAt * 1_000 : completedAtMs }
|
|
86
|
+
: {}),
|
|
80
87
|
};
|
|
81
88
|
}
|
|
82
89
|
|
|
90
|
+
export function completedPlanClearDelay(plan: PlanSnapshot, now = Date.now()): number | null {
|
|
91
|
+
if (planProgress(plan).state !== "complete") return null;
|
|
92
|
+
const completedAtMs = plan.completedAtMs ?? plan.updatedAt * 1_000;
|
|
93
|
+
return Math.max(0, completedAtMs + COMPLETED_PLAN_RETENTION_MS - now);
|
|
94
|
+
}
|
|
95
|
+
|
|
83
96
|
export function planProgress(plan: PlanSnapshot): PlanProgress {
|
|
84
97
|
const completed = plan.steps.filter((item) => item.status === "completed").length;
|
|
85
98
|
const pending = plan.steps.filter((item) => item.status === "pending").length;
|
|
@@ -149,6 +162,7 @@ function isPlanSnapshot(value: unknown): value is PlanSnapshot {
|
|
|
149
162
|
typeof candidate.revision === "number" &&
|
|
150
163
|
Array.isArray(candidate.steps) &&
|
|
151
164
|
typeof candidate.createdAt === "number" &&
|
|
152
|
-
typeof candidate.updatedAt === "number"
|
|
165
|
+
typeof candidate.updatedAt === "number" &&
|
|
166
|
+
(candidate.completedAtMs === undefined || typeof candidate.completedAtMs === "number")
|
|
153
167
|
);
|
|
154
168
|
}
|
|
@@ -97,7 +97,6 @@ type NavigatorState = {
|
|
|
97
97
|
};
|
|
98
98
|
|
|
99
99
|
const GLOBAL_KEY = Symbol.for("pi-better-harness.navigator.state");
|
|
100
|
-
const PLAN_NAVIGATION_KEY = Symbol.for("pi-better-harness.plan-navigation.state");
|
|
101
100
|
const FACTORY_MARK = "__piBetterHarnessNavigatorFactory";
|
|
102
101
|
const FACTORY_REFRESH = "__piBetterHarnessNavigatorRefresh";
|
|
103
102
|
|
|
@@ -121,23 +120,6 @@ function state(): NavigatorState {
|
|
|
121
120
|
return g[GLOBAL_KEY]!;
|
|
122
121
|
}
|
|
123
122
|
|
|
124
|
-
type PlanNavigationState = {
|
|
125
|
-
visible: boolean;
|
|
126
|
-
progressLabel?: string;
|
|
127
|
-
releaseWorkFocus?: () => void;
|
|
128
|
-
refreshNavigationHint?: () => void;
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
function planNavigationState(): PlanNavigationState {
|
|
132
|
-
const global = globalThis as typeof globalThis & { [PLAN_NAVIGATION_KEY]?: PlanNavigationState };
|
|
133
|
-
if (!global[PLAN_NAVIGATION_KEY]) global[PLAN_NAVIGATION_KEY] = { visible: false };
|
|
134
|
-
return global[PLAN_NAVIGATION_KEY]!;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function refreshPlanNavigationHint(): void {
|
|
138
|
-
refreshBackgroundWorkNavigator();
|
|
139
|
-
}
|
|
140
|
-
|
|
141
123
|
export function registerBackgroundWorkProvider(provider: BackgroundWorkProvider): () => void {
|
|
142
124
|
const s = state();
|
|
143
125
|
const previousUnsub = s.unsubscribers.get(provider.id);
|
|
@@ -179,12 +161,7 @@ export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boole
|
|
|
179
161
|
}
|
|
180
162
|
|
|
181
163
|
export function navigatorFooterHint(count: number): string | null {
|
|
182
|
-
|
|
183
|
-
const navigation = planNavigationState();
|
|
184
|
-
const plan = navigation.visible
|
|
185
|
-
? `→ plan${navigation.progressLabel ? ` · ${navigation.progressLabel}` : ""}`
|
|
186
|
-
: null;
|
|
187
|
-
return [work, plan].filter((hint): hint is string => hint !== null).join(" ") || null;
|
|
164
|
+
return count > 0 ? `← work · ${count}` : null;
|
|
188
165
|
}
|
|
189
166
|
|
|
190
167
|
export function applyNavigatorFooter(ui: { setStatus(key: string, value: string | undefined): void }, count: number): string | null {
|
|
@@ -208,10 +185,6 @@ export function ensureBackgroundWorkNavigator(ctx: ExtensionContext, deps: HostD
|
|
|
208
185
|
s.mainListRequestRender = undefined;
|
|
209
186
|
s.mainListDeadlineScheduler?.dispose();
|
|
210
187
|
s.mainListDeadlineScheduler = createRenderScheduler(() => refreshMainListWidget());
|
|
211
|
-
const planNavigation = planNavigationState();
|
|
212
|
-
planNavigation.releaseWorkFocus = unfocusMainList;
|
|
213
|
-
planNavigation.refreshNavigationHint = refreshPlanNavigationHint;
|
|
214
|
-
try { (ctx.ui as any).setStatus?.("pi-better-plan-nav", undefined); } catch { /* ignore */ }
|
|
215
188
|
installNavigatorEditor(ctx.ui as any, deps);
|
|
216
189
|
s.lastHint = undefined;
|
|
217
190
|
refreshBackgroundWorkNavigator(ctx);
|
|
@@ -240,11 +213,6 @@ export function disposeBackgroundWorkNavigator(ctx?: ExtensionContext): void {
|
|
|
240
213
|
s.mainListRequestRender = undefined;
|
|
241
214
|
s.editorComponent = undefined;
|
|
242
215
|
s.detailOverlayRows = undefined;
|
|
243
|
-
const planNavigation = planNavigationState();
|
|
244
|
-
planNavigation.releaseWorkFocus = undefined;
|
|
245
|
-
if (planNavigation.refreshNavigationHint === refreshPlanNavigationHint) {
|
|
246
|
-
planNavigation.refreshNavigationHint = undefined;
|
|
247
|
-
}
|
|
248
216
|
s.mainListSelectedId = undefined;
|
|
249
217
|
s.mainListFocused = false;
|
|
250
218
|
}
|
|
@@ -492,10 +460,9 @@ function buildMainListLines(
|
|
|
492
460
|
}
|
|
493
461
|
|
|
494
462
|
function shortcutsLine(focused: boolean, fg: (color: string, value: string) => string): string {
|
|
495
|
-
const plan = planNavigationState().visible ? " · → plan" : "";
|
|
496
463
|
const keys = focused
|
|
497
|
-
?
|
|
498
|
-
:
|
|
464
|
+
? "↑↓ switch · Enter detail · x stop · Esc unfocus"
|
|
465
|
+
: "← work navigator";
|
|
499
466
|
return dim(keys, fg);
|
|
500
467
|
}
|
|
501
468
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-harness",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
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",
|
|
@@ -50,12 +50,12 @@
|
|
|
50
50
|
"test": "node --test test/*.test.mjs"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"pi-better-background-tasks": "0.2.
|
|
53
|
+
"pi-better-background-tasks": "0.2.11",
|
|
54
54
|
"pi-better-goal": "0.2.1",
|
|
55
|
-
"pi-better-plan": "0.1.
|
|
55
|
+
"pi-better-plan": "0.1.4",
|
|
56
56
|
"pi-better-sandbox": "0.3.0",
|
|
57
57
|
"pi-better-ssh": "0.1.1",
|
|
58
|
-
"pi-better-subagents": "0.1.
|
|
58
|
+
"pi-better-subagents": "0.1.26"
|
|
59
59
|
},
|
|
60
60
|
"bundledDependencies": [
|
|
61
61
|
"pi-better-background-tasks",
|