@pi-unipi/info-screen 2.6.1 → 2.9.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/config.ts CHANGED
@@ -164,15 +164,6 @@ export function setGroupSettings(groupId: string, groupSettings: GroupSettings):
164
164
  saveInfoSettings(settings);
165
165
  }
166
166
 
167
- /**
168
- * Check if a group is enabled.
169
- */
170
- export function isGroupEnabled(groupId: string): boolean {
171
- const settings = getInfoSettings();
172
- if (!(groupId in settings.groups)) return true; // Default to enabled
173
- return settings.groups[groupId].show;
174
- }
175
-
176
167
  /**
177
168
  * Check if a stat within a group is enabled.
178
169
  */
@@ -182,10 +173,3 @@ export function isStatEnabled(groupId: string, statId: string): boolean {
182
173
  if (!(statId in groupSettings.stats)) return true;
183
174
  return groupSettings.stats[statId];
184
175
  }
185
-
186
- /**
187
- * Clear cached settings (for testing or reload).
188
- */
189
- export function clearSettingsCache(): void {
190
- cachedSettings = null;
191
- }
package/core-groups.ts CHANGED
@@ -141,11 +141,6 @@ export function startLoadTracking(): void {
141
141
  }
142
142
  }
143
143
 
144
- /** Record when a module starts loading */
145
- export function recordModuleStart(name: string): void {
146
- moduleStartTimes.set(name, Date.now());
147
- }
148
-
149
144
  /** Record a load time */
150
145
  export function recordLoadTime(name: string, type: string, ms?: number): void {
151
146
  // If no ms provided, calculate from start time
@@ -172,11 +167,6 @@ export function finishLoadTracking(): void {
172
167
  }
173
168
  }
174
169
 
175
- /** Get load times */
176
- export function getLoadTimes(): Array<{ name: string; type: string; ms: number }> {
177
- return [...loadTimes];
178
- }
179
-
180
170
  /** Get total load time */
181
171
  export function getTotalLoadTime(): number {
182
172
  return totalLoadTimeMs > 0 ? totalLoadTimeMs : (loadTrackingStarted ? Date.now() - loadTrackingStartMs : 0);
package/index.ts CHANGED
@@ -12,12 +12,12 @@
12
12
  import { dirname } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
- import { UNIPI_EVENTS, MODULES, UNIPI_PREFIX, emitEvent, getPackageVersion, type UnipiModuleEvent, type UnipiInfoGroupEvent } from "@pi-unipi/core";
15
+ import { UNIPI_EVENTS, MODULES, UNIPI_PREFIX, emitEvent, getPackageVersion, type UnipiModuleEvent } from "@pi-unipi/core";
16
16
  import { infoRegistry } from "./registry.js";
17
- import { registerCoreGroups, trackModule, trackTool, setPiApi, registerSkillDir, startLoadTracking, recordLoadTime, finishLoadTracking, recordModuleStart } from "./core-groups.js";
17
+ import { registerCoreGroups, trackModule, trackTool, setPiApi, registerSkillDir, startLoadTracking, recordLoadTime, finishLoadTracking } from "./core-groups.js";
18
18
 
19
19
  /** Re-export for external use */
20
- export { infoRegistry, registerSkillDir, startLoadTracking, recordLoadTime, finishLoadTracking, recordModuleStart };
20
+ export { infoRegistry, registerSkillDir, startLoadTracking, recordLoadTime, finishLoadTracking };
21
21
  import { getInfoSettings } from "./config.js";
22
22
  import { InfoOverlay } from "./tui/info-overlay.js";
23
23
  import { SettingsOverlay } from "./settings/settings-tui.js";
@@ -95,10 +95,6 @@ export default function (pi: ExtensionAPI) {
95
95
  }
96
96
  });
97
97
 
98
- pi.events.on(UNIPI_EVENTS.INFO_GROUP_REGISTERED, (_data) => {
99
- // Group already registered via globalThis in registerGroup()
100
- });
101
-
102
98
  // Track built-in tools
103
99
  const trackedBuiltinTools = new Set<string>();
