@pi-unipi/info-screen 0.1.10 → 0.1.11

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.
@@ -1,81 +1,135 @@
1
1
  /**
2
- * @pi-unipi/info-screen — TUI Overlay Component
2
+ * @pi-unipi/info-screen — TUI Overlay Component (Cache-First Reactive)
3
3
  *
4
- * Main dashboard overlay with tabbed navigation.
5
- * Displays registered groups as tabs with their stats.
4
+ * Opens immediately with cached data.
5
+ * Each group loads independently in the background.
6
+ * Reactive: re-renders as data arrives.
7
+ * Shows humanized "last updated" timestamps.
6
8
  */
7
9
 
8
10
  import type { Component } from "@mariozechner/pi-tui";
9
- import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
11
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
12
+ import type { Theme } from "@mariozechner/pi-coding-agent";
10
13
  import { infoRegistry } from "../registry.js";
11
14
  import { getInfoSettings } from "../config.js";
12
15
  import type { InfoGroup, GroupData } from "../types.js";
13
16
 
14
- /** ANSI escape codes */
15
- const ansi = {
16
- reset: "\x1b[0m",
17
- bold: "\x1b[1m",
18
- dim: "\x1b[2m",
19
- underline: "\x1b[4m",
20
- // Colors
21
- blue: "\x1b[34m",
22
- cyan: "\x1b[36m",
23
- green: "\x1b[32m",
24
- yellow: "\x1b[33m",
25
- magenta: "\x1b[35m",
26
- white: "\x1b[37m",
27
- red: "\x1b[31m",
28
- gray: "\x1b[90m",
29
-
30
- };
31
-
32
17
  /** Tab color palette */
