pi-better-goal 0.10.4 → 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.10.4",
3
+ "version": "0.11.0",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  renderGoalClockLine,
39
39
  } from "./goal-clock.js";
40
40
  import { createRenderScheduler } from "./shared-render-scheduler.js";
41
+ import { initializeNavigatorPanels, setNavigatorPanel } from "./shared-navigator-panels.js";
41
42
  import { collectSubagentActivity } from "./subagents.js";
42
43
  import { currentWorkflowOwner, skillCommandName, workflowEntry, workflowOwnerFromSkill, WORKFLOW_ENTRY_TYPE } from "./workflow.js";
43
44
  import { completionGate, pauseGate, schedulerWaitUntil } from "./workflow-gates.js";
@@ -301,6 +302,7 @@ function formatSnapshot(snapshot: ActivitySnapshot): string {
301
302
  }
302
303
 
303
304
  export default function (pi: ExtensionAPI): void {
305
+ initializeNavigatorPanels(pi);
304
306
  const providers = new Map<string, BackgroundActivityProvider>();
305
307
  const providerUnsubscribers = new Map<string, () => void>();
306
308
  providers.set("subagents", {
@@ -793,7 +795,8 @@ export default function (pi: ExtensionAPI): void {
793
795
  if (!ctx.hasUI) {
794
796
  return;
795
797
  }
796
- ctx.ui.setWidget(
798
+ setNavigatorPanel(
799
+ ctx,
797
800
  EXTENSION_NAME,
798
801
  (tui, theme) => {
799
802
  const inputIsEditor = (): boolean => {
@@ -1589,7 +1592,7 @@ export default function (pi: ExtensionAPI): void {
1589
1592
  latestSnapshot = null;
1590
1593
  collectionPending = false;
1591
1594
  try {
1592
- ctx.ui.setWidget(EXTENSION_NAME, undefined);
1595
+ setNavigatorPanel(ctx, EXTENSION_NAME, undefined);
1593
1596
  } catch {
1594
1597
  // Best-effort cleanup.
1595
1598
  }
@@ -0,0 +1,250 @@
1
+ // Generated from packages/navigator/panels.ts. Do not edit directly.
2
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
3
+ import type { Component, TUI } from "@earendil-works/pi-tui";
4
+ import { randomUUID } from "node:crypto";
5
+ import { readHarnessSetting, updateHarnessSetting } from "./shared-harness-settings.js";
6
+
7
+ export const NAVIGATOR_PANEL_WIDGET_KEY = "background-work-list";
8
+ export const NAVIGATOR_ENTRY_TYPE = "pi-better-harness-navigator-panels";
9
+ export const NAVIGATOR_STICKY_SETTING_ID = "navigator-sticky";
10
+ type MouseEvent = { type: string; button: string; x: number; y: number; width: number; shift?: boolean; alt?: boolean; ctrl?: boolean };
11
+ type PanelComponent = Component & { dispose?(): void; handleMouse?(event: MouseEvent): { handled: true; render: boolean } | undefined };
12
+ export type NavigatorPanelFactory = (tui: TUI, theme: Theme) => PanelComponent;
13
+ type Panel = { factory: NavigatorPanelFactory; component?: PanelComponent | undefined };
14
+ type Preference = { version: 1; sticky: boolean };
15
+ type Owner = { pi: ExtensionAPI; stop?: (() => void) | undefined };
16
+ type State = {
17
+ panels: Map<string, Panel>;
18
+ owners: Map<ExtensionAPI, Owner>;
19
+ ctx?: ExtensionContext | undefined;
20
+ ui?: ExtensionContext["ui"] | undefined;
21
+ pi?: ExtensionAPI | undefined;
22
+ sticky: boolean;
23
+ mounted: boolean;
24
+ tui?: TUI | undefined;
25
+ theme?: Theme | undefined;
26
+ anchor?: string | undefined;
27
+ pendingAnchor: boolean;
28
+ generation: number;
29
+ ranges: Array<{ start: number; end: number; component: PanelComponent }>;
30
+ control?: { id: string; label: string; values: string[]; get(): string; change(value: string, ctx: ExtensionContext): void } | undefined;
31
+ };
32
+ const KEY = Symbol.for("pi-better-harness.navigator-panels.state");
33
+ function state(): State {
34
+ const global = globalThis as typeof globalThis & { [KEY]?: State };
35
+ return global[KEY] ??= { panels: new Map(), owners: new Map(), sticky: true, mounted: false, pendingAnchor: false, generation: 0, ranges: [] };
36
+ }
37
+ function parsePreference(value: unknown): Preference {
38
+ if (value === undefined) return { version: 1, sticky: true };
39
+ const preference = value as Partial<Preference> | null;
40
+ if (!preference || preference.version !== 1 || typeof preference.sticky !== "boolean") {
41
+ throw new Error("Navigator preference must contain version 1 and a boolean sticky value.");
42
+ }
43
+ return { version: 1, sticky: preference.sticky };
44
+ }
45
+ function canScroll(pi: ExtensionAPI | undefined): boolean {
46
+ return typeof pi?.registerEntryRenderer === "function" && typeof pi?.appendEntry === "function";
47
+ }
48
+ function isTui(ctx: ExtensionContext | undefined): ctx is ExtensionContext {
49
+ return ctx?.mode === "tui" && ctx.hasUI && typeof ctx.ui?.setWidget === "function";
50
+ }
51
+ function disposePanels(s: State): void {
52
+ for (const panel of s.panels.values()) {
53
+ try { panel.component?.dispose?.(); } catch { /* stale UI cleanup is best effort */ }
54
+ panel.component = undefined;
55
+ }
56
+ s.ranges = [];
57
+ }
58
+ function orderedPanels(s: State): Panel[] {
59
+ const rank = (key: string) => key === "pi-better-goal" ? 0 : key === "pi-better-plan" ? 1 : 2;
60
+ return [...s.panels.entries()].sort(([a], [b]) => rank(a) - rank(b)).map(([, panel]) => panel);
61
+ }
62
+ function buildPanels(s: State): void {
63
+ if (!s.tui || !s.theme) return;
64
+ for (const panel of orderedPanels(s)) panel.component ??= panel.factory(s.tui, s.theme);
65
+ }
66
+ function renderPanels(s: State, width: number): string[] {
67
+ buildPanels(s);
68
+ const lines: string[] = [];
69
+ s.ranges = [];
70
+ for (const panel of orderedPanels(s)) {
71
+ if (!panel.component) continue;
72
+ const rendered = panel.component.render(width);
73
+ s.ranges.push({ start: lines.length, end: lines.length + rendered.length, component: panel.component });
74
+ lines.push(...rendered);
75
+ }
76
+ return lines;
77
+ }
78
+ function mouse(s: State, event: MouseEvent) {
79
+ const range = s.ranges.find(range => event.y >= range.start && event.y < range.end);
80
+ return range?.component.handleMouse?.({ ...event, y: event.y - range.start });
81
+ }
82
+ function restoreAnchor(ctx: ExtensionContext): string | undefined {
83
+ let anchor: string | undefined;
84
+ for (const entry of ctx.sessionManager?.getBranch?.() ?? []) {
85
+ if (entry.type !== "custom" || entry.customType !== NAVIGATOR_ENTRY_TYPE) continue;
86
+ const data = entry.data as { version?: unknown; anchor?: unknown } | undefined;
87
+ if (data?.version === 1 && typeof data.anchor === "string") anchor = data.anchor;
88
+ }
89
+ return anchor;
90
+ }
91
+ function appendAnchor(s: State): void {
92
+ if (s.sticky || s.anchor || !canScroll(s.pi)) return;
93
+ const anchor = randomUUID();
94
+ s.anchor = anchor;
95
+ try { s.pi!.appendEntry(NAVIGATOR_ENTRY_TYPE, { version: 1, anchor }); }
96
+ catch (error) { s.anchor = undefined; throw error; }
97
+ }
98
+ function scheduleAnchor(s: State): void {
99
+ if (s.anchor || s.pendingAnchor || s.sticky) return;
100
+ s.pendingAnchor = true;
101
+ const generation = s.generation;
102
+ // Session writes cannot run inside a component render. Empty sessions stay untouched.
103
+ queueMicrotask(() => {
104
+ if (generation !== s.generation) return;
105
+ s.pendingAnchor = false;
106
+ if (!s.sticky && s.panels.size) appendAnchor(s);
107
+ });
108
+ }
109
+ function clearHost(s: State): void {
110
+ s.generation++;
111
+ s.pendingAnchor = false;
112
+ try {
113
+ if (s.mounted && isTui(s.ctx)) s.ctx.ui.setWidget(NAVIGATOR_PANEL_WIDGET_KEY, undefined);
114
+ } catch { /* an old session context may already be detached */ }
115
+ disposePanels(s);
116
+ s.mounted = false;
117
+ s.tui = undefined;
118
+ s.theme = undefined;
119
+ }
120
+ function adoptContext(s: State, ctx: ExtensionContext): void {
121
+ if (s.ui && s.ui !== ctx.ui) {
122
+ clearHost(s);
123
+ s.panels.clear();
124
+ s.anchor = undefined;
125
+ }
126
+ s.ctx = ctx;
127
+ s.ui = ctx.ui;
128
+ }
129
+ function mountHost(s: State): void {
130
+ if (!isTui(s.ctx) || s.mounted || !s.panels.size) return;
131
+ s.mounted = true;
132
+ s.ctx.ui.setWidget(NAVIGATOR_PANEL_WIDGET_KEY, (tui, theme) => {
133
+ if (s.tui !== tui || s.theme !== theme) disposePanels(s);
134
+ s.tui = tui;
135
+ s.theme = theme;
136
+ buildPanels(s);
137
+ return {
138
+ render(width: number) {
139
+ const lines = renderPanels(s, width);
140
+ if (s.sticky) return lines;
141
+ if (lines.length) scheduleAnchor(s);
142
+ return [];
143
+ },
144
+ handleMouse: (event: MouseEvent) => s.sticky ? mouse(s, event) : undefined,
145
+ invalidate() {
146
+ for (const panel of s.panels.values()) panel.component?.invalidate();
147
+ },
148
+ dispose() { disposePanels(s); s.mounted = false; },
149
+ };
150
+ }, { placement: "aboveEditor" });
151
+ }
152
+ function changeSticky(value: string, ctx: ExtensionContext): void {
153
+ if (value !== "On" && value !== "Off") throw new Error("Sticky navigator expects On or Off.");
154
+ const s = state();
155
+ if (value === "Off" && !canScroll(s.pi)) {
156
+ throw new Error("Scrolling navigator requires a Pi version with registerEntryRenderer support.");
157
+ }
158
+ updateHarnessSetting("navigator", current => {
159
+ parsePreference(current);
160
+ return { version: 1, sticky: value === "On" };
161
+ });
162
+ adoptContext(s, ctx);
163
+ if (s.sticky && value === "Off") s.anchor = undefined;
164
+ s.sticky = value === "On";
165
+ refreshNavigatorPanels(ctx);
166
+ }
167
+ export function initializeNavigatorPanels(pi: ExtensionAPI): void {
168
+ const s = state();
169
+ if (s.owners.has(pi)) return;
170
+ s.pi = pi;
171
+ try { s.sticky = parsePreference(readHarnessSetting("navigator")).sticky; }
172
+ catch { s.sticky = true; }
173
+ if (!canScroll(pi)) s.sticky = true;
174
+ if (canScroll(pi)) pi.registerEntryRenderer(NAVIGATOR_ENTRY_TYPE, (entry) => {
175
+ const data = entry.data as { version?: number; anchor?: string } | undefined;
176
+ if (data?.version !== 1 || !data.anchor) return undefined;
177
+ // Pi reconstructs chat before session_start restores the visible branch.
178
+ // Keep the component mounted and resolve its active anchor at render time.
179
+ return {
180
+ render: width => !s.sticky && data.anchor === s.anchor ? renderPanels(s, width) : [],
181
+ handleMouse: event => !s.sticky && data.anchor === s.anchor ? mouse(s, event) : undefined,
182
+ invalidate() { for (const panel of s.panels.values()) panel.component?.invalidate(); },
183
+ } as PanelComponent;
184
+ });
185
+ s.control ??= {
186
+ id: NAVIGATOR_STICKY_SETTING_ID, label: "Sticky navigator", values: ["On", "Off"],
187
+ get: () => state().sticky ? "On" : "Off", change: changeSticky,
188
+ };
189
+ const owner: Owner = { pi };
190
+ s.owners.set(pi, owner);
191
+ // Use one stable contribution callback across vendored copies, but each owner
192
+ // cleans up its own event subscription when its extension is unloaded.
193
+ const register = () => pi.events?.emit?.("harness-settings:register", s.control);
194
+ owner.stop = pi.events?.on?.("harness-settings:request", register);
195
+ register();
196
+ pi.on("session_start", (_event, ctx) => {
197
+ adoptContext(s, ctx);
198
+ s.anchor = restoreAnchor(ctx);
199
+ });
200
+ pi.on("session_tree", (_event, ctx) => {
201
+ adoptContext(s, ctx);
202
+ s.anchor = restoreAnchor(ctx);
203
+ refreshNavigatorPanels(ctx);
204
+ });
205
+ pi.on("session_shutdown", () => {
206
+ owner.stop?.();
207
+ s.owners.delete(pi);
208
+ if (s.owners.size) return;
209
+ clearHost(s);
210
+ s.panels.clear();
211
+ s.anchor = undefined;
212
+ s.ctx = undefined;
213
+ s.ui = undefined;
214
+ s.pi = undefined;
215
+ });
216
+ }
217
+ export function setNavigatorPanel(
218
+ ctx: ExtensionContext, key: string, content: NavigatorPanelFactory | undefined,
219
+ _options?: { placement?: "aboveEditor" | "belowEditor" },
220
+ ): void {
221
+ const s = state();
222
+ if (!isTui(ctx)) return;
223
+ adoptContext(s, ctx);
224
+ s.panels.get(key)?.component?.dispose?.();
225
+ if (content) s.panels.set(key, { factory: content });
226
+ else s.panels.delete(key);
227
+ if (!s.panels.size) { clearHost(s); return; }
228
+ mountHost(s);
229
+ buildPanels(s);
230
+ s.tui?.requestRender?.();
231
+ }
232
+ export function refreshNavigatorPanels(ctx?: ExtensionContext): void {
233
+ const s = state();
234
+ if (ctx) adoptContext(s, ctx);
235
+ mountHost(s);
236
+ s.tui?.requestRender?.(true);
237
+ }
238
+ export function __resetNavigatorPanelsForTests(): void {
239
+ const s = state();
240
+ clearHost(s);
241
+ for (const owner of s.owners.values()) owner.stop?.();
242
+ s.owners.clear();
243
+ s.panels.clear();
244
+ s.pi = undefined;
245
+ s.ctx = undefined;
246
+ s.ui = undefined;
247
+ s.anchor = undefined;
248
+ s.sticky = true;
249
+ s.control = undefined;
250
+ }