104
100
  pi.on("tool_call", async (event, _ctx) => {
@@ -116,9 +112,10 @@ export default function (pi: ExtensionAPI) {
116
112
  * Background: each group fetches independently, overlay re-renders reactively.
117
113
  */
118
114
  function showOverlay(ctx: ExtensionContext, autoCloseMs?: number): void {
115
+ let overlay: InfoOverlay;
119
116
  ctx.ui.custom<void>(
120
117
  (tui, theme, _keybindings, done) => {
121
- const overlay = new InfoOverlay();
118
+ overlay = new InfoOverlay();
122
119
  overlay.setTheme(theme);
123
120
  overlayVisible = true;
124
121
  overlay.onClose = () => {
@@ -127,11 +124,7 @@ export default function (pi: ExtensionAPI) {
127
124
  done();
128
125
  };
129
126
  overlay.requestRender = () => tui.requestRender();
130
- // Boot dashboard dismisses itself; any keypress cancels the timer.
131
- if (autoCloseMs && autoCloseMs > 0) {
132
- overlay.startBootTimer(autoCloseMs);
133
- }
134
- return {
127
+ const component = {
135
128
  render: (w: number) => overlay.render(w),
136
129
  invalidate: () => overlay.invalidate(),
137
130
  handleInput: (data: string) => {
@@ -139,6 +132,11 @@ export default function (pi: ExtensionAPI) {
139
132
  tui.requestRender();
140
133
  },
141
134
  };
135
+ // Boot dashboard dismisses itself; any keypress cancels the timer.
136
+ if (autoCloseMs && autoCloseMs > 0) {
137
+ overlay.startBootTimer(autoCloseMs);
138
+ }
139
+ return component;
142
140
  },
143
141
  {
144
142
  overlay: true,
@@ -148,6 +146,17 @@ export default function (pi: ExtensionAPI) {
148
146
  anchor: "center" as const,
149
147
  margin: 2,
150
148
  },
149
+ // `done()` (the extension UI's close callback) pops the *topmost* overlay
150
+ // in the TUI stack, not this one specifically. When another overlay (e.g.
151
+ // the updater's "Update Available" prompt) is stacked on top, the boot
152
+ // auto-close timer must not fire `done()` — that would pop the covering
153
+ // overlay and strand this dashboard with a spent one-shot close the user
154
+ // can no longer dismiss. `isTopmostOverlay` lets the boot timer defer
155
+ // until we are the focused (topmost) entry; the user can still press
156
+ // q/Esc to close once the covering overlay is gone.
157
+ onHandle: (handle) => {
158
+ overlay.isTopmostOverlay = () => handle.isFocused();
159
+ },
151
160
  }
152
161
  );
153
162
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pi-unipi/info-screen",
3
- "version": "2.6.1",
4
- "description": "Dashboard and module registry for Unipi configurable info overlay with tabbed groups",
3
+ "version": "2.9.0",
4
+ "description": "Dashboard and module registry for Unipi \u2014 configurable info overlay with tabbed groups",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
7
  "license": "MIT",
@@ -33,18 +33,20 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "2.6.1"
36
+ "@pi-unipi/core": "2.9.0"
37
37
  },
38
38
  "peerDependencies": {
39
- "@earendil-works/pi-coding-agent": "^0.80.0",
40
- "@earendil-works/pi-tui": "^0.80.0",
39
+ "@earendil-works/pi-coding-agent": "^0.84.0",
40
+ "@earendil-works/pi-tui": "^0.84.0",
41
41
  "typebox": "^1.1.38"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/node": "^25.6.0"
45
45
  },
46
46
  "pi": {
47
- "extensions": [],
47
+ "extensions": [
48
+ "./index.ts"
49
+ ],
48
50
  "skills": [],
49
51
  "prompts": [],
50
52
  "themes": []
package/registry.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { InfoGroup, GroupData } from "./types.js";
9
- import { getInfoSettings, isStatEnabled } from "./config.js";
9
+ import { isStatEnabled } from "./config.js";
10
10
 
11
11
  /** Callback for reactive updates */
12
12
  type GroupUpdateCallback = (groupId: string, data: GroupData) => void;
@@ -24,9 +24,6 @@ class InfoRegistry {
24
24
  /** Cache TTL in ms */
25
25
  private cacheTtlMs = 5000;
26
26
 
27
- /** Subscribers per group */
28
- private subscribers = new Map<string, Set<GroupUpdateCallback>>();
29
-
30
27
  /** Global subscribers (any group update) */
31
28
  private globalSubscribers = new Set<GroupUpdateCallback>();
32
29
 
@@ -43,33 +40,6 @@ class InfoRegistry {
43
40
  this.notifyGroupRegistered(group.id);
44
41
  }
45
42
 
46
- /**
47
- * Unregister an info group.
48
- */
49
- unregisterGroup(groupId: string): void {
50
- this.groups.delete(groupId);
51
- this.dataCache.delete(groupId);
52
- this.lastUpdated.delete(groupId);
53
- this.subscribers.delete(groupId);
54
- }
55
-
56
- /**
57
- * Get all registered groups, sorted by priority.
58
- */
59
- getGroups(): InfoGroup[] {
60
- const settings = getInfoSettings();
61
- const allGroups = Array.from(this.groups.values());
62
-
63
- return allGroups
64
- .filter((group) => {
65
- const groupSettings = settings.groups[group.id];
66
- if (groupSettings && !groupSettings.show) return false;
67
- if (!groupSettings && !group.config.showByDefault) return false;
68
- return true;
69
- })
70
- .sort((a, b) => a.priority - b.priority);
71
- }
72
-
73
43
  /**
74
44
  * Get all registered groups (including hidden ones).
75
45
  */
@@ -100,13 +70,6 @@ class InfoRegistry {
100
70
  return this.lastUpdated.get(groupId) ?? 0;
101
71
  }
102
72
 
103
- /**
104
- * Synchronous: check if a group is currently fetching.
105
- */
106
- isFetching(groupId: string): boolean {
107
- return this.inflight.has(groupId);
108
- }
109
-
110
73
  /**
111
74
  * Get data for a group, using cache if fresh.
112
75
  * Returns immediately from cache if fresh, otherwise fetches in background
@@ -176,21 +139,6 @@ class InfoRegistry {
176
139
  }
177
140
  }
178
141
 
179
- /**
180
- * Subscribe to updates for a specific group.
181
- * Returns unsubscribe function.
182
- */
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
142
  /**
195
143
  * Subscribe to all group updates.
196
144
  * Returns unsubscribe function.
@@ -203,14 +151,6 @@ class InfoRegistry {
203
151
  }
204
152
 
205
153
  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
154
  // Global subscribers
215
155
  for (const cb of this.globalSubscribers) {
216
156
  try { cb(groupId, data); } catch { /* ignore */ }
@@ -239,14 +179,6 @@ class InfoRegistry {
239
179
  this.lastUpdated.delete(groupId);
240
180
  }
241
181
 
242
- /**
243
- * Invalidate all caches.
244
- */
245
- invalidateAllCaches(): void {
246
- this.dataCache.clear();
247
- this.lastUpdated.clear();
248
- }
249
-
250
182
  /**
251
183
  * Notify that a new group was registered.
252
184
  * Subscribers can use this to sync group lists.
@@ -266,6 +198,3 @@ export const infoRegistry = new InfoRegistry();
266
198
  if (!globalThis.__unipi_info_registry) {
267
199
  globalThis.__unipi_info_registry = infoRegistry;
268
200
  }
269
- export const getGlobalRegistry = (): InfoRegistry => {
270
- return (globalThis.__unipi_info_registry as InfoRegistry | undefined) ?? infoRegistry;
271
- };
@@ -1,3 +1,4 @@
1
+ import { ansi, TOGGLE_ON, TOGGLE_OFF } from "@pi-unipi/core";
1
2
  /**
2
3
  * @pi-unipi/info-screen — Settings TUI Component
3
4
  *
@@ -6,28 +7,15 @@
6
7
  */
7
8
 
8
9
  import type { Component } from "@earendil-works/pi-tui";
9
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
+ import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
11
  import { infoRegistry } from "../registry.js";
11
12
  import { getInfoSettings, saveInfoSettings, getGroupSettings, setGroupSettings } from "../config.js";
12
13
  import type { InfoScreenSettings, GroupSettings, BootMode } from "../types.js";
13
14
  import { BOOT_MODES } from "../types.js";
14
15
 
15
16
  /** ANSI escape codes */
16
- const ansi = {
17
- reset: "\x1b[0m",
18
- bold: "\x1b[1m",
19
- dim: "\x1b[2m",
20
- // Colors
21
- cyan: "\x1b[36m",
22
- green: "\x1b[32m",
23
- yellow: "\x1b[33m",
24
- red: "\x1b[31m",
25
- gray: "\x1b[90m",
26
- };
27
17
 
28
18
  /** Toggle symbols */
29
- const TOGGLE_ON = `${ansi.green}●${ansi.reset}`;
30
- const TOGGLE_OFF = `${ansi.dim}○${ansi.reset}`;
31
19
 
32
20
  /** How each boot mode is presented in the settings list. */
33
21
  const BOOT_MODE_LABELS: Record<BootMode, string> = {
@@ -99,57 +87,42 @@ export class SettingsOverlay implements Component {
99
87
  const rowCount = this.groups.length + H; // header rows, then groups
100
88
  const onBootMode = this.selectedIndex === SettingsOverlay.BOOT_MODE_INDEX;
101
89
  const onBootTimeout = this.selectedIndex === SettingsOverlay.BOOT_TIMEOUT_INDEX;
102
- switch (data) {
103
- case "\x1b[A": // Up
104
- case "k":
105
- this.selectedIndex = (this.selectedIndex - 1 + rowCount) % rowCount;
106
- break;
107
- case "\x1b[B": // Down
108
- case "j":
109
- this.selectedIndex = (this.selectedIndex + 1) % rowCount;
110
- break;
111
- case " ": // Space - toggle / cycle
112
- if (onBootMode) {
113
- this.cycleBootMode();
114
- } else if (onBootTimeout) {
115
- this.adjustBootTimeout(500);
116
- } else {
117
- this.toggleGroupVisibility(this.groups[this.selectedIndex - H].id);
118
- }
119
- break;
120
- case "\r": // Enter - enter stats mode
121
- case "\x1b[C": // Right - enter stats mode
122
- case "l":
123
- if (onBootMode) {
124
- this.cycleBootMode();
125
- } else if (onBootTimeout) {
126
- this.adjustBootTimeout(500);
127
- } else {
128
- this.enterStatsMode(this.groups[this.selectedIndex - H].id);
129
- }
130
- break;
131
- case "\x1b[D": // Left - cycle back / decrease
132
- case "h":
133
- if (onBootMode) {
134
- this.cycleBootMode(-1);
135
- } else if (onBootTimeout) {
136
- this.adjustBootTimeout(-500);
137
- }
138
- break;
139
- case "J": // Shift+J - move group down
140
- if (this.selectedIndex >= H) {
141
- this.moveGroupDown();
142
- }
143
- break;
144
- case "K": // Shift+K - move group up
145
- if (this.selectedIndex >= H) {
146
- this.moveGroupUp();
147
- }
148
- break;
149
- case "q": // Quit
150
- case "\x1b": // Escape
151
- this.onClose?.();
152
- break;
90
+ if (matchesKey(data, Key.up) || data === "k") {
91
+ this.selectedIndex = (this.selectedIndex - 1 + rowCount) % rowCount;
92
+ } else if (matchesKey(data, Key.down) || data === "j") {
93
+ this.selectedIndex = (this.selectedIndex + 1) % rowCount;
94
+ } else if (data === " ") { // Space - toggle / cycle
95
+ if (onBootMode) {
96
+ this.cycleBootMode();
97
+ } else if (onBootTimeout) {
98
+ this.adjustBootTimeout(500);
99
+ } else {
100
+ this.toggleGroupVisibility(this.groups[this.selectedIndex - H].id);
101
+ }
102
+ } else if (data === "\r" || matchesKey(data, Key.right) || data === "l") { // Enter / Right - enter stats mode
103
+ if (onBootMode) {
104
+ this.cycleBootMode();
105
+ } else if (onBootTimeout) {
106
+ this.adjustBootTimeout(500);
107
+ } else {
108
+ this.enterStatsMode(this.groups[this.selectedIndex - H].id);
109
+ }
110
+ } else if (matchesKey(data, Key.left) || data === "h") { // Left - cycle back / decrease
111
+ if (onBootMode) {
112
+ this.cycleBootMode(-1);
113
+ } else if (onBootTimeout) {
114
+ this.adjustBootTimeout(-500);
115
+ }
116
+ } else if (data === "J") { // Shift+J - move group down
117
+ if (this.selectedIndex >= H) {
118
+ this.moveGroupDown();
119
+ }
120
+ } else if (data === "K") { // Shift+K - move group up
121
+ if (this.selectedIndex >= H) {
122
+ this.moveGroupUp();
123
+ }
124
+ } else if (data === "q" || matchesKey(data, Key.escape)) { // Quit
125
+ this.onClose?.();
153
126
  }
154
127
  }
155
128
 
@@ -162,27 +135,16 @@ export class SettingsOverlay implements Component {
162
135
  const group = infoRegistry.getGroup(this.selectedGroupId);
163
136
  if (!group) return;
164
137
 
165
- switch (data) {
166
- case "\x1b[A": // Up
167
- case "k":
168
- this.selectedIndex = (this.selectedIndex - 1 + group.config.stats.length) % group.config.stats.length;
169
- break;
170
- case "\x1b[B": // Down
171
- case "j":
172
- this.selectedIndex = (this.selectedIndex + 1) % group.config.stats.length;
173
- break;
174
- case " ": // Space - toggle stat
175
- this.toggleStatVisibility(this.selectedGroupId, group.config.stats[this.selectedIndex].id);
176
- break;
177
- case "\x1b[D": // Left - back to groups
178
- case "h":
179
- case "\r": // Enter - also go back
180
- this.backToGroups();
181
- break;
182
- case "q": // Quit from stats mode
183
- case "\x1b":
184
- this.onClose?.();
185
- break;
138
+ if (matchesKey(data, Key.up) || data === "k") {
139
+ this.selectedIndex = (this.selectedIndex - 1 + group.config.stats.length) % group.config.stats.length;
140
+ } else if (matchesKey(data, Key.down) || data === "j") {
141
+ this.selectedIndex = (this.selectedIndex + 1) % group.config.stats.length;
142
+ } else if (data === " ") { // Space - toggle stat
143
+ this.toggleStatVisibility(this.selectedGroupId, group.config.stats[this.selectedIndex].id);
144
+ } else if (matchesKey(data, Key.left) || data === "h" || data === "\r") { // Left/Enter - back to groups
145
+ this.backToGroups();
146
+ } else if (data === "q" || matchesKey(data, Key.escape)) { // Quit from stats mode
147
+ this.onClose?.();
186
148
  }
187
149
  }
188
150
 
@@ -8,12 +8,12 @@
8
8
  */
9
9
 
10
10
  import type { Component } from "@earendil-works/pi-tui";
11
- import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
11
+ import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
12
12
  import type { Theme } from "@earendil-works/pi-coding-agent";
13
13
  import { infoRegistry } from "../registry.js";
14
14
  import { getInfoSettings } from "../config.js";
15
15
  import type { InfoGroup, GroupData } from "../types.js";
16
- import { boxInnerWidth } from "@pi-unipi/core";
16
+ import { boxInnerWidth, OverlayTheme } from "@pi-unipi/core";
17
17
 
18
18
  /**
19
19
  * How long to wait before warming the non-visible tabs.
@@ -63,11 +63,17 @@ export class InfoOverlay implements Component {
63
63
 
64
64
  onClose?: () => void;
65
65
  requestRender?: () => void;
66
+ /**
67
+ * Whether this overlay is the focused (topmost) entry in the TUI overlay
68
+ * stack. The boot auto-close timer only fires while this is true — see
69
+ * `startBootTimer` for why.
70
+ */
71
+ isTopmostOverlay?: () => boolean;
66
72
 
67
- private theme: Theme | null = null;
73
+ private overlay = new OverlayTheme();
68
74
 
69
75
  setTheme(theme: Theme): void {
70
- this.theme = theme;
76
+ this.overlay.setTheme(theme);
71
77
  }
72
78
 
73
79
  constructor() {
@@ -230,17 +236,36 @@ export class InfoOverlay implements Component {
230
236
  *
231
237
  * Used when the overlay is shown on boot: the dashboard is informational, so
232
238
  * it should get out of the way on its own rather than requiring a keypress.
239
+ *
240
+ * The close callback (`onClose` → `done`) pops the *topmost* overlay in the
241
+ * TUI stack, not this one specifically. When another overlay (e.g. the
242
+ * updater's "Update Available" prompt) is stacked on top, firing `done()`
243
+ * here would remove the covering overlay and strand this dashboard with a
244
+ * spent one-shot close the user can no longer trigger — leaving the starting
245
+ * screen stuck. So we defer the auto-close until we are actually the
246
+ * focused/topmost overlay; the user can still press q/Esc to dismiss it once
247
+ * the covering overlay is gone.
233
248
  */
234
249
  startBootTimer(ms: number): void {
235
250
  this.cancelBootTimer();
236
251
  if (!Number.isFinite(ms) || ms <= 0) return;
237
- this.bootTimer = setTimeout(() => {
238
- this.bootTimer = null;
239
- if (this._destroyed) return;
240
- this.destroy();
241
- this.onClose?.();
242
- }, ms);
243
- this.bootTimer.unref?.();
252
+ const arm = (): void => {
253
+ this.bootTimer = setTimeout(() => {
254
+ this.bootTimer = null;
255
+ if (this._destroyed) return;
256
+ if (this.isTopmostOverlay && !this.isTopmostOverlay()) {
257
+ // Something is stacked on top of us — closing now would pop it
258
+ // instead of this dashboard. Retry shortly; once the covering
259
+ // overlay closes we'll be topmost and can auto-close safely.
260
+ arm();
261
+ return;
262
+ }
263
+ this.destroy();
264
+ this.onClose?.();
265
+ }, ms);
266
+ this.bootTimer.unref?.();
267
+ };
268
+ arm();
244
269
  }
245
270
 
246
271
  invalidate(): void {
@@ -251,17 +276,17 @@ export class InfoOverlay implements Component {
251
276
  // Any keypress means the user is driving; stop the boot auto-close.
252
277
  this.cancelBootTimer();
253
278
 
254
- if (data === "\x1b[C" || data === "l") {
279
+ if (matchesKey(data, Key.right) || data === "l") {
255
280
  this.activeTabIndex = (this.activeTabIndex + 1) % this.groups.length;
256
281
  this.scrollOffset = 0;
257
282
  this.fetchActiveGroup();
258
- } else if (data === "\x1b[D" || data === "h") {
283
+ } else if (matchesKey(data, Key.left) || data === "h") {
259
284
  this.activeTabIndex = (this.activeTabIndex - 1 + this.groups.length) % this.groups.length;
260
285
  this.scrollOffset = 0;
261
286
  this.fetchActiveGroup();
262
- } else if (data === "\x1b[B" || data === "j") {
287
+ } else if (matchesKey(data, Key.down) || data === "j") {
263
288
  this.scrollOffset++;
264
- } else if (data === "\x1b[A" || data === "k") {
289
+ } else if (matchesKey(data, Key.up) || data === "k") {
265
290
  this.scrollOffset = Math.max(0, this.scrollOffset - 1);
266
291
  } else if (data === "g") {
267
292
  this.scrollOffset = 0;
@@ -273,7 +298,7 @@ export class InfoOverlay implements Component {
273
298
  } else if (data === "R") {
274
299
  // Refresh all
275
300
  this.refreshAll();
276
- } else if (data === "q" || matchesKey(data, "escape")) {
301
+ } else if (data === "q" || matchesKey(data, Key.escape)) {
277
302
  this.destroy();
278
303
  this.onClose?.();
279
304
  }
@@ -309,60 +334,20 @@ export class InfoOverlay implements Component {
309
334
  return this.renderDashboard(width);
310
335
  }
311
336
 
312
- // ─── Theme helpers ───────────────────────────────────────────────────
313
-
314
- private fg(color: string, text: string): string {
315
- if (this.theme) return this.theme.fg(color as any, text);
316
- const c: Record<string, string> = {
317
- accent: "\x1b[36m", success: "\x1b[32m", warning: "\x1b[33m",
318
- error: "\x1b[31m", dim: "\x1b[2m", borderMuted: "\x1b[90m",
319
- };
320
- return `${c[color] ?? ""}${text}\x1b[0m`;
321
- }
322
-
323
- private bold(text: string): string {
324
- return this.theme ? this.theme.bold(text) : `\x1b[1m${text}\x1b[0m`;
325
- }
326
-
327
- private bg(color: string, text: string): string {
328
- return this.theme ? this.theme.bg(color as any, text) : text;
329
- }
330
-
331
- private frameLine(content: string, innerWidth: number): string {
332
- const truncated = truncateToWidth(content, innerWidth, "");
333
- const padding = Math.max(0, innerWidth - visibleWidth(truncated));
334
- return `${this.fg("borderMuted", "│")}${truncated}${" ".repeat(padding)}${this.fg("borderMuted", "│")}`;
335
- }
336
-
337
- private ruleLine(innerWidth: number): string {
338
- return this.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`);
339
- }
340
-
341
- private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
342
- const left = edge === "top" ? "┌" : "└";
343
- const right = edge === "top" ? "┐" : "┘";
344
- return this.fg("borderMuted", `${left}${"─".repeat(innerWidth)}${right}`);
345
- }
346
-
347
- private getDialogHeight(): number {
348
- const terminalRows = process.stdout.rows ?? 30;
349
- return Math.max(18, Math.min(32, Math.floor(terminalRows * 0.78)));
350
- }
351
-
352
337
  // ─── State views ─────────────────────────────────────────────────────
353
338
 
354
339
  private renderEmpty(width: number): string[] {
355
340
  const innerWidth = boxInnerWidth(width);
356
341
  const lines: string[] = [];
357
- lines.push(this.borderLine(innerWidth, "top"));
358
- lines.push(this.frameLine(this.fg("accent", this.bold("📊 UniPi Info Screen")), innerWidth));
359
- lines.push(this.ruleLine(innerWidth));
360
- lines.push(this.frameLine(this.fg("dim", "No groups registered."), innerWidth));
361
- lines.push(this.frameLine(this.fg("dim", "Modules will register groups on startup."), innerWidth));
362
- for (let i = 0; i < 4; i++) lines.push(this.frameLine("", innerWidth));
363
- lines.push(this.ruleLine(innerWidth));
364
- lines.push(this.frameLine(this.fg("dim", "q/Esc close · r refresh"), innerWidth));
365
- lines.push(this.borderLine(innerWidth, "bottom"));
342
+ lines.push(this.overlay.borderLine(innerWidth, "top"));
343
+ lines.push(this.overlay.frameLine(this.overlay.fg("accent", this.overlay.bold("📊 UniPi Info Screen")), innerWidth));
344
+ lines.push(this.overlay.ruleLine(innerWidth));
345
+ lines.push(this.overlay.frameLine(this.overlay.fg("dim", "No groups registered."), innerWidth));
346
+ lines.push(this.overlay.frameLine(this.overlay.fg("dim", "Modules will register groups on startup."), innerWidth));
347
+ for (let i = 0; i < 4; i++) lines.push(this.overlay.frameLine("", innerWidth));
348
+ lines.push(this.overlay.ruleLine(innerWidth));
349
+ lines.push(this.overlay.frameLine(this.overlay.fg("dim", "q/Esc close · r refresh"), innerWidth));
350
+ lines.push(this.overlay.borderLine(innerWidth, "bottom"));
366
351
  return lines;
367
352
  }
368
353
 
@@ -377,19 +362,19 @@ export class InfoOverlay implements Component {
377
362
  const CONTENT_HEIGHT = 12;
378
363
  const lines: string[] = [];
379
364
 
380
- lines.push(this.borderLine(innerWidth, "top"));
365
+ lines.push(this.overlay.borderLine(innerWidth, "top"));
381
366
 
382
367
  // Header: group name + loading indicator
383
368
  const loadingDot = isLoading
384
- ? ` ${this.fg("warning", "●")}`
385
- : ` ${this.fg("success", "●")}`;
386
- const headerText = this.fg("accent", this.bold(` ${group.icon} ${group.name} `)) + loadingDot;
387
- lines.push(this.frameLine(headerText, innerWidth));
388
- lines.push(this.ruleLine(innerWidth));
369
+ ? ` ${this.overlay.fg("warning", "●")}`
370
+ : ` ${this.overlay.fg("success", "●")}`;
371
+ const headerText = this.overlay.fg("accent", this.overlay.bold(` ${group.icon} ${group.name} `)) + loadingDot;
372
+ lines.push(this.overlay.frameLine(headerText, innerWidth));
373
+ lines.push(this.overlay.ruleLine(innerWidth));
389
374
 
390
375
  // Tab bar
391
- lines.push(this.frameLine(this.renderTabBar(innerWidth), innerWidth));
392
- lines.push(this.ruleLine(innerWidth));
376
+ lines.push(this.overlay.frameLine(this.renderTabBar(innerWidth), innerWidth));
377
+ lines.push(this.overlay.ruleLine(innerWidth));
393
378
 
394
379
  // Content with scrolling
395
380
  const contentLines = this.renderGroupContent(innerWidth, group, data);
@@ -399,13 +384,13 @@ export class InfoOverlay implements Component {
399
384
 
400
385
  const visible = wrapped.slice(this.scrollOffset, this.scrollOffset + CONTENT_HEIGHT);
401
386
  for (let i = 0; i < CONTENT_HEIGHT; i++) {
402
- lines.push(this.frameLine(visible[i] ?? "", innerWidth));
387
+ lines.push(this.overlay.frameLine(visible[i] ?? "", innerWidth));
403
388
  }
404
389
 
405
390
  // Footer
406
- lines.push(this.ruleLine(innerWidth));
407
- lines.push(this.frameLine(this.renderFooter(innerWidth, wrapped.length, CONTENT_HEIGHT), innerWidth));
408
- lines.push(this.borderLine(innerWidth, "bottom"));
391
+ lines.push(this.overlay.ruleLine(innerWidth));
392
+ lines.push(this.overlay.frameLine(this.renderFooter(innerWidth, wrapped.length, CONTENT_HEIGHT), innerWidth));
393
+ lines.push(this.overlay.borderLine(innerWidth, "bottom"));
409
394
 
410
395
  return lines;
411
396
  }
@@ -414,7 +399,7 @@ export class InfoOverlay implements Component {
414
399
  if (this.groups.length === 0) return "";
415
400
 
416
401
  const tabWidths = this.groups.map(g => visibleWidth(` ${g.icon} ${g.name} `));
417
- const sepW = visibleWidth(this.fg("borderMuted", "│"));
402
+ const sepW = visibleWidth(this.overlay.fg("borderMuted", "│"));
418
403
  const indicatorSpace = 3;
419
404
  let maxTabs = 0;
420
405
  let totalW = 0;
@@ -443,18 +428,18 @@ export class InfoOverlay implements Component {
443
428
  const color = TAB_FG[i % TAB_FG.length]!;
444
429
  // Per-tab loading indicator
445
430
  const isLoading = this.groupLoading.get(g.id) ?? false;
446
- const dot = isLoading ? this.fg("warning", "●") : "";
431
+ const dot = isLoading ? this.overlay.fg("warning", "●") : "";
447
432
 
448
433
  if (isActive) {
449
- tabs.push(this.fg(color, this.bold(` ${g.icon} ${g.name} ${dot}`)));
434
+ tabs.push(this.overlay.fg(color, this.overlay.bold(` ${g.icon} ${g.name} ${dot}`)));
450
435
  } else {
451
- tabs.push(this.fg("dim", ` ${g.icon} ${g.name} ${dot}`));
436
+ tabs.push(this.overlay.fg("dim", ` ${g.icon} ${g.name} ${dot}`));
452
437
  }
453
438
  }
454
439
 
455
- const tabStr = tabs.join(this.fg("borderMuted", "│"));
456
- if (this.tabScrollOffset > 0) return `${this.fg("dim", "◀")} ${tabStr}`;
457
- if (this.tabScrollOffset + maxTabs < this.groups.length) return `${tabStr} ${this.fg("dim", "▶")}`;
440
+ const tabStr = tabs.join(this.overlay.fg("borderMuted", "│"));
441
+ if (this.tabScrollOffset > 0) return `${this.overlay.fg("dim", "◀")} ${tabStr}`;
442
+ if (this.tabScrollOffset + maxTabs < this.groups.length) return `${tabStr} ${this.overlay.fg("dim", "▶")}`;
458
443
  return tabStr;
459
444
  }
460
445
 
@@ -465,15 +450,15 @@ export class InfoOverlay implements Component {
465
450
  const isActive = i === this.activeTabIndex;
466
451
  const color = TAB_FG[i % TAB_FG.length]!;
467
452
  const isLoading = this.groupLoading.get(g.id) ?? false;
468
- const dot = isLoading ? this.fg("warning", "●") : "";
453
+ const dot = isLoading ? this.overlay.fg("warning", "●") : "";
469
454
 
470
455
  if (isActive) {
471
- tabs.push(this.fg(color, this.bold(` ${g.icon} ${g.name} ${dot}`)));
456
+ tabs.push(this.overlay.fg(color, this.overlay.bold(` ${g.icon} ${g.name} ${dot}`)));
472
457
  } else {
473
- tabs.push(this.fg("dim", ` ${g.icon} ${g.name} ${dot}`));
458
+ tabs.push(this.overlay.fg("dim", ` ${g.icon} ${g.name} ${dot}`));
474
459
  }
475
460
  }
476
- return tabs.join(this.fg("borderMuted", "│"));
461
+ return tabs.join(this.overlay.fg("borderMuted", "│"));
477
462
  }
478
463
 
479
464
  private renderGroupContent(width: number, group: InfoGroup, data: GroupData): string[] {
@@ -482,14 +467,14 @@ export class InfoOverlay implements Component {
482
467
  const visibleStats = infoRegistry.getVisibleStats(group.id);
483
468
 
484
469
  if (visibleStats.length === 0) {
485
- lines.push(` ${this.fg("dim", "No stats configured for this group.")}`);
470
+ lines.push(` ${this.overlay.fg("dim", "No stats configured for this group.")}`);
486
471
  return lines;
487
472
  }
488
473
 
489
474
  // If no data yet and loading, show placeholder per stat
490
475
  if (Object.keys(data).length === 0 && isLoading) {
491
476
  for (const stat of visibleStats) {
492
- lines.push(` ${this.fg("dim", `${stat.label}:`)} ${this.fg("warning", "···")}`);
477
+ lines.push(` ${this.overlay.fg("dim", `${stat.label}:`)} ${this.overlay.fg("warning", "···")}`);
493
478
  }
494
479
  return lines;
495
480
  }
@@ -502,12 +487,12 @@ export class InfoOverlay implements Component {
502
487
  const detail = statData?.detail;
503
488
 
504
489
  const label = `${stat.label}:`.padEnd(maxLabelLen + 1);
505
- let line = ` ${this.fg("dim", label)} ${this.bold(value)}`;
490
+ let line = ` ${this.overlay.fg("dim", label)} ${this.overlay.bold(value)}`;
506
491
 
507
492
  if (detail) {
508
493
  const detailLines = detail.split("\n");
509
494
  if (detailLines.length === 1) {
510
- line += ` ${this.fg("dim", `(${detail})`)}`;
495
+ line += ` ${this.overlay.fg("dim", `(${detail})`)}`;
511
496
  } else {
512
497
  lines.push(line);
513
498
  for (const dLine of detailLines) {
@@ -536,7 +521,7 @@ export class InfoOverlay implements Component {
536
521
  const hasScroll = totalLines > visibleHeight;
537
522
  let scrollStr = "";
538
523
  if (hasScroll) {
539
- scrollStr = this.fg("dim", `${this.scrollOffset + 1}-${Math.min(this.scrollOffset + visibleHeight, totalLines)}/${totalLines}`);
524
+ scrollStr = this.overlay.fg("dim", `${this.scrollOffset + 1}-${Math.min(this.scrollOffset + visibleHeight, totalLines)}/${totalLines}`);
540
525
  }
541
526
 
542
527
  // Last updated for active group
@@ -545,17 +530,17 @@ export class InfoOverlay implements Component {
545
530
  const age = lastUp > 0 ? humanizeAge(Date.now() - lastUp) : "loading…";
546
531
 
547
532
  const hints = [
548
- `${this.fg("accent", "←/→")} tabs`,
549
- `${this.fg("success", "↑/↓")} scroll`,
550
- `${this.fg("warning", "r")} refresh`,
551
- `${this.fg("error", "q/Esc")} close`,
533
+ `${this.overlay.fg("accent", "←/→")} tabs`,
534
+ `${this.overlay.fg("success", "↑/↓")} scroll`,
535
+ `${this.overlay.fg("warning", "r")} refresh`,
536
+ `${this.overlay.fg("error", "q/Esc")} close`,
552
537
  ];
553
538
 
554
- const hintStr = hints.join(` ${this.fg("borderMuted", "•")} `);
539
+ const hintStr = hints.join(` ${this.overlay.fg("borderMuted", "•")} `);
555
540
 
556
541
  // Build right side: age + hints
557
- const ageStr = this.fg("dim", `⏱ ${age}`);
558
- const rightStr = `${ageStr} ${this.fg("borderMuted", "│")} ${hintStr}`;
542
+ const ageStr = this.overlay.fg("dim", `⏱ ${age}`);
543
+ const rightStr = `${ageStr} ${this.overlay.fg("borderMuted", "│")} ${hintStr}`;
559
544
 
560
545
  const scrollW = visibleWidth(scrollStr);
561
546
  const rightW = visibleWidth(rightStr);
package/usage-parser.ts CHANGED
@@ -25,13 +25,10 @@ export interface UsageStats {
25
25
  today: number;
26
26
  week: number;
27
27
  month: number;
28
- allTime: number;
29
28
  };
30
29
  /** Total cost by period (USD) */
31
30
  cost: {
32
31
  today: number;
33
- week: number;
34
- month: number;
35
32
  allTime: number;
36
33
  };
37
34
  /** Token counts by model (all time) */
@@ -44,8 +41,6 @@ export interface UsageStats {
44
41
  byModelMonth: Record<string, { tokens: number; cost: number; sessions: number }>;
45
42
  /** Total sessions */
46
43
  sessionCount: number;
47
- /** Total messages */
48
- messageCount: number;
49
44
  }
50
45
 
51
46
  /** Time period boundaries */
@@ -275,16 +270,8 @@ function collectSessionFiles(dir: string, files: string[]): void {
275
270
  }
276
271
 
277
272
  /**
278
- * Parse all session files and aggregate usage stats.
279
- * Matches tmustier's parsing logic.
280
- */
281
- export function parseUsageStats(): UsageStats {
282
- const { stats } = collectStats(null);
283
- return stats;
284
- }
285
-
286
- /**
287
- * Async variant that yields to the event loop while parsing.
273
+ * Parse all session files and aggregate usage stats, yielding to the event
274
+ * loop while parsing.
288
275
  *
289
276
  * A cold parse is several seconds of pure CPU. Running it synchronously starves
290
277
  * the event loop, so keystrokes queue up and the UI cannot repaint. Deferring
@@ -292,41 +279,34 @@ export function parseUsageStats(): UsageStats {
292
279
  * `yieldEvery` files, control returns to the loop.
293
280
  */
294
281
  export async function parseUsageStatsAsync(): Promise<UsageStats> {
295
- const yielder = async (): Promise<void> => {
296
- await new Promise<void>((resolve) => setImmediate(resolve));
297
- };
298
- const { stats, pending } = collectStats(yielder);
299
- if (pending) await pending;
282
+ const { stats, pending } = collectStats();
283
+ await pending;
300
284
  return stats;
301
285
  }
302
286
 
303
287
  /** Empty stats accumulator. */
304
288
  function emptyStats(): UsageStats {
305
289
  return {
306
- tokens: { today: 0, week: 0, month: 0, allTime: 0 },
307
- cost: { today: 0, week: 0, month: 0, allTime: 0 },
290
+ tokens: { today: 0, week: 0, month: 0 },
291
+ cost: { today: 0, allTime: 0 },
308
292
  byModel: {},
309
293
  byModelToday: {},
310
294
  byModelWeek: {},
311
295
  byModelMonth: {},
312
296
  sessionCount: 0,
313
- messageCount: 0,
314
297
  };
315
298
  }
316
299
 
317
300
  /**
318
- * Shared implementation for the sync and async entry points.
301
+ * Scan all session files and aggregate usage stats.
319
302
  *
320
- * When `yielder` is null the whole scan runs synchronously and `stats` is fully
321
- * populated on return. When provided, the returned `stats` object is filled in
322
- * as `pending` progresses, and the caller must await it.
303
+ * The returned `stats` object is filled in as `pending` progresses; the caller
304
+ * must await it.
323
305
  */
324
- function collectStats(
325
- yielder: (() => Promise<void>) | null,
326
- ): { stats: UsageStats; pending: Promise<void> | null } {
306
+ function collectStats(): { stats: UsageStats; pending: Promise<void> } {
327
307
  const stats = emptyStats();
328
308
  const sessionsDir = getSessionsDir();
329
- if (!existsSync(sessionsDir)) return { stats, pending: null };
309
+ if (!existsSync(sessionsDir)) return { stats, pending: Promise.resolve() };
330
310
 
331
311
  const sessionFiles: string[] = [];
332
312
  collectSessionFiles(sessionsDir, sessionFiles);
@@ -405,7 +385,6 @@ function collectStats(
405
385
  counted++;
406
386
  const model = models[modelIdx] ?? "unknown";
407
387
 
408
- stats.tokens.allTime += countedTokens;
409
388
  stats.cost.allTime += cost;
410
389
  bump(stats.byModel, model, countedTokens, cost);
411
390
 
@@ -416,19 +395,16 @@ function collectStats(
416
395
  }
417
396
  if (timestamp >= weekStart) {
418
397
  stats.tokens.week += countedTokens;
419
- stats.cost.week += cost;
420
398
  bump(stats.byModelWeek, model, countedTokens, cost);
421
399
  }
422
400
  if (timestamp >= monthStart) {
423
401
  stats.tokens.month += countedTokens;
424
- stats.cost.month += cost;
425
402
  bump(stats.byModelMonth, model, countedTokens, cost);
426
403
  }
427
404
  }
428
405
 
429
406
  if (counted > 0) {
430
407
  stats.sessionCount++;
431
- stats.messageCount += counted;
432
408
  }
433
409
  };
434
410
 
@@ -440,17 +416,6 @@ function collectStats(
440
416
 
441
417
  const YIELD_EVERY = 25;
442
418
 
443
- if (!yielder) {
444
- for (const item of work) {
445
- const records = item.cached
446
- ? item.cached.records
447
- : (nextFiles[item.path].records = extractRecords(item.path, modelIndex, models));
448
- aggregate(records);
449
- }
450
- finish();
451
- return { stats, pending: null };
452
- }
453
-
454
419
  const pending = (async () => {
455
420
  let sinceYield = 0;
456
421
  for (const item of work) {
@@ -467,7 +432,7 @@ function collectStats(
467
432
  aggregate(records);
468
433
  if (sinceYield >= YIELD_EVERY) {
469
434
  sinceYield = 0;
470
- await yielder();
435
+ await new Promise<void>((resolve) => setImmediate(resolve));
471
436
  }
472
437
  }
473
438
  finish();