@pi-unipi/info-screen 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,6 +26,7 @@ pi install npm:unipi
26
26
  - **Tabbed groups** — each module registers info groups with custom data providers
27
27
  - **Configurable** — per-group and per-stat visibility via settings
28
28
  - **Boot overlay** — shows dashboard on session start (configurable)
29
+ - **Styled dialog chrome** — uses pi-tui theme API for consistent borders, scrollable content, and navigation hints (matching the overlay style used by @pi-unipi/btw)
29
30
  - **Core groups** — modules, tools, load time, session info out of the box
30
31
 
31
32
  ## Registering a Group
package/index.ts CHANGED
@@ -1,20 +1,20 @@
1
1
  /**
2
2
  * @pi-unipi/info-screen — Extension entry
3
3
  *
4
- * Dashboard and module registry for Unipi.
5
- * Shows configurable info overlay on boot and via /unipi:info command.
4
+ * Cache-first reactive dashboard.
5
+ * Opens immediately with cached data, updates in background.
6
6
  *
7
7
  * Usage:
8
8
  * /unipi:info - Show info dashboard
9
9
  * /unipi:info-settings - Configure info display
10
10
  */
11
11
 
12
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
12
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
13
13
  import { UNIPI_EVENTS, MODULES, UNIPI_PREFIX, emitEvent, getPackageVersion } from "@pi-unipi/core";
14
14
  import { infoRegistry } from "./registry.js";
15
15
  import { registerCoreGroups, trackModule, trackTool, setPiApi, registerSkillDir, startLoadTracking, recordLoadTime, finishLoadTracking, recordModuleStart } from "./core-groups.js";
16
16
 
17
- /** Re-export infoRegistry, registerSkillDir, and load tracking for external use */
17
+ /** Re-export for external use */
18
18
  export { infoRegistry, registerSkillDir, startLoadTracking, recordLoadTime, finishLoadTracking, recordModuleStart };
19
19
  import { getInfoSettings } from "./config.js";
20
20
  import { InfoOverlay } from "./tui/info-overlay.js";
@@ -23,71 +23,43 @@ import { SettingsOverlay } from "./settings/settings-tui.js";
23
23
  /** Package version */
24
24
  const VERSION = getPackageVersion(new URL(".", import.meta.url).pathname);
25
25
 
