@pi-unipi/info-screen 0.1.1

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.
@@ -0,0 +1,406 @@
1
+ /**
2
+ * @pi-unipi/info-screen — TUI Overlay Component
3
+ *
4
+ * Main dashboard overlay with tabbed navigation.
5
+ * Displays registered groups as tabs with their stats.
6
+ */
7
+
8
+ import type { Component } from "@mariozechner/pi-tui";
9
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
10
+ import { infoRegistry } from "../registry.js";
11
+ import type { InfoGroup, GroupData, StatData } from "../types.js";
12
+
13
+ /** ANSI escape codes */
14
+ const ansi = {
15
+ reset: "\x1b[0m",
16
+ bold: "\x1b[1m",
17
+ dim: "\x1b[2m",
18
+ underline: "\x1b[4m",
19
+ // Colors
20
+ blue: "\x1b[34m",
21
+ cyan: "\x1b[36m",
22
+ green: "\x1b[32m",
23
+ yellow: "\x1b[33m",
24
+ magenta: "\x1b[35m",
25
+ white: "\x1b[37m",
26
+ red: "\x1b[31m",
27
+ gray: "\x1b[90m",
28
+ };
29
+
30
+ /** Tab color palette */
31
+ const TAB_COLORS = [
32
+ ansi.cyan,
33
+ ansi.green,
34
+ ansi.yellow,
35
+ ansi.magenta,
36
+ ansi.blue,
37
+ ];
38
+
39
+ /**
40
+ * Info overlay component with tabbed navigation.
41
+ */
42
+ export class InfoOverlay implements Component {
43
+ private groups: InfoGroup[] = [];
44
+ private activeTabIndex = 0;
45
+ private groupData = new Map<string, GroupData>();
46
+ private loading = true;
47
+ private error: string | null = null;
48
+ private scrollOffset = 0;
49
+ /** Callback when overlay should close */
50
+ onClose?: () => void;
51
+
52
+ constructor() {
53
+ this.loadData();
54
+ }
55
+
56
+ /**
57
+ * Invalidate cached render state.
58
+ */
59
+ invalidate(): void {
60
+ // No cached state to invalidate
61
+ }
62
+
63
+ /**
64
+ * Load data for all groups.
65
+ */
66
+ private async loadData(): Promise<void> {
67
+ this.loading = true;
68
+ // Always re-fetch ALL groups to catch late registrations
69
+ this.groups = infoRegistry.getAllGroups();
70
+
71
+ try {
72
+ // Load data for all groups in parallel
73
+ const promises = this.groups.map(async (group) => {
74
+ const data = await infoRegistry.getGroupData(group.id);
75
+ this.groupData.set(group.id, data);
76
+ });
77
+
78
+ await Promise.all(promises);
79
+ } catch (error) {
80
+ this.error = error instanceof Error ? error.message : String(error);
81
+ }
82
+
83
+ this.loading = false;
84
+ }
85
+
86
+ /**
87
+ * Handle keyboard input.
88
+ */
89
+ handleInput(data: string): void {
90
+ if (this.loading) return;
91
+
92
+ // Arrow keys for tab navigation
93
+ if (data === "\x1b[C" || data === "l") {
94
+ // Right arrow - switch tab
95
+ this.activeTabIndex = (this.activeTabIndex + 1) % this.groups.length;
96
+ this.scrollOffset = 0; // Reset scroll on tab switch
97
+ } else if (data === "\x1b[D" || data === "h") {
98
+ // Left arrow - switch tab
99
+ this.activeTabIndex = (this.activeTabIndex - 1 + this.groups.length) % this.groups.length;
100
+ this.scrollOffset = 0; // Reset scroll on tab switch
101
+ } else if (data === "\x1b[B" || data === "j") {
102
+ // Down arrow - scroll down
103
+ this.scrollOffset++;
104
+ } else if (data === "\x1b[A" || data === "k") {
105
+ // Up arrow - scroll up
106
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
107
+ } else if (data === "g") {
108
+ // g - go to top
109
+ this.scrollOffset = 0;
110
+ } else if (data === "G") {
111
+ // G - go to bottom (will be clamped in render)
112
+ this.scrollOffset = Infinity;
113
+ } else if (data === "q" || data === "\x1b") {
114
+ // q or Escape - close overlay
115
+ this.onClose?.();
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Render the component.
121
+ */
122
+ render(width: number): string[] {
123
+ if (this.loading) {
124
+ return this.renderLoading(width);
125
+ }
126
+
127
+ if (this.error) {
128
+ return this.renderError(width);
129
+ }
130
+
131
+ // Check for new groups (but don't re-trigger loading)
132
+ const allGroups = infoRegistry.getAllGroups();
133
+ const groupIds = allGroups.map(g => g.id).join(",");
134
+ const currentIds = this.groups.map(g => g.id).join(",");
135
+
136
+ if (groupIds !== currentIds) {
137
+ this.groups = allGroups;
138
+ // Load data for any new groups (non-blocking)
139
+ this.loadDataForNewGroups(allGroups);
140
+ }
141
+
142
+ if (this.groups.length === 0) {
143
+ return this.renderEmpty(width);
144
+ }
145
+
146
+ return this.renderDashboard(width);
147
+ }
148
+
149
+ /**
150
+ * Load data for groups we don't have data for yet.
151
+ */
152
+ private async loadDataForNewGroups(groups: InfoGroup[]): Promise<void> {
153
+ for (const group of groups) {
154
+ if (!this.groupData.has(group.id)) {
155
+ try {
156
+ const data = await infoRegistry.getGroupData(group.id);
157
+ this.groupData.set(group.id, data);
158
+ } catch {
159
+ // Silently skip groups with errors
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Render loading state.
167
+ */
168
+ private renderLoading(width: number): string[] {
169
+ const lines: string[] = [];
170
+ const padding = " ".repeat(Math.max(0, Math.floor((width - 20) / 2)));
171
+
172
+ lines.push("");
173
+ lines.push(`${padding}${ansi.cyan}${ansi.bold}📊 UniPi Info Screen${ansi.reset}`);
174
+ lines.push("");
175
+ lines.push(`${padding}${ansi.dim}Loading dashboard...${ansi.reset}`);
176
+ lines.push("");
177
+
178
+ return lines;
179
+ }
180
+
181
+ /**
182
+ * Render error state.
183
+ */
184
+ private renderError(width: number): string[] {
185
+ const lines: string[] = [];
186
+ const padding = " ".repeat(Math.max(0, Math.floor((width - 20) / 2)));
187
+
188
+ lines.push("");
189
+ lines.push(`${padding}${ansi.yellow}${ansi.bold}⚠️ Error${ansi.reset}`);
190
+ lines.push(`${padding}${ansi.dim}${this.error ?? "Unknown error"}${ansi.reset}`);
191
+ lines.push("");
192
+
193
+ return lines;
194
+ }
195
+
196
+ /**
197
+ * Render empty state.
198
+ */
199
+ private renderEmpty(width: number): string[] {
200
+ const lines: string[] = [];
201
+ const padding = " ".repeat(Math.max(0, Math.floor((width - 30) / 2)));
202
+
203
+ lines.push("");
204
+ lines.push(`${padding}${ansi.cyan}${ansi.bold}📊 UniPi Info Screen${ansi.reset}`);
205
+ lines.push("");
206
+ lines.push(`${padding}${ansi.dim}No groups registered.${ansi.reset}`);
207
+ lines.push(`${padding}${ansi.dim}Modules will register groups on startup.${ansi.reset}`);
208
+ lines.push("");
209
+
210
+ return lines;
211
+ }
212
+
213
+ /**
214
+ * Pad a line to fill a target visual width.
215
+ */
216
+ private padToWidth(line: string, targetWidth: number): string {
217
+ const visLen = visibleWidth(line);
218
+ const pad = Math.max(0, targetWidth - visLen);
219
+ return line + " ".repeat(pad);
220
+ }
221
+
222
+ /**
223
+ * Render the full dashboard.
224
+ */
225
+ private renderDashboard(width: number): string[] {
226
+ const lines: string[] = [];
227
+ const group = this.groups[this.activeTabIndex];
228
+ const data = this.groupData.get(group.id) ?? {};
229
+ // Inner width for content (subtract 2 for left+right borders)
230
+ // Subtract extra 1 to prevent right border clipping on some terminals
231
+ const innerWidth = width - 3;
232
+
233
+ // Top border
234
+ lines.push(`${ansi.dim}╭${"─".repeat(innerWidth + 1)}╮${ansi.reset}`);
235
+
236
+ // Header
237
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderHeader(innerWidth, group), innerWidth)}${ansi.dim} │${ansi.reset}`);
238
+ lines.push(`${ansi.dim}├${"─".repeat(innerWidth + 1)}┤${ansi.reset}`);
239
+
240
+ // Tab bar
241
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderTabBar(innerWidth), innerWidth)}${ansi.dim} │${ansi.reset}`);
242
+ lines.push(`${ansi.dim}├${"─".repeat(innerWidth + 1)}┤${ansi.reset}`);
243
+
244
+ // Content with scrolling
245
+ const contentLines = this.renderGroupContent(innerWidth, group, data);
246
+ const maxVisibleLines = 15; // Max content lines visible
247
+
248
+ // Clamp scroll offset
249
+ const maxScroll = Math.max(0, contentLines.length - maxVisibleLines);
250
+ this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
251
+
252
+ // Get visible slice
253
+ const visibleContent = contentLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines);
254
+
255
+ for (const line of visibleContent) {
256
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(line, innerWidth)}${ansi.dim} │${ansi.reset}`);
257
+ }
258
+
259
+ // Show scroll indicator if needed
260
+ if (contentLines.length > maxVisibleLines) {
261
+ const scrollInfo = ` ${this.scrollOffset + 1}-${Math.min(this.scrollOffset + maxVisibleLines, contentLines.length)}/${contentLines.length} `;
262
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(scrollInfo, innerWidth)}${ansi.dim} │${ansi.reset}`);
263
+ }
264
+
265
+ // Footer
266
+ const hasScroll = contentLines.length > maxVisibleLines;
267
+ lines.push(`${ansi.dim}├${"─".repeat(innerWidth + 1)}┤${ansi.reset}`);
268
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderFooter(innerWidth, hasScroll), innerWidth)}${ansi.dim} │${ansi.reset}`);
269
+ lines.push(`${ansi.dim}╰${"─".repeat(innerWidth + 1)}╯${ansi.reset}`);
270
+
271
+ return lines;
272
+ }
273
+
274
+ /**
275
+ * Render header with title and group info.
276
+ */
277
+ private renderHeader(width: number, group: InfoGroup): string {
278
+ const title = `${group.icon} ${group.name}`;
279
+ const paddedTitle = ` ${title} `;
280
+ const visLen = visibleWidth(paddedTitle);
281
+
282
+ if (visLen >= width - 4) {
283
+ return ansi.bold + truncateToWidth(paddedTitle, width - 4) + ansi.reset;
284
+ }
285
+
286
+ // Center the title
287
+ const leftPad = Math.floor((width - visLen) / 2);
288
+
289
+ return " ".repeat(leftPad) + ansi.bold + paddedTitle + ansi.reset;
290
+ }
291
+
292
+ /**
293
+ * Render tab bar.
294
+ */
295
+ private renderTabBar(width: number): string {
296
+ const tabs: string[] = [];
297
+
298
+ for (let i = 0; i < this.groups.length; i++) {
299
+ const group = this.groups[i];
300
+ const isActive = i === this.activeTabIndex;
301
+ const color = TAB_COLORS[i % TAB_COLORS.length];
302
+
303
+ if (isActive) {
304
+ tabs.push(`${color}${ansi.bold} ${group.icon} ${group.name} ${ansi.reset}`);
305
+ } else {
306
+ tabs.push(`${ansi.dim} ${group.icon} ${group.name} ${ansi.reset}`);
307
+ }
308
+ }
309
+
310
+ const tabStr = tabs.join(`${ansi.dim}│${ansi.reset}`);
311
+ const visLen = visibleWidth(tabStr);
312
+
313
+ // Truncate if too wide
314
+ if (visLen > width - 2) {
315
+ return truncateToWidth(tabStr, width - 2);
316
+ }
317
+
318
+ return tabStr;
319
+ }
320
+
321
+ /**
322
+ * Render a separator line.
323
+ */
324
+ private renderSeparator(width: number): string {
325
+ return ansi.dim + "─".repeat(width) + ansi.reset;
326
+ }
327
+
328
+ /**
329
+ * Render group content.
330
+ */
331
+ private renderGroupContent(width: number, group: InfoGroup, data: GroupData): string[] {
332
+ const lines: string[] = [];
333
+ const visibleStats = infoRegistry.getVisibleStats(group.id);
334
+
335
+ if (visibleStats.length === 0) {
336
+ lines.push(` ${ansi.dim}No stats configured for this group.${ansi.reset}`);
337
+ return lines;
338
+ }
339
+
340
+ // Calculate label width for alignment
341
+ const maxLabelLen = Math.max(...visibleStats.map((s) => s.label.length));
342
+
343
+ for (const stat of visibleStats) {
344
+ const statData = data[stat.id];
345
+ const value = statData?.value ?? "—";
346
+ const detail = statData?.detail;
347
+
348
+ const label = `${stat.label}:`.padEnd(maxLabelLen + 1);
349
+ let line = ` ${ansi.dim}${label}${ansi.reset} ${ansi.bold}${value}${ansi.reset}`;
350
+
351
+ // Handle multi-line detail
352
+ if (detail) {
353
+ const detailLines = detail.split("\n");
354
+ if (detailLines.length === 1) {
355
+ // Single line detail - show inline
356
+ line += ` ${ansi.dim}(${detail})${ansi.reset}`;
357
+ } else {
358
+ // Multiple lines - show value on first line, details indented below
359
+ lines.push(line);
360
+ for (const dLine of detailLines) {
361
+ const indent = " ".repeat(maxLabelLen + 4);
362
+ let detailLine = `${indent}${dLine}`;
363
+ if (visibleWidth(detailLine) > width - 2) {
364
+ detailLine = truncateToWidth(detailLine, width - 2);
365
+ }
366
+ lines.push(detailLine);
367
+ }
368
+ continue;
369
+ }
370
+ }
371
+
372
+ // Truncate if too wide
373
+ if (visibleWidth(line) > width - 2) {
374
+ line = truncateToWidth(line, width - 2);
375
+ }
376
+
377
+ lines.push(line);
378
+ }
379
+
380
+ return lines;
381
+ }
382
+
383
+ /**
384
+ * Render footer with navigation hints.
385
+ */
386
+ private renderFooter(width: number, hasScroll?: boolean): string {
387
+ const hints = [
388
+ `${ansi.cyan}←/→${ansi.reset} tabs`,
389
+ ];
390
+
391
+ hints.push(`${ansi.green}↑/↓${ansi.reset} scroll`);
392
+ hints.push(`${ansi.yellow}g/G${ansi.reset} top/bottom`);
393
+ hints.push(`${ansi.red}q/Esc${ansi.reset} close`);
394
+
395
+ const hintStr = hints.join(` ${ansi.dim}•${ansi.reset} `);
396
+ const visLen = visibleWidth(hintStr);
397
+
398
+ if (visLen >= width - 4) {
399
+ return truncateToWidth(hintStr, width - 4);
400
+ }
401
+
402
+ const leftPad = Math.floor((width - visLen) / 2);
403
+
404
+ return " ".repeat(leftPad) + hintStr;
405
+ }
406
+ }
package/types.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @pi-unipi/info-screen — Type definitions
3
+ */
4
+
5
+ /** A single stat within a group */
6
+ export interface InfoStat {
7
+ /** Stat identifier */
8
+ id: string;
9
+ /** Display label */
10
+ label: string;
11
+ /** Whether to show by default */
12
+ show: boolean;
13
+ }
14
+
15
+ /** Configuration for a group's display */
16
+ export interface GroupConfig {
17
+ /** Whether group is shown by default */
18
+ showByDefault: boolean;
19
+ /** Stats within this group */
20
+ stats: InfoStat[];
21
+ }
22
+
23
+ /** Data for a single stat */
24
+ export interface StatData {
25
+ /** Display value */
26
+ value: string;
27
+ /** Optional detail text */
28
+ detail?: string;
29
+ }
30
+
31
+ /** Data returned by a group's data provider */
32
+ export type GroupData = Record<string, StatData>;
33
+
34
+ /** Registration for an info group */
35
+ export interface InfoGroup {
36
+ /** Unique group identifier */
37
+ id: string;
38
+ /** Display name */
39
+ name: string;
40
+ /** Icon emoji */
41
+ icon: string;
42
+ /** Priority for tab ordering (lower = earlier) */
43
+ priority: number;
44
+ /** Group configuration */
45
+ config: GroupConfig;
46
+ /** Async data provider */
47
+ dataProvider: () => Promise<GroupData>;
48
+ }
49
+
50
+ /** Settings for info-screen in settings.json */
51
+ export interface InfoScreenSettings {
52
+ /** Whether to show dashboard on boot */
53
+ showOnBoot: boolean;
54
+ /** Timeout in ms waiting for modules at boot */
55
+ bootTimeoutMs: number;
56
+ /** Per-group settings */
57
+ groups: Record<string, GroupSettings>;
58
+ }
59
+
60
+ /** Settings for a single group */
61
+ export interface GroupSettings {
62
+ /** Whether group is visible */
63
+ show: boolean;
64
+ /** Per-stat visibility overrides */
65
+ stats?: Record<string, boolean>;
66
+ }
67
+
68
+ /** Default settings */
69
+ export const DEFAULT_SETTINGS: InfoScreenSettings = {
70
+ showOnBoot: true,
71
+ bootTimeoutMs: 2000,
72
+ groups: {},
73
+ };