33
- const TAB_COLORS = [
34
- ansi.cyan,
35
- ansi.green,
36
- ansi.yellow,
37
- ansi.magenta,
38
- ansi.blue,
18
+ const TAB_FG: Array<"accent" | "success" | "warning" | "error"> = [
19
+ "accent",
20
+ "success",
21
+ "warning",
22
+ "error",
39
23
  ];
40
24
 
25
+ /** Humanize a duration in ms to a short string */
26
+ function humanizeAge(ms: number): string {
27
+ if (ms <= 0) return "never";
28
+ const seconds = Math.floor(ms / 1000);
29
+ if (seconds < 5) return "just now";
30
+ if (seconds < 60) return `${seconds}s ago`;
31
+ const minutes = Math.floor(seconds / 60);
32
+ if (minutes < 60) return `${minutes}m ago`;
33
+ const hours = Math.floor(minutes / 60);
34
+ return `${hours}h ago`;
35
+ }
36
+
41
37
  /**
42
- * Info overlay component with tabbed navigation.
38
+ * Info overlay component with cache-first reactive model.
43
39
  */
44
40
  export class InfoOverlay implements Component {
45
41
  private groups: InfoGroup[] = [];
46
42
  private activeTabIndex = 0;
47
43
  private groupData = new Map<string, GroupData>();
48
- private loading = true;
49
- private error: string | null = null;
44
+ private groupLoading = new Map<string, boolean>();
50
45
  private scrollOffset = 0;
51
- /** Tab scroll offset for windowed scrolling */
52
46
  private tabScrollOffset = 0;
53
- /** Callback when overlay should close */
47
+ private lastGlobalUpdate = 0;
48
+ private unsubscribers: Array<() => void> = [];
49
+ private _destroyed = false;
50
+
54
51
  onClose?: () => void;
52
+ requestRender?: () => void;
53
+
54
+ private theme: Theme | null = null;
55
+
56
+ setTheme(theme: Theme): void {
57
+ this.theme = theme;
58
+ }
55
59
 
56
60
  constructor() {
57
- // Start loading data immediately
58
- this.loadData();
61
+ // Load groups synchronously (they're already registered)
62
+ this.groups = infoRegistry.getAllGroups();
63
+ this.applyOrder();
64
+
65
+ // Seed cache with any existing data (instant display)
66
+ for (const group of this.groups) {
67
+ const cached = infoRegistry.getCachedData(group.id);
68
+ if (cached) {
69
+ this.groupData.set(group.id, cached);
70
+ }
71
+ this.groupLoading.set(group.id, true);
72
+ }
73
+
74
+ // Subscribe to per-group updates for reactive rendering
75
+ this.unsubscribers.push(
76
+ infoRegistry.subscribeAll((groupId, data) => {
77
+ if (this._destroyed) return;
78
+ this.groupData.set(groupId, data);
79
+ this.groupLoading.set(groupId, false);
80
+ this.lastGlobalUpdate = Date.now();
81
+ this.requestRender?.();
82
+ })
83
+ );
84
+
85
+ // Start background fetch for all groups (non-blocking)
86
+ this.fetchAllBackground();
59
87
  }
60
88
 
61
89
  /**
62
- * Invalidate cached render state.
90
+ * Fetch all groups in background. Each resolves independently.
63
91
  */
64
- invalidate(): void {
65
- // No cached state to invalidate
92
+ private async fetchAllBackground(): Promise<void> {
93
+ for (const group of this.groups) {
94
+ // Fire each fetch independently — don't await sequentially
95
+ infoRegistry.getGroupData(group.id).then(() => {
96
+ this.groupLoading.set(group.id, false);
97
+ }).catch(() => {
98
+ this.groupLoading.set(group.id, false);
99
+ });
100
+ }
66
101
  }
67
102
 
68
103
  /**
69
- * Load data for all groups.
104
+ * Handle late-arriving groups (e.g., subagents announces after boot).
70
105
  */
71
- private async loadData(): Promise<void> {
72
- this.loading = true;
73
- // Wait a bit for modules to announce before fetching groups
74
- await new Promise(r => setTimeout(r, 500));
75
- // Always re-fetch ALL groups to catch late registrations
76
- this.groups = infoRegistry.getAllGroups();
77
-
78
- // Apply saved order from settings
106
+ private syncGroups(): void {
107
+ const allGroups = infoRegistry.getAllGroups();
108
+ if (allGroups.length !== this.groups.length) {
109
+ this.groups = allGroups;
110
+ this.applyOrder();
111
+
112
+ // Seed new groups from cache
113
+ for (const group of this.groups) {
114
+ if (!this.groupData.has(group.id)) {
115
+ const cached = infoRegistry.getCachedData(group.id);
116
+ if (cached) {
117
+ this.groupData.set(group.id, cached);
118
+ } else {
119
+ this.groupLoading.set(group.id, true);
120
+ // Fetch this new group
121
+ infoRegistry.getGroupData(group.id).then(() => {
122
+ this.groupLoading.set(group.id, false);
123
+ }).catch(() => {
124
+ this.groupLoading.set(group.id, false);
125
+ });
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ private applyOrder(): void {
79
133
  const settings = getInfoSettings();
80
134
  if (settings.groupOrder && settings.groupOrder.length > 0) {
81
135
  const order = settings.groupOrder;
@@ -85,107 +139,69 @@ export class InfoOverlay implements Component {
85
139
  return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
86
140
  });
87
141
  }
142
+ }
88
143
 
89
- try {
90
- // Load data for all groups in parallel with timeout
91
- const loadPromises = this.groups.map(async (group) => {
92
- try {
93
- const data = await Promise.race([
94
- infoRegistry.getGroupData(group.id),
95
- new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
96
- ]);
97
- this.groupData.set(group.id, data);
98
- } catch {
99
- // Use empty data on timeout/error
100
- this.groupData.set(group.id, {});
101
- }
102
- });
103
-
104
- await Promise.all(loadPromises);
105
- } catch (error) {
106
- this.error = error instanceof Error ? error.message : String(error);
144
+ /**
145
+ * Cleanup subscriptions.
146
+ */
147
+ destroy(): void {
148
+ this._destroyed = true;
149
+ for (const unsub of this.unsubscribers) {
150
+ unsub();
107
151
  }
152
+ this.unsubscribers = [];
153
+ }
108
154
 
109
- this.loading = false;
155
+ invalidate(): void {
156
+ this.syncGroups();
110
157
  }
111
158
 
112
- /**
113
- * Handle keyboard input.
114
- */
115
159
  handleInput(data: string): void {
116
- if (this.loading) return;
117
-
118
- // Arrow keys for tab navigation
119
160
  if (data === "\x1b[C" || data === "l") {
120
- // Right arrow - switch tab
121
161
  this.activeTabIndex = (this.activeTabIndex + 1) % this.groups.length;
122
- this.scrollOffset = 0; // Reset scroll on tab switch
123
- this.ensureTabVisible();
162
+ this.scrollOffset = 0;
124
163
  } else if (data === "\x1b[D" || data === "h") {
125
- // Left arrow - switch tab
126
164
  this.activeTabIndex = (this.activeTabIndex - 1 + this.groups.length) % this.groups.length;
127
- this.scrollOffset = 0; // Reset scroll on tab switch
128
- this.ensureTabVisible();
165
+ this.scrollOffset = 0;
129
166
  } else if (data === "\x1b[B" || data === "j") {
130
- // Down arrow - scroll down
131
167
  this.scrollOffset++;
132
168
  } else if (data === "\x1b[A" || data === "k") {
133
- // Up arrow - scroll up
134
169
  this.scrollOffset = Math.max(0, this.scrollOffset - 1);
135
170
  } else if (data === "g") {
136
- // g - go to top
137
171
  this.scrollOffset = 0;
138
172
  } else if (data === "G") {
139
- // G - go to bottom (will be clamped in render)
140
173
  this.scrollOffset = Infinity;
174
+ } else if (data === "r") {
175
+ // Manual refresh
176
+ this.refreshActiveGroup();
177
+ } else if (data === "R") {
178
+ // Refresh all
179
+ this.refreshAll();
141
180
  } else if (data === "q" || data === "\x1b") {
142
- // q or Escape - close overlay
181
+ this.destroy();
143
182
  this.onClose?.();
144
183
  }
145
184
  }
146
185
 
147
- /**
148
- * Ensure active tab is visible in the tab bar (horizontal scroll).
149
- */
150
- private ensureTabVisible(): void {
151
- // Tab bar shows ~maxTabsVisible tabs, centered around active
152
- // This is handled in renderTabBar
186
+ private refreshActiveGroup(): void {
187
+ const group = this.groups[this.activeTabIndex];
188
+ if (!group) return;
189
+ this.groupLoading.set(group.id, true);
190
+ this.requestRender?.();
191
+ infoRegistry.refreshGroup(group.id);
153
192
  }
154
193
 
155
- /**
156
- * Render the component.
157
- */
158
- render(width: number): string[] {
159
- // While loading, show loading state
160
- if (this.loading) {
161
- return this.renderLoading(width);
162
- }
163
-
164
- if (this.error) {
165
- return this.renderError(width);
166
- }
167
-
168
- // Check for new groups and re-fetch data
169
- const allGroups = infoRegistry.getAllGroups();
170
- const groupIds = allGroups.map(g => g.id).join(",");
171
- const currentIds = this.groups.map(g => g.id).join(",");
172
-
173
- if (groupIds !== currentIds || this.groups.length !== allGroups.length) {
174
- this.groups = allGroups;
175
- // Apply saved order
176
- const settings = getInfoSettings();
177
- if (settings.groupOrder && settings.groupOrder.length > 0) {
178
- const order = settings.groupOrder;
179
- this.groups.sort((a, b) => {
180
- const ai = order.indexOf(a.id);
181
- const bi = order.indexOf(b.id);
182
- return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
183
- });
184
- }
194
+ private refreshAll(): void {
195
+ for (const group of this.groups) {
196
+ this.groupLoading.set(group.id, true);
185
197
  }
198
+ this.requestRender?.();
199
+ infoRegistry.refreshAll();
200
+ }
186
201
 
187
- // Always re-fetch data for all groups to catch late updates
188
- this.refreshAllData();
202
+ render(width: number): string[] {
203
+ // Sync groups in case late arrivals
204
+ this.syncGroups();
189
205
 
190
206
  if (this.groups.length === 0) {
191
207
  return this.renderEmpty(width);
@@ -194,290 +210,191 @@ export class InfoOverlay implements Component {
194
210
  return this.renderDashboard(width);
195
211
  }
196
212
 
197
- /**
198
- * Load data for groups we don't have data for yet.
199
- */
200
- private async loadDataForNewGroups(groups: InfoGroup[]): Promise<void> {
201
- for (const group of groups) {
202
- if (!this.groupData.has(group.id)) {
203
- try {
204
- const data = await infoRegistry.getGroupData(group.id);
205
- this.groupData.set(group.id, data);
206
- } catch {
207
- // Silently skip groups with errors
208
- }
209
- }
210
- }
211
- }
212
-
213
- /**
214
- * Callback set by wrapper to trigger re-render.
215
- */
216
- requestRender?: () => void;
213
+ // ─── Theme helpers ───────────────────────────────────────────────────
217
214
 
218
- /**
219
- * Refresh data for all groups (non-blocking).
220
- */
221
- private refreshAllData(): void {
222
- for (const group of this.groups) {
223
- // Invalidate cache to get fresh data
224
- infoRegistry.invalidateCache(group.id);
225
- // Fetch fresh data (non-blocking)
226
- infoRegistry.getGroupData(group.id).then(data => {
227
- const old = this.groupData.get(group.id);
228
- const oldStr = JSON.stringify(old);
229
- const newStr = JSON.stringify(data);
230
- this.groupData.set(group.id, data);
231
- // Trigger re-render if data changed
232
- if (oldStr !== newStr && this.requestRender) {
233
- this.requestRender();
234
- }
235
- }).catch(() => {
236
- // Ignore errors
237
- });
238
- }
215
+ private fg(color: string, text: string): string {
216
+ if (this.theme) return this.theme.fg(color as any, text);
217
+ const c: Record<string, string> = {
218
+ accent: "\x1b[36m", success: "\x1b[32m", warning: "\x1b[33m",
219
+ error: "\x1b[31m", dim: "\x1b[2m", borderMuted: "\x1b[90m",
220
+ };
221
+ return `${c[color] ?? ""}${text}\x1b[0m`;
239
222
  }
240
223
 
241
- /**
242
- * Render loading state.
243
- */
244
- private renderLoading(width: number): string[] {
245
- const lines: string[] = [];
246
- const padding = " ".repeat(Math.max(0, Math.floor((width - 20) / 2)));
224
+ private bold(text: string): string {
225
+ return this.theme ? this.theme.bold(text) : `\x1b[1m${text}\x1b[0m`;
226
+ }
247
227
 
248
- lines.push(`${padding}${ansi.cyan}${ansi.bold}📊 UniPi Info Screen${ansi.reset}`);
249
- lines.push("");
250
- lines.push(`${padding}${ansi.dim}Loading dashboard...${ansi.reset}`);
228
+ private bg(color: string, text: string): string {
229
+ return this.theme ? this.theme.bg(color as any, text) : text;
230
+ }
251
231
 
252
- return lines;
232
+ private frameLine(content: string, innerWidth: number): string {
233
+ const truncated = truncateToWidth(content, innerWidth, "");
234
+ const padding = Math.max(0, innerWidth - visibleWidth(truncated));
235
+ return `${this.fg("borderMuted", "│")}${truncated}${" ".repeat(padding)}${this.fg("borderMuted", "│")}`;
253
236
  }
254
237
 
255
- /**
256
- * Render error state.
257
- */
258
- private renderError(width: number): string[] {
259
- const lines: string[] = [];
260
- const padding = " ".repeat(Math.max(0, Math.floor((width - 20) / 2)));
238
+ private ruleLine(innerWidth: number): string {
239
+ return this.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`);
240
+ }
261
241
 
262
- lines.push(`${padding}${ansi.yellow}${ansi.bold}⚠️ Error${ansi.reset}`);
263
- lines.push(`${padding}${ansi.dim}${this.error ?? "Unknown error"}${ansi.reset}`);
242
+ private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
243
+ const left = edge === "top" ? "┌" : "└";
244
+ const right = edge === "top" ? "┐" : "┘";
245
+ return this.fg("borderMuted", `${left}${"─".repeat(innerWidth)}${right}`);
246
+ }
264
247
 
265
- return lines;
248
+ private getDialogHeight(): number {
249
+ const terminalRows = process.stdout.rows ?? 30;
250
+ return Math.max(18, Math.min(32, Math.floor(terminalRows * 0.78)));
266
251
  }
267
252
 
268
- /**
269
- * Render empty state.
270
- */
253
+ // ─── State views ─────────────────────────────────────────────────────
254
+
271
255
  private renderEmpty(width: number): string[] {
256
+ const innerWidth = Math.max(22, width - 2);
272
257
  const lines: string[] = [];
273
- const padding = " ".repeat(Math.max(0, Math.floor((width - 30) / 2)));
274
-
275
- lines.push(`${padding}${ansi.cyan}${ansi.bold}📊 UniPi Info Screen${ansi.reset}`);
276
- lines.push("");
277
- lines.push(`${padding}${ansi.dim}No groups registered.${ansi.reset}`);
278
- lines.push(`${padding}${ansi.dim}Modules will register groups on startup.${ansi.reset}`);
279
-
258
+ lines.push(this.borderLine(innerWidth, "top"));
259
+ lines.push(this.frameLine(this.fg("accent", this.bold("📊 UniPi Info Screen")), innerWidth));
260
+ lines.push(this.ruleLine(innerWidth));
261
+ lines.push(this.frameLine(this.fg("dim", "No groups registered."), innerWidth));
262
+ lines.push(this.frameLine(this.fg("dim", "Modules will register groups on startup."), innerWidth));
263
+ for (let i = 0; i < 4; i++) lines.push(this.frameLine("", innerWidth));
264
+ lines.push(this.ruleLine(innerWidth));
265
+ lines.push(this.frameLine(this.fg("dim", "q/Esc close · r refresh"), innerWidth));
266
+ lines.push(this.borderLine(innerWidth, "bottom"));
280
267
  return lines;
281
268
  }
282
269
 
283
- /**
284
- * Pad a line to fill a target visual width with background.
285
- */
286
- private padToWidth(line: string, targetWidth: number, bg?: string): string {
287
- const visLen = visibleWidth(line);
288
- const pad = Math.max(0, targetWidth - visLen);
289
- if (bg) {
290
- return bg + line + " ".repeat(pad) + ansi.reset;
291
- }
292
- return line + " ".repeat(pad);
293
- }
270
+ // ─── Dashboard ───────────────────────────────────────────────────────
294
271
 
295
- /**
296
- * Render the full dashboard.
297
- */
298
272
  private renderDashboard(width: number): string[] {
299
- const lines: string[] = [];
273
+ const innerWidth = Math.max(22, width - 2);
300
274
  const group = this.groups[this.activeTabIndex];
301
275
  const data = this.groupData.get(group.id) ?? {};
302
- // Inner width for content (subtract 2 for left+right borders)
303
- const innerWidth = width - 2;
276
+ const isLoading = this.groupLoading.get(group.id) ?? false;
304
277
 
305
- // Fixed content height for stability
306
278
  const CONTENT_HEIGHT = 12;
279
+ const lines: string[] = [];
307
280
 
308
- // Top border
309
- lines.push(`${ansi.dim}╭${"─".repeat(innerWidth)}╮${ansi.reset}`);
281
+ lines.push(this.borderLine(innerWidth, "top"));
310
282
 
311
- // Header
312
- lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderHeader(innerWidth, group), innerWidth)}${ansi.dim}│${ansi.reset}`);
313
- lines.push(`${ansi.dim}├${"".repeat(innerWidth)}┤${ansi.reset}`);
283
+ // Header: group name + loading indicator
284
+ const loadingDot = isLoading
285
+ ? ` ${this.fg("warning", "●")}`
286
+ : ` ${this.fg("success", "●")}`;
287
+ const headerText = this.fg("accent", this.bold(` ${group.icon} ${group.name} `)) + loadingDot;
288
+ lines.push(this.frameLine(headerText, innerWidth));
289
+ lines.push(this.ruleLine(innerWidth));
314
290
 
315
- // Tab bar with horizontal scrolling
316
- lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderTabBar(innerWidth), innerWidth)}${ansi.dim}│${ansi.reset}`);
317
- lines.push(`${ansi.dim}├${"─".repeat(innerWidth)}┤${ansi.reset}`);
291
+ // Tab bar
292
+ lines.push(this.frameLine(this.renderTabBar(innerWidth), innerWidth));
293
+ lines.push(this.ruleLine(innerWidth));
318
294
 
319
- // Content with scrolling (fixed height)
295
+ // Content with scrolling
320
296
  const contentLines = this.renderGroupContent(innerWidth, group, data);
321
-
322
- // Clamp scroll offset
323
- const maxScroll = Math.max(0, contentLines.length - CONTENT_HEIGHT);
297
+ const wrapped = this.wrapLines(contentLines, innerWidth);
298
+ const maxScroll = Math.max(0, wrapped.length - CONTENT_HEIGHT);
324
299
  this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
325
-
326
- // Get visible slice
327
- const visibleContent = contentLines.slice(this.scrollOffset, this.scrollOffset + CONTENT_HEIGHT);
328
-
329
- // Render content lines (pad to fixed height)
300
+
301
+ const visible = wrapped.slice(this.scrollOffset, this.scrollOffset + CONTENT_HEIGHT);
330
302
  for (let i = 0; i < CONTENT_HEIGHT; i++) {
331
- const line = visibleContent[i] ?? "";
332
- lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(line, innerWidth)}${ansi.dim}│${ansi.reset}`);
303
+ lines.push(this.frameLine(visible[i] ?? "", innerWidth));
333
304
  }
334
305
 
335
- // Footer with scroll indicator inline
336
- lines.push(`${ansi.dim}├${"─".repeat(innerWidth)}┤${ansi.reset}`);
337
- lines.push(`${ansi.dim}│${ansi.reset}${this.renderFooterWithScroll(innerWidth, contentLines.length, CONTENT_HEIGHT)}${ansi.dim}│${ansi.reset}`);
338
- lines.push(`${ansi.dim}╰${"".repeat(innerWidth)}╯${ansi.reset}`);
306
+ // Footer
307
+ lines.push(this.ruleLine(innerWidth));
308
+ lines.push(this.frameLine(this.renderFooter(innerWidth, wrapped.length, CONTENT_HEIGHT), innerWidth));
309
+ lines.push(this.borderLine(innerWidth, "bottom"));
339
310
 
340
311
  return lines;
341
312
  }
342
313
 
343
- /**
344
- * Render header with title and group info.
345
- */
346
- private renderHeader(width: number, group: InfoGroup): string {
347
- const title = `${group.icon} ${group.name}`;
348
- const paddedTitle = ` ${title} `;
349
- const visLen = visibleWidth(paddedTitle);
350
-
351
- if (visLen >= width - 4) {
352
- return ansi.bold + truncateToWidth(paddedTitle, width - 4) + ansi.reset;
353
- }
354
-
355
- // Center the title
356
- const leftPad = Math.floor((width - visLen) / 2);
357
-
358
- return " ".repeat(leftPad) + ansi.bold + paddedTitle + ansi.reset;
359
- }
360
-
361
- /**
362
- * Render tab bar with windowed scrolling.
363
- * Window slides only when active tab reaches the edge.
364
- * Example: abcde → user on 'e' presses right → efghi
365
- */
366
314
  private renderTabBar(width: number): string {
367
315
  if (this.groups.length === 0) return "";
368
316
 
369
- // Calculate tab widths
370
317
  const tabWidths = this.groups.map(g => visibleWidth(` ${g.icon} ${g.name} `));
371
- const separatorWidth = visibleWidth(`${ansi.dim}│${ansi.reset}`);
372
-
373
- // Find how many tabs fit (account for potential scroll indicators)
374
- const indicatorSpace = 3; // Space for ◀ or ▶
318
+ const sepW = visibleWidth(this.fg("borderMuted", "│"));
319
+ const indicatorSpace = 3;
375
320
  let maxTabs = 0;
376
- let totalWidth = 0;
321
+ let totalW = 0;
377
322
  for (let i = 0; i < this.groups.length; i++) {
378
- const tabW = tabWidths[i]!;
379
- const sepW = i > 0 ? separatorWidth : 0;
380
- // Reserve space for scroll indicator on one side
381
- if (totalWidth + sepW + tabW > width - 2 - indicatorSpace) break;
382
- totalWidth += sepW + tabW;
323
+ const add = (i > 0 ? sepW : 0) + tabWidths[i]!;
324
+ if (totalW + add > width - 2 - indicatorSpace) break;
325
+ totalW += add;
383
326
  maxTabs = i + 1;
384
327
  }
385
328
 
386
- // If all tabs fit, show all
387
329
  if (maxTabs >= this.groups.length) {
388
- return this.renderAllTabs(width);
330
+ return this.renderAllTabs();
389
331
  }
390
332
 
391
- // Windowed scrolling: slide only when active tab reaches edge
392
- // Initialize tabScrollOffset if needed
393
- if (this.tabScrollOffset === undefined) {
394
- this.tabScrollOffset = 0;
395
- }
396
-
397
- // Ensure active tab is visible within the window
398
333
  if (this.activeTabIndex < this.tabScrollOffset) {
399
- // Active tab is before window - slide left
400
334
  this.tabScrollOffset = this.activeTabIndex;
401
335
  } else if (this.activeTabIndex >= this.tabScrollOffset + maxTabs) {
402
- // Active tab is after window - slide right
403
336
  this.tabScrollOffset = this.activeTabIndex - maxTabs + 1;
404
337
  }
405
-
406
- // Clamp scroll offset
407
338
  this.tabScrollOffset = Math.max(0, Math.min(this.tabScrollOffset, this.groups.length - maxTabs));
408
-
409
- // Build visible tabs
339
+
410
340
  const tabs: string[] = [];
411
341
  for (let i = this.tabScrollOffset; i < this.tabScrollOffset + maxTabs && i < this.groups.length; i++) {
412
- const group = this.groups[i]!;
342
+ const g = this.groups[i]!;
413
343
  const isActive = i === this.activeTabIndex;
414
- const color = TAB_COLORS[i % TAB_COLORS.length]!;
344
+ const color = TAB_FG[i % TAB_FG.length]!;
345
+ // Per-tab loading indicator
346
+ const isLoading = this.groupLoading.get(g.id) ?? false;
347
+ const dot = isLoading ? this.fg("warning", "●") : "";
415
348
 
416
349
  if (isActive) {
417
- tabs.push(`${color}${ansi.bold} ${group.icon} ${group.name} ${ansi.reset}`);
350
+ tabs.push(this.fg(color, this.bold(` ${g.icon} ${g.name} ${dot}`)));
418
351
  } else {
419
- tabs.push(`${ansi.dim} ${group.icon} ${group.name} ${ansi.reset}`);
352
+ tabs.push(this.fg("dim", ` ${g.icon} ${g.name} ${dot}`));
420
353
  }
421
354
  }
422
355
 
423
- const tabStr = tabs.join(`${ansi.dim}│${ansi.reset}`);
424
-
425
- // Add scroll indicators
426
- const hasLeft = this.tabScrollOffset > 0;
427
- const hasRight = this.tabScrollOffset + maxTabs < this.groups.length;
428
-
429
- if (hasLeft) {
430
- return `${ansi.dim}◀${ansi.reset} ${tabStr}`;
431
- }
432
- if (hasRight) {
433
- return `${tabStr} ${ansi.dim}▶${ansi.reset}`;
434
- }
435
-
356
+ const tabStr = tabs.join(this.fg("borderMuted", "│"));
357
+ if (this.tabScrollOffset > 0) return `${this.fg("dim", "◀")} ${tabStr}`;
358
+ if (this.tabScrollOffset + maxTabs < this.groups.length) return `${tabStr} ${this.fg("dim", "▶")}`;
436
359
  return tabStr;
437
360
  }
438
361
 
439
- /**
440
- * Render all tabs (when they all fit).
441
- */
442
- private renderAllTabs(width: number): string {
362
+ private renderAllTabs(): string {
443
363
  const tabs: string[] = [];
444
-
445
364
  for (let i = 0; i < this.groups.length; i++) {
446
- const group = this.groups[i]!;
365
+ const g = this.groups[i]!;
447
366
  const isActive = i === this.activeTabIndex;
448
- const color = TAB_COLORS[i % TAB_COLORS.length]!;
367
+ const color = TAB_FG[i % TAB_FG.length]!;
368
+ const isLoading = this.groupLoading.get(g.id) ?? false;
369
+ const dot = isLoading ? this.fg("warning", "●") : "";
449
370
 
450
371
  if (isActive) {
451
- tabs.push(`${color}${ansi.bold} ${group.icon} ${group.name} ${ansi.reset}`);
372
+ tabs.push(this.fg(color, this.bold(` ${g.icon} ${g.name} ${dot}`)));
452
373
  } else {
453
- tabs.push(`${ansi.dim} ${group.icon} ${group.name} ${ansi.reset}`);
374
+ tabs.push(this.fg("dim", ` ${g.icon} ${g.name} ${dot}`));
454
375
  }
455
376
  }
456
-
457
- const tabStr = tabs.join(`${ansi.dim}│${ansi.reset}`);
458
- const visLen = visibleWidth(tabStr);
459
-
460
- // Truncate if too wide (shouldn't happen if maxTabs calculation is correct)
461
- if (visLen > width - 2) {
462
- return truncateToWidth(tabStr, width - 2);
463
- }
464
-
465
- return tabStr;
377
+ return tabs.join(this.fg("borderMuted", "│"));
466
378
  }
467
379
 
468
- /**
469
- * Render group content.
470
- */
471
380
  private renderGroupContent(width: number, group: InfoGroup, data: GroupData): string[] {
472
381
  const lines: string[] = [];
382
+ const isLoading = this.groupLoading.get(group.id) ?? false;
473
383
  const visibleStats = infoRegistry.getVisibleStats(group.id);
474
384
 
475
385
  if (visibleStats.length === 0) {
476
- lines.push(` ${ansi.dim}No stats configured for this group.${ansi.reset}`);
386
+ lines.push(` ${this.fg("dim", "No stats configured for this group.")}`);
387
+ return lines;
388
+ }
389
+
390
+ // If no data yet and loading, show placeholder per stat
391
+ if (Object.keys(data).length === 0 && isLoading) {
392
+ for (const stat of visibleStats) {
393
+ lines.push(` ${this.fg("dim", `${stat.label}:`)} ${this.fg("warning", "···")}`);
394
+ }
477
395
  return lines;
478
396
  }
479
397
 
480
- // Calculate label width for alignment
481
398
  const maxLabelLen = Math.max(...visibleStats.map((s) => s.label.length));
482
399
 
483
400
  for (const stat of visibleStats) {
@@ -486,16 +403,13 @@ export class InfoOverlay implements Component {
486
403
  const detail = statData?.detail;
487
404
 
488
405
  const label = `${stat.label}:`.padEnd(maxLabelLen + 1);
489
- let line = ` ${ansi.dim}${label}${ansi.reset} ${ansi.bold}${value}${ansi.reset}`;
406
+ let line = ` ${this.fg("dim", label)} ${this.bold(value)}`;
490
407
 
491
- // Handle multi-line detail
492
408
  if (detail) {
493
409
  const detailLines = detail.split("\n");
494
410
  if (detailLines.length === 1) {
495
- // Single line detail - show inline
496
- line += ` ${ansi.dim}(${detail})${ansi.reset}`;
411
+ line += ` ${this.fg("dim", `(${detail})`)}`;
497
412
  } else {
498
- // Multiple lines - show value on first line, details indented below
499
413
  lines.push(line);
500
414
  for (const dLine of detailLines) {
501
415
  const indent = " ".repeat(maxLabelLen + 4);
@@ -509,7 +423,6 @@ export class InfoOverlay implements Component {
509
423
  }
510
424
  }
511
425
 
512
- // Truncate if too wide
513
426
  if (visibleWidth(line) > width - 2) {
514
427
  line = truncateToWidth(line, width - 2);
515
428
  }
@@ -520,38 +433,50 @@ export class InfoOverlay implements Component {
520
433
  return lines;
521
434
  }
522
435
 
523
- /**
524
- * Render footer with navigation hints and scroll indicator.
525
- */
526
- private renderFooterWithScroll(width: number, totalLines: number, visibleHeight: number): string {
527
- // Left side: scroll indicator
436
+ private renderFooter(width: number, totalLines: number, visibleHeight: number): string {
528
437
  const hasScroll = totalLines > visibleHeight;
529
438
  let scrollStr = "";
530
439
  if (hasScroll) {
531
- scrollStr = `${ansi.dim}${this.scrollOffset + 1}-${Math.min(this.scrollOffset + visibleHeight, totalLines)}/${totalLines}${ansi.reset}`;
440
+ scrollStr = this.fg("dim", `${this.scrollOffset + 1}-${Math.min(this.scrollOffset + visibleHeight, totalLines)}/${totalLines}`);
532
441
  }
533
442
 
534
- // Right side: navigation hints
443
+ // Last updated for active group
444
+ const group = this.groups[this.activeTabIndex];
445
+ const lastUp = infoRegistry.getLastUpdated(group?.id ?? "");
446
+ const age = lastUp > 0 ? humanizeAge(Date.now() - lastUp) : "loading…";
447
+
535
448
  const hints = [
536
- `${ansi.cyan}←/→${ansi.reset} tabs`,
537
- `${ansi.green}↑/↓${ansi.reset} scroll`,
538
- `${ansi.red}q/Esc${ansi.reset} close`,
449
+ `${this.fg("accent", "←/→")} tabs`,
450
+ `${this.fg("success", "↑/↓")} scroll`,
451
+ `${this.fg("warning", "r")} refresh`,
452
+ `${this.fg("error", "q/Esc")} close`,
539
453
  ];
540
454
 
541
- const hintStr = hints.join(` ${ansi.dim}•${ansi.reset} `);
542
- const hintWidth = visibleWidth(hintStr);
543
- const scrollWidth = visibleWidth(scrollStr);
455
+ const hintStr = hints.join(` ${this.fg("borderMuted", "•")} `);
456
+
457
+ // Build right side: age + hints
458
+ const ageStr = this.fg("dim", `⏱ ${age}`);
459
+ const rightStr = `${ageStr} ${this.fg("borderMuted", "│")} ${hintStr}`;
544
460
 
545
- // Calculate spacing
461
+ const scrollW = visibleWidth(scrollStr);
462
+ const rightW = visibleWidth(rightStr);
546
463
  const gap = 4;
547
- const totalWidth = scrollWidth + gap + hintWidth;
548
-
549
- if (totalWidth >= width - 2) {
550
- // Too wide, just show hints
551
- return truncateToWidth(hintStr, width - 2);
464
+ const totalW = scrollW + gap + rightW;
465
+
466
+ if (totalW >= width - 2) {
467
+ return truncateToWidth(rightStr, width - 2);
552
468
  }
553
469
 
554
- const padding = " ".repeat(width - 2 - totalWidth);
555
- return scrollStr + padding + hintStr;
470
+ const padding = " ".repeat(Math.max(0, width - 2 - totalW));
471
+ return scrollStr + padding + rightStr;
472
+ }
473
+
474
+ private wrapLines(lines: string[], innerWidth: number): string[] {
475
+ const wrapped: string[] = [];
476
+ for (const line of lines) {
477
+ if (!line) { wrapped.push(""); continue; }
478
+ wrapped.push(...wrapTextWithAnsi(line, Math.max(1, innerWidth)));
479
+ }
480
+ return wrapped;
556
481
  }
557
482
  }