26
- /** Module ready tracking */
27
- let moduleReady = false;
28
- let moduleReadyResolve: (() => void) | null = null;
29
- const moduleReadyPromise = new Promise<void>((resolve) => {
30
- moduleReadyResolve = resolve;
31
- });
32
-
33
- /** Timeout for waiting for modules */
34
- const MODULE_WAIT_TIMEOUT_MS = 8000;
35
-
36
- /**
37
- * Wait for modules to announce, then return.
38
- */
39
- async function waitForModules(): Promise<void> {
40
- const settings = getInfoSettings();
41
- const timeoutMs = settings.bootTimeoutMs || MODULE_WAIT_TIMEOUT_MS;
42
-
43
- // Wait a bit for modules to announce
44
- // We wait for the full timeout to give all modules time to emit MODULE_READY
45
- await new Promise<void>((resolve) => setTimeout(resolve, timeoutMs));
46
- }
47
-
48
26
  export default function (pi: ExtensionAPI) {
49
- // Set pi API reference for tools access
50
27
  setPiApi(pi);
51
28
 
52
- // Register core groups on load
29
+ // Register core groups immediately (synchronous)
53
30
  registerCoreGroups();
54
31
 
55
-
56
-
57
32
  // Start load tracking
58
33
  startLoadTracking();
59
34
 
60
- // Listen for module announcements
35
+ // Listen for module announcements — track and trigger reactive updates
61
36
  pi.events.on(UNIPI_EVENTS.MODULE_READY, (event: any) => {
62
37
  if (event.name && event.name !== MODULES.INFO_SCREEN) {
63
- // Track the module
64
38
  trackModule(event.name, event.version || "unknown");
65
39
  recordLoadTime(event.name, "module", event.loadTimeMs);
66
40
 
67
- // Invalidate overview cache so next render shows updated modules
41
+ // Invalidate overview so next fetch picks up new module list
68
42
  infoRegistry.invalidateCache("overview");
69
43
 
70
- // Track tools from this module
44
+ // Trigger background refresh of overview — subscribers will re-render
45
+ infoRegistry.getGroupData("overview");
46
+
71
47
  if (event.tools && Array.isArray(event.tools)) {
72
48
  for (const tool of event.tools) {
73
49
  trackTool(tool, event.name);
74
50
  }
75
- }
76
-
77
- // Signal that a module has announced
78
- if (!moduleReady) {
79
- moduleReady = true;
80
- moduleReadyResolve?.();
51
+ // Refresh tools group too
52
+ infoRegistry.invalidateCache("tools");
53
+ infoRegistry.getGroupData("tools");
81
54
  }
82
55
  }
83
56
  });
84
57
 
85
- // Listen for info group registrations via events
86
58
  pi.events.on(UNIPI_EVENTS.INFO_GROUP_REGISTERED, (_event: any) => {
87
59
  // Group already registered via globalThis in registerGroup()
88
60
  });
89
61
 
90
- // Also track built-in tools by intercepting tool calls
62
+ // Track built-in tools
91
63
  const trackedBuiltinTools = new Set<string>();
92
64
  pi.on("tool_call", async (event, _ctx) => {
93
65
  const toolName = event.toolName;
@@ -95,50 +67,56 @@ export default function (pi: ExtensionAPI) {
95
67
  trackedBuiltinTools.add(toolName);
96
68
  trackTool(toolName, "builtin");
97
69
  }
98
- return undefined; // Don't block the tool call
70
+ return undefined;
99
71
  });
100
72
 
101
- // Session lifecycle
73
+ /**
74
+ * Show the info overlay immediately.
75
+ * Cache-first: opens with whatever data is cached (even empty).
76
+ * Background: each group fetches independently, overlay re-renders reactively.
77
+ */
78
+ function showOverlay(ctx: any): void {
79
+ ctx.ui.custom(
80
+ (tui: any, theme: any, _keybindings: any, done: () => void) => {
81
+ const overlay = new InfoOverlay();
82
+ overlay.setTheme(theme);
83
+ overlay.onClose = () => {
84
+ overlay.destroy();
85
+ done();
86
+ };
87
+ overlay.requestRender = () => tui.requestRender();
88
+ return {
89
+ render: (w: number) => overlay.render(w),
90
+ invalidate: () => overlay.invalidate(),
91
+ handleInput: (data: string) => {
92
+ overlay.handleInput(data);
93
+ tui.requestRender();
94
+ },
95
+ };
96
+ },
97
+ {
98
+ overlay: true,
99
+ overlayOptions: {
100
+ width: "80%",
101
+ minWidth: 60,
102
+ anchor: "center",
103
+ margin: 2,
104
+ },
105
+ }
106
+ );
107
+ }
108
+
109
+ // Session lifecycle — show immediately on boot, no blocking wait
102
110
  pi.on("session_start", async (event, ctx) => {
103
111
  const settings = getInfoSettings();
104
112
 
105
- // Show dashboard only on initial startup, not on /new
106
113
  if (settings.showOnBoot && event.reason === "startup") {
107
- // Wait for other modules to announce
108
- await waitForModules();
109
-
110
- // Show the overlay using three-method object pattern
111
- ctx.ui.custom(
112
- (tui, _theme, _keybindings, done) => {
113
- const overlay = new InfoOverlay();
114
- overlay.onClose = () => done(undefined);
115
- overlay.requestRender = () => tui.requestRender();
116
- // Return three-method object as per pi-tui docs
117
- return {
118
- render: (w: number) => overlay.render(w),
119
- invalidate: () => overlay.invalidate(),
120
- handleInput: (data: string) => {
121
- overlay.handleInput?.(data);
122
- tui.requestRender();
123
- },
124
- };
125
- },
126
- {
127
- overlay: true,
128
- overlayOptions: {
129
- width: "80%",
130
- minWidth: 60,
131
- anchor: "center",
132
- margin: 2,
133
- },
134
- }
135
- );
114
+ // Open immediately cache-first, no waiting
115
+ showOverlay(ctx);
136
116
  }
137
117
 
138
- // Finish load tracking
139
118
  finishLoadTracking();
140
119
 
141
- // Announce module
142
120
  emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
143
121
  name: MODULES.INFO_SCREEN,
144
122
  version: VERSION,
@@ -147,43 +125,20 @@ export default function (pi: ExtensionAPI) {
147
125
  });
148
126
  });
149
127
 
150
- // Register /unipi:info command
128
+ // /unipi:info — open immediately
151
129
  pi.registerCommand(`${UNIPI_PREFIX}info`, {
152
130
  description: "Show info screen dashboard",
153
131
  handler: async (_args, ctx) => {
154
- ctx.ui.custom(
155
- (tui, _theme, _keybindings, done) => {
156
- const overlay = new InfoOverlay();
157
- overlay.onClose = () => done(undefined);
158
- overlay.requestRender = () => tui.requestRender();
159
- return {
160
- render: (w: number) => overlay.render(w),
161
- invalidate: () => overlay.invalidate(),
162
- handleInput: (data: string) => {
163
- overlay.handleInput?.(data);
164
- tui.requestRender();
165
- },
166
- };
167
- },
168
- {
169
- overlay: true,
170
- overlayOptions: {
171
- width: "80%",
172
- minWidth: 60,
173
- anchor: "center",
174
- margin: 2,
175
- },
176
- }
177
- );
132
+ showOverlay(ctx);
178
133
  },
179
134
  });
180
135
 
181
- // Register /unipi:info-settings command
136
+ // /unipi:info-settings
182
137
  pi.registerCommand(`${UNIPI_PREFIX}info-settings`, {
183
138
  description: "Configure info screen display",
184
139
  handler: async (_args, ctx) => {
185
140
  ctx.ui.custom(
186
- (tui, _theme, _keybindings, done) => {
141
+ (tui: any, _theme: any, _keybindings: any, done: any) => {
187
142
  const overlay = new SettingsOverlay();
188
143
  overlay.onClose = () => done(undefined);
189
144
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/info-screen",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Dashboard and module registry for Unipi — configurable info overlay with tabbed groups",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/registry.ts CHANGED
@@ -1,41 +1,16 @@
1
1
  /**
2
2
  * @pi-unipi/info-screen — Registry
3
3
  *
4
- * Central registry for info groups. Core groups register at startup.
5
- * External modules call registerGroup() to add their groups.
4
+ * Central registry for info groups with cache-first reactive model.
5
+ * Groups load independently; overlay subscribes to per-group updates.
6
6
  */
7
7
 
8
8
  import type { InfoGroup, GroupData } from "./types.js";
9
- import { getInfoSettings, isGroupEnabled, isStatEnabled } from "./config.js";
9
+ import { getInfoSettings, isStatEnabled } from "./config.js";
10
+
11
+ /** Callback for reactive updates */
12
+ type GroupUpdateCallback = (groupId: string, data: GroupData) => void;
10
13
 
11
- /**
12
- * Registry for info-screen groups.
13
- *
14
- * Usage:
15
- * ```ts
16
- * import { infoRegistry } from "@pi-unipi/info-screen";
17
- *
18
- * // Register a group
19
- * infoRegistry.registerGroup({
20
- * id: "memory",
21
- * name: "Memory",
22
- * icon: "🧠",
23
- * priority: 60,
24
- * config: {
25
- * showByDefault: true,
26
- * stats: [
27
- * { id: "total", label: "Total", show: true },
28
- * ],
29
- * },
30
- * dataProvider: async () => ({
31
- * total: { value: "42" },
32
- * }),
33
- * });
34
- *
35
- * // Get all registered groups
36
- * const groups = infoRegistry.getGroups();
37
- * ```
38
- */
39
14
  class InfoRegistry {
40
15
  /** Registered groups by id */
41
16
  private groups = new Map<string, InfoGroup>();
@@ -43,18 +18,29 @@ class InfoRegistry {
43
18
  /** Cached data per group */
44
19
  private dataCache = new Map<string, GroupData>();
45
20
 
21
+ /** Last successful fetch timestamp per group */
22
+ private lastUpdated = new Map<string, number>();
23
+
46
24
  /** Cache TTL in ms */
47
25
  private cacheTtlMs = 5000;
48
26
 
49
- /** Last cache update per group */
50
- private cacheTimestamps = new Map<string, number>();
27
+ /** Subscribers per group */
28
+ private subscribers = new Map<string, Set<GroupUpdateCallback>>();
29
+
30
+ /** Global subscribers (any group update) */
31
+ private globalSubscribers = new Set<GroupUpdateCallback>();
32
+
33
+ /** In-flight fetches per group */
34
+ private inflight = new Map<string, Promise<GroupData>>();
51
35
 
52
36
  /**
53
37
  * Register an info group.
54
- * If a group with the same id exists, it's replaced.
38
+ * Notifies subscribers so overlays can pick up late-arriving groups.
55
39
  */
56
40
  registerGroup(group: InfoGroup): void {
57
41
  this.groups.set(group.id, group);
42
+ // Notify that a new group appeared (triggers overlay sync)
43
+ this.notifyGroupRegistered(group.id);
58
44
  }
59
45
 
60
46
  /**
@@ -63,12 +49,12 @@ class InfoRegistry {
63
49
  unregisterGroup(groupId: string): void {
64
50
  this.groups.delete(groupId);
65
51
  this.dataCache.delete(groupId);
66
- this.cacheTimestamps.delete(groupId);
52
+ this.lastUpdated.delete(groupId);
53
+ this.subscribers.delete(groupId);
67
54
  }
68
55
 
69
56
  /**
70
57
  * Get all registered groups, sorted by priority.
71
- * Respects config — groups with show: false are excluded.
72
58
  */
73
59
  getGroups(): InfoGroup[] {
74
60
  const settings = getInfoSettings();
@@ -76,10 +62,8 @@ class InfoRegistry {
76
62
 
77
63
  return allGroups
78
64
  .filter((group) => {
79
- // Check group-level visibility
80
65
  const groupSettings = settings.groups[group.id];
81
66
  if (groupSettings && !groupSettings.show) return false;
82
- // If no settings, use group's default
83
67
  if (!groupSettings && !group.config.showByDefault) return false;
84
68
  return true;
85
69
  })
@@ -101,8 +85,32 @@ class InfoRegistry {
101
85
  return this.groups.get(groupId);
102
86
  }
103
87
 
88
+ /**
89
+ * Synchronous: get cached data for immediate display.
90
+ * Returns null if never fetched.
91
+ */
92
+ getCachedData(groupId: string): GroupData | null {
93
+ return this.dataCache.get(groupId) ?? null;
94
+ }
95
+
96
+ /**
97
+ * Synchronous: get last updated timestamp for a group.
98
+ */
99
+ getLastUpdated(groupId: string): number {
100
+ return this.lastUpdated.get(groupId) ?? 0;
101
+ }
102
+
103
+ /**
104
+ * Synchronous: check if a group is currently fetching.
105
+ */
106
+ isFetching(groupId: string): boolean {
107
+ return this.inflight.has(groupId);
108
+ }
109
+
104
110
  /**
105
111
  * Get data for a group, using cache if fresh.
112
+ * Returns immediately from cache if fresh, otherwise fetches in background
113
+ * and notifies subscribers when done.
106
114
  */
107
115
  async getGroupData(groupId: string): Promise<GroupData> {
108
116
  const group = this.groups.get(groupId);
@@ -110,44 +118,115 @@ class InfoRegistry {
110
118
 
111
119
  // Check cache freshness
112
120
  const now = Date.now();
113
- const lastUpdate = this.cacheTimestamps.get(groupId) ?? 0;
121
+ const lastUpdate = this.lastUpdated.get(groupId) ?? 0;
114
122
  if (now - lastUpdate < this.cacheTtlMs) {
115
123
  const cached = this.dataCache.get(groupId);
116
124
  if (cached) return cached;
117
125
  }
118
126
 
127
+ // Deduplicate in-flight requests
128
+ const existing = this.inflight.get(groupId);
129
+ if (existing) return existing;
130
+
119
131
  // Fetch fresh data
132
+ const fetchPromise = this.fetchGroupData(groupId, group);
133
+ this.inflight.set(groupId, fetchPromise);
134
+
135
+ try {
136
+ const data = await fetchPromise;
137
+ return data;
138
+ } finally {
139
+ this.inflight.delete(groupId);
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Background fetch: update cache and notify subscribers.
145
+ * Does not throw — falls back to cached data.
146
+ */
147
+ private async fetchGroupData(groupId: string, group: InfoGroup): Promise<GroupData> {
120
148
  try {
121
149
  const data = await group.dataProvider();
122
150
  this.dataCache.set(groupId, data);
123
- this.cacheTimestamps.set(groupId, now);
151
+ this.lastUpdated.set(groupId, Date.now());
152
+ this.notifySubscribers(groupId, data);
124
153
  return data;
125
- } catch (error) {
126
- // Silently fall back to cached data
154
+ } catch {
127
155
  return this.dataCache.get(groupId) ?? {};
128
156
  }
129
157
  }
130
158
 
131
159
  /**
132
- * Manually update data for a group (for live updates).
160
+ * Trigger a background refresh for a group.
161
+ * Returns cached data immediately if available.
162
+ */
163
+ refreshGroup(groupId: string): GroupData | null {
164
+ const cached = this.dataCache.get(groupId) ?? null;
165
+ // Fire and forget
166
+ this.getGroupData(groupId);
167
+ return cached;
168
+ }
169
+
170
+ /**
171
+ * Refresh all groups in background.
172
+ */
173
+ refreshAll(): void {
174
+ for (const [id, group] of this.groups) {
175
+ this.fetchGroupData(id, group);
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Subscribe to updates for a specific group.
181
+ * Returns unsubscribe function.
133
182
  */
134
- updateGroupData(groupId: string, data: GroupData): void {
135
- this.dataCache.set(groupId, data);
136
- this.cacheTimestamps.set(groupId, Date.now());
183
+ subscribe(groupId: string, callback: GroupUpdateCallback): () => void {
184
+ if (!this.subscribers.has(groupId)) {
185
+ this.subscribers.set(groupId, new Set());
186
+ }
187
+ this.subscribers.get(groupId)!.add(callback);
188
+
189
+ return () => {
190
+ this.subscribers.get(groupId)?.delete(callback);
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Subscribe to all group updates.
196
+ * Returns unsubscribe function.
197
+ */
198
+ subscribeAll(callback: GroupUpdateCallback): () => void {
199
+ this.globalSubscribers.add(callback);
200
+ return () => {
201
+ this.globalSubscribers.delete(callback);
202
+ };
203
+ }
204
+
205
+ private notifySubscribers(groupId: string, data: GroupData): void {
206
+ // Per-group subscribers
207
+ const groupSubs = this.subscribers.get(groupId);
208
+ if (groupSubs) {
209
+ for (const cb of groupSubs) {
210
+ try { cb(groupId, data); } catch { /* ignore */ }
211
+ }
212
+ }
213
+
214
+ // Global subscribers
215
+ for (const cb of this.globalSubscribers) {
216
+ try { cb(groupId, data); } catch { /* ignore */ }
217
+ }
137
218
  }
138
219
 
139
220
  /**
140
221
  * Get filtered stats for a group based on config.
141
- * Returns stats that are enabled in both group config and settings.
142
222
  */
143
223
  getVisibleStats(groupId: string): Array<{ id: string; label: string }> {
144
224
  const group = this.groups.get(groupId);
145
225
  if (!group) return [];
146
226
 
227
+ if (!group.config?.stats) return [];
147
228
  return group.config.stats.filter((stat) => {
148
- // Check stat-level visibility from settings
149
229
  if (!isStatEnabled(groupId, stat.id)) return false;
150
- // Check stat's own default
151
230
  return stat.show;
152
231
  });
153
232
  }
@@ -157,7 +236,7 @@ class InfoRegistry {
157
236
  */
158
237
  invalidateCache(groupId: string): void {
159
238
  this.dataCache.delete(groupId);
160
- this.cacheTimestamps.delete(groupId);
239
+ this.lastUpdated.delete(groupId);
161
240
  }
162
241
 
163
242
  /**
@@ -165,7 +244,18 @@ class InfoRegistry {
165
244
  */
166
245
  invalidateAllCaches(): void {
167
246
  this.dataCache.clear();
168
- this.cacheTimestamps.clear();
247
+ this.lastUpdated.clear();
248
+ }
249
+
250
+ /**
251
+ * Notify that a new group was registered.
252
+ * Subscribers can use this to sync group lists.
253
+ */
254
+ private notifyGroupRegistered(groupId: string): void {
255
+ // Fire a dummy update so overlays re-sync their group list
256
+ for (const cb of this.globalSubscribers) {
257
+ try { cb(groupId, {} as GroupData); } catch { /* ignore */ }
258
+ }
169
259
  }
170
260
  }
171
261
 
@@ -173,7 +263,6 @@ class InfoRegistry {
173
263
  export const infoRegistry = new InfoRegistry();
174
264
 
175
265
  // Expose globally so other modules can access without direct imports
176
- // (pi loads extensions independently, so imports may not resolve)
177
266
  const globalObj = globalThis as any;
178
267
  if (!globalObj.__unipi_info_registry) {
179
268
  globalObj.__unipi_info_registry = infoRegistry;