@pi-unipi/info-screen 0.1.1 → 0.1.2

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/index.ts CHANGED
@@ -99,18 +99,20 @@ export default function (pi: ExtensionAPI) {
99
99
  // Wait for other modules to announce
100
100
  await waitForModules();
101
101
 
102
- // Show the overlay
102
+ // Show the overlay using three-method object pattern
103
103
  ctx.ui.custom(
104
104
  (tui, _theme, _keybindings, done) => {
105
105
  const overlay = new InfoOverlay();
106
106
  overlay.onClose = () => done(undefined);
107
- // Wrap handleInput to trigger re-render after state changes
108
- const originalHandleInput = overlay.handleInput?.bind(overlay);
109
- overlay.handleInput = (data: string) => {
110
- originalHandleInput?.(data);
111
- tui.requestRender();
107
+ // Return three-method object as per pi-tui docs
108
+ return {
109
+ render: (w: number) => overlay.render(w),
110
+ invalidate: () => overlay.invalidate(),
111
+ handleInput: (data: string) => {
112
+ overlay.handleInput?.(data);
113
+ tui.requestRender();
114
+ },
112
115
  };
113
- return overlay;
114
116
  },
115
117
  {
116
118
  overlay: true,
@@ -141,12 +143,14 @@ export default function (pi: ExtensionAPI) {
141
143
  (tui, _theme, _keybindings, done) => {
142
144
  const overlay = new InfoOverlay();
143
145
  overlay.onClose = () => done(undefined);
144
- const originalHandleInput = overlay.handleInput?.bind(overlay);
145
- overlay.handleInput = (data: string) => {
146
- originalHandleInput?.(data);
147
- tui.requestRender();
146
+ return {
147
+ render: (w: number) => overlay.render(w),
148
+ invalidate: () => overlay.invalidate(),
149
+ handleInput: (data: string) => {
150
+ overlay.handleInput?.(data);
151
+ tui.requestRender();
152
+ },
148
153
  };
149
- return overlay;
150
154
  },
151
155
  {
152
156
  overlay: true,
@@ -169,12 +173,14 @@ export default function (pi: ExtensionAPI) {
169
173
  (tui, _theme, _keybindings, done) => {
170
174
  const overlay = new SettingsOverlay();
171
175
  overlay.onClose = () => done(undefined);
172
- const originalHandleInput = overlay.handleInput?.bind(overlay);
173
- overlay.handleInput = (data: string) => {
174
- originalHandleInput?.(data);
175
- tui.requestRender();
176
+ return {
177
+ render: (w: number) => overlay.render(w),
178
+ invalidate: () => overlay.invalidate(),
179
+ handleInput: (data: string) => {
180
+ overlay.handleInput?.(data);
181
+ tui.requestRender();
182
+ },
176
183
  };
177
- return overlay;
178
184
  },
179
185
  {
180
186
  overlay: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/info-screen",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Dashboard and module registry for Unipi — configurable info overlay with tabbed groups",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,6 +47,15 @@ export class SettingsOverlay implements Component {
47
47
  name: g.name,
48
48
  icon: g.icon,
49
49
  }));
50
+ // Apply saved order if exists
51
+ if (this.settings.groupOrder) {
52
+ const order = this.settings.groupOrder;
53
+ this.groups.sort((a, b) => {
54
+ const ai = order.indexOf(a.id);
55
+ const bi = order.indexOf(b.id);
56
+ return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
57
+ });
58
+ }
50
59
  }
51
60
 
52
61
  /**
@@ -88,6 +97,12 @@ export class SettingsOverlay implements Component {
88
97
  case "l":
89
98
  this.enterStatsMode(this.groups[this.selectedIndex].id);
90
99
  break;
100
+ case "J": // Shift+J - move group down
101
+ this.moveGroupDown();
102
+ break;
103
+ case "K": // Shift+K - move group up
104
+ this.moveGroupUp();
105
+ break;
91
106
  case "q": // Quit
92
107
  case "\x1b": // Escape
93
108
  this.onClose?.();
@@ -166,6 +181,42 @@ export class SettingsOverlay implements Component {
166
181
  this.selectedIndex = 0;
167
182
  }
168
183
 
184
+ /**
185
+ * Move selected group up in order.
186
+ */
187
+ private moveGroupUp(): void {
188
+ if (this.selectedIndex <= 0) return;
189
+ const i = this.selectedIndex;
190
+ // Swap with previous
191
+ const temp = this.groups[i]!;
192
+ this.groups[i] = this.groups[i - 1]!;
193
+ this.groups[i - 1] = temp;
194
+ this.selectedIndex--;
195
+ this.saveGroupOrder();
196
+ }
197
+
198
+ /**
199
+ * Move selected group down in order.
200
+ */
201
+ private moveGroupDown(): void {
202
+ if (this.selectedIndex >= this.groups.length - 1) return;
203
+ const i = this.selectedIndex;
204
+ // Swap with next
205
+ const temp = this.groups[i]!;
206
+ this.groups[i] = this.groups[i + 1]!;
207
+ this.groups[i + 1] = temp;
208
+ this.selectedIndex++;
209
+ this.saveGroupOrder();
210
+ }
211
+
212
+ /**
213
+ * Save group order to settings.
214
+ */
215
+ private saveGroupOrder(): void {
216
+ this.settings.groupOrder = this.groups.map((g) => g.id);
217
+ saveInfoSettings(this.settings);
218
+ }
219
+
169
220
  /**
170
221
  * Render the component.
171
222
  */
@@ -218,7 +269,7 @@ export class SettingsOverlay implements Component {
218
269
  // Footer
219
270
  lines.push("");
220
271
  lines.push(ansi.dim + "─".repeat(width) + ansi.reset);
221
- lines.push(this.renderCentered(`${ansi.dim}↑↓ select Space toggle → stats q close${ansi.reset}`, width));
272
+ lines.push(this.renderCentered(`${ansi.dim}↑↓ select Space toggle → stats J/K reorder q close${ansi.reset}`, width));
222
273
  lines.push("");
223
274
 
224
275
  return lines;
@@ -8,7 +8,8 @@
8
8
  import type { Component } from "@mariozechner/pi-tui";
9
9
  import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
10
10
  import { infoRegistry } from "../registry.js";
11
- import type { InfoGroup, GroupData, StatData } from "../types.js";
11
+ import { getInfoSettings } from "../config.js";
12
+ import type { InfoGroup, GroupData } from "../types.js";
12
13
 
13
14
  /** ANSI escape codes */
14
15
  const ansi = {
@@ -25,6 +26,9 @@ const ansi = {
25
26
  white: "\x1b[37m",
26
27
  red: "\x1b[31m",
27
28
  gray: "\x1b[90m",
29
+ // Backgrounds
30
+ bgDarkGray: "\x1b[48;5;235m",
31
+ bgDarkerGray: "\x1b[48;5;233m",
28
32
  };
29
33
 
30
34
  /** Tab color palette */
@@ -46,6 +50,8 @@ export class InfoOverlay implements Component {
46
50
  private loading = true;
47
51
  private error: string | null = null;
48
52
  private scrollOffset = 0;
53
+ /** Tab scroll offset for horizontal scrolling */
54
+ private tabScrollOffset = 0;
49
55
  /** Callback when overlay should close */
50
56
  onClose?: () => void;
51
57
 
@@ -67,6 +73,17 @@ export class InfoOverlay implements Component {
67
73
  this.loading = true;
68
74
  // Always re-fetch ALL groups to catch late registrations
69
75
  this.groups = infoRegistry.getAllGroups();
76
+
77
+ // Apply saved order from settings
78
+ const settings = getInfoSettings();
79
+ if (settings.groupOrder && settings.groupOrder.length > 0) {
80
+ const order = settings.groupOrder;
81
+ this.groups.sort((a, b) => {
82
+ const ai = order.indexOf(a.id);
83
+ const bi = order.indexOf(b.id);
84
+ return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
85
+ });
86
+ }
70
87
 
71
88
  try {
72
89
  // Load data for all groups in parallel
@@ -94,10 +111,12 @@ export class InfoOverlay implements Component {
94
111
  // Right arrow - switch tab
95
112
  this.activeTabIndex = (this.activeTabIndex + 1) % this.groups.length;
96
113
  this.scrollOffset = 0; // Reset scroll on tab switch
114
+ this.ensureTabVisible();
97
115
  } else if (data === "\x1b[D" || data === "h") {
98
116
  // Left arrow - switch tab
99
117
  this.activeTabIndex = (this.activeTabIndex - 1 + this.groups.length) % this.groups.length;
100
118
  this.scrollOffset = 0; // Reset scroll on tab switch
119
+ this.ensureTabVisible();
101
120
  } else if (data === "\x1b[B" || data === "j") {
102
121
  // Down arrow - scroll down
103
122
  this.scrollOffset++;
@@ -116,6 +135,14 @@ export class InfoOverlay implements Component {
116
135
  }
117
136
  }
118
137
 
138
+ /**
139
+ * Ensure active tab is visible in the tab bar (horizontal scroll).
140
+ */
141
+ private ensureTabVisible(): void {
142
+ // Tab bar shows ~maxTabsVisible tabs, centered around active
143
+ // This is handled in renderTabBar
144
+ }
145
+
119
146
  /**
120
147
  * Render the component.
121
148
  */
@@ -135,6 +162,16 @@ export class InfoOverlay implements Component {
135
162
 
136
163
  if (groupIds !== currentIds) {
137
164
  this.groups = allGroups;
165
+ // Apply saved order
166
+ const settings = getInfoSettings();
167
+ if (settings.groupOrder && settings.groupOrder.length > 0) {
168
+ const order = settings.groupOrder;
169
+ this.groups.sort((a, b) => {
170
+ const ai = order.indexOf(a.id);
171
+ const bi = order.indexOf(b.id);
172
+ return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
173
+ });
174
+ }
138
175
  // Load data for any new groups (non-blocking)
139
176
  this.loadDataForNewGroups(allGroups);
140
177
  }
@@ -169,11 +206,9 @@ export class InfoOverlay implements Component {
169
206
  const lines: string[] = [];
170
207
  const padding = " ".repeat(Math.max(0, Math.floor((width - 20) / 2)));
171
208
 
172
- lines.push("");
173
209
  lines.push(`${padding}${ansi.cyan}${ansi.bold}📊 UniPi Info Screen${ansi.reset}`);
174
210
  lines.push("");
175
211
  lines.push(`${padding}${ansi.dim}Loading dashboard...${ansi.reset}`);
176
- lines.push("");
177
212
 
178
213
  return lines;
179
214
  }
@@ -185,10 +220,8 @@ export class InfoOverlay implements Component {
185
220
  const lines: string[] = [];
186
221
  const padding = " ".repeat(Math.max(0, Math.floor((width - 20) / 2)));
187
222
 
188
- lines.push("");
189
223
  lines.push(`${padding}${ansi.yellow}${ansi.bold}⚠️ Error${ansi.reset}`);
190
224
  lines.push(`${padding}${ansi.dim}${this.error ?? "Unknown error"}${ansi.reset}`);
191
- lines.push("");
192
225
 
193
226
  return lines;
194
227
  }
@@ -200,22 +233,23 @@ export class InfoOverlay implements Component {
200
233
  const lines: string[] = [];
201
234
  const padding = " ".repeat(Math.max(0, Math.floor((width - 30) / 2)));
202
235
 
203
- lines.push("");
204
236
  lines.push(`${padding}${ansi.cyan}${ansi.bold}📊 UniPi Info Screen${ansi.reset}`);
205
237
  lines.push("");
206
238
  lines.push(`${padding}${ansi.dim}No groups registered.${ansi.reset}`);
207
239
  lines.push(`${padding}${ansi.dim}Modules will register groups on startup.${ansi.reset}`);
208
- lines.push("");
209
240
 
210
241
  return lines;
211
242
  }
212
243
 
213
244
  /**
214
- * Pad a line to fill a target visual width.
245
+ * Pad a line to fill a target visual width with background.
215
246
  */
216
- private padToWidth(line: string, targetWidth: number): string {
247
+ private padToWidth(line: string, targetWidth: number, bg?: string): string {
217
248
  const visLen = visibleWidth(line);
218
249
  const pad = Math.max(0, targetWidth - visLen);
250
+ if (bg) {
251
+ return bg + line + " ".repeat(pad) + ansi.reset;
252
+ }
219
253
  return line + " ".repeat(pad);
220
254
  }
221
255
 
@@ -227,19 +261,22 @@ export class InfoOverlay implements Component {
227
261
  const group = this.groups[this.activeTabIndex];
228
262
  const data = this.groupData.get(group.id) ?? {};
229
263
  // 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;
264
+ const innerWidth = width - 2;
265
+
266
+ // Background colors
267
+ const bgHeader = ansi.bgDarkGray;
268
+ const bgContent = ansi.bgDarkerGray;
232
269
 
233
270
  // Top border
234
- lines.push(`${ansi.dim}╭${"─".repeat(innerWidth + 1)}╮${ansi.reset}`);
271
+ lines.push(`${ansi.dim}╭${"─".repeat(innerWidth)}╮${ansi.reset}`);
235
272
 
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}`);
273
+ // Header with background
274
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderHeader(innerWidth, group), innerWidth, bgHeader)}${ansi.dim}│${ansi.reset}`);
275
+ lines.push(`${ansi.dim}├${"─".repeat(innerWidth)}┤${ansi.reset}`);
239
276
 
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}`);
277
+ // Tab bar with horizontal scrolling
278
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderTabBar(innerWidth), innerWidth, bgHeader)}${ansi.dim}│${ansi.reset}`);
279
+ lines.push(`${ansi.dim}├${"─".repeat(innerWidth)}┤${ansi.reset}`);
243
280
 
244
281
  // Content with scrolling
245
282
  const contentLines = this.renderGroupContent(innerWidth, group, data);
@@ -253,20 +290,20 @@ export class InfoOverlay implements Component {
253
290
  const visibleContent = contentLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines);
254
291
 
255
292
  for (const line of visibleContent) {
256
- lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(line, innerWidth)}${ansi.dim} │${ansi.reset}`);
293
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(line, innerWidth, bgContent)}${ansi.dim}│${ansi.reset}`);
257
294
  }
258
295
 
259
296
  // Show scroll indicator if needed
260
297
  if (contentLines.length > maxVisibleLines) {
261
298
  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}`);
299
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(scrollInfo, innerWidth, bgContent)}${ansi.dim}│${ansi.reset}`);
263
300
  }
264
301
 
265
302
  // Footer
266
303
  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}`);
304
+ lines.push(`${ansi.dim}├${"─".repeat(innerWidth)}┤${ansi.reset}`);
305
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderFooter(innerWidth, hasScroll), innerWidth, bgHeader)}${ansi.dim}│${ansi.reset}`);
306
+ lines.push(`${ansi.dim}╰${"─".repeat(innerWidth)}╯${ansi.reset}`);
270
307
 
271
308
  return lines;
272
309
  }
@@ -290,15 +327,78 @@ export class InfoOverlay implements Component {
290
327
  }
291
328
 
292
329
  /**
293
- * Render tab bar.
330
+ * Render tab bar with horizontal scrolling.
331
+ * When tabs overflow, slides to keep active tab visible.
294
332
  */
295
333
  private renderTabBar(width: number): string {
334
+ if (this.groups.length === 0) return "";
335
+
336
+ // Calculate tab widths
337
+ const tabWidths = this.groups.map(g => visibleWidth(` ${g.icon} ${g.name} `));
338
+ const separatorWidth = visibleWidth(`${ansi.dim}│${ansi.reset}`);
339
+
340
+ // Find how many tabs fit
341
+ let maxTabs = 0;
342
+ let totalWidth = 0;
343
+ for (let i = 0; i < this.groups.length; i++) {
344
+ const tabW = tabWidths[i]!;
345
+ const sepW = i > 0 ? separatorWidth : 0;
346
+ if (totalWidth + sepW + tabW > width - 2) break;
347
+ totalWidth += sepW + tabW;
348
+ maxTabs = i + 1;
349
+ }
350
+
351
+ // If all tabs fit, show all
352
+ if (maxTabs >= this.groups.length) {
353
+ return this.renderAllTabs(width);
354
+ }
355
+
356
+ // Calculate scroll offset to keep active tab visible
357
+ // Center the active tab in the visible window
358
+ const halfVisible = Math.floor(maxTabs / 2);
359
+ let startIdx = this.activeTabIndex - halfVisible;
360
+ startIdx = Math.max(0, Math.min(startIdx, this.groups.length - maxTabs));
361
+
362
+ // Build visible tabs
363
+ const tabs: string[] = [];
364
+ for (let i = startIdx; i < startIdx + maxTabs && i < this.groups.length; i++) {
365
+ const group = this.groups[i]!;
366
+ const isActive = i === this.activeTabIndex;
367
+ const color = TAB_COLORS[i % TAB_COLORS.length]!;
368
+
369
+ if (isActive) {
370
+ tabs.push(`${color}${ansi.bold} ${group.icon} ${group.name} ${ansi.reset}`);
371
+ } else {
372
+ tabs.push(`${ansi.dim} ${group.icon} ${group.name} ${ansi.reset}`);
373
+ }
374
+ }
375
+
376
+ const tabStr = tabs.join(`${ansi.dim}│${ansi.reset}`);
377
+
378
+ // Add scroll indicators
379
+ const hasLeft = startIdx > 0;
380
+ const hasRight = startIdx + maxTabs < this.groups.length;
381
+
382
+ if (hasLeft) {
383
+ return `${ansi.dim}◀${ansi.reset} ${tabStr}`;
384
+ }
385
+ if (hasRight) {
386
+ return `${tabStr} ${ansi.dim}▶${ansi.reset}`;
387
+ }
388
+
389
+ return tabStr;
390
+ }
391
+
392
+ /**
393
+ * Render all tabs (when they all fit).
394
+ */
395
+ private renderAllTabs(width: number): string {
296
396
  const tabs: string[] = [];
297
397
 
298
398
  for (let i = 0; i < this.groups.length; i++) {
299
- const group = this.groups[i];
399
+ const group = this.groups[i]!;
300
400
  const isActive = i === this.activeTabIndex;
301
- const color = TAB_COLORS[i % TAB_COLORS.length];
401
+ const color = TAB_COLORS[i % TAB_COLORS.length]!;
302
402
 
303
403
  if (isActive) {
304
404
  tabs.push(`${color}${ansi.bold} ${group.icon} ${group.name} ${ansi.reset}`);
@@ -310,7 +410,7 @@ export class InfoOverlay implements Component {
310
410
  const tabStr = tabs.join(`${ansi.dim}│${ansi.reset}`);
311
411
  const visLen = visibleWidth(tabStr);
312
412
 
313
- // Truncate if too wide
413
+ // Truncate if too wide (shouldn't happen if maxTabs calculation is correct)
314
414
  if (visLen > width - 2) {
315
415
  return truncateToWidth(tabStr, width - 2);
316
416
  }
@@ -318,13 +418,6 @@ export class InfoOverlay implements Component {
318
418
  return tabStr;
319
419
  }
320
420
 
321
- /**
322
- * Render a separator line.
323
- */
324
- private renderSeparator(width: number): string {
325
- return ansi.dim + "─".repeat(width) + ansi.reset;
326
- }
327
-
328
421
  /**
329
422
  * Render group content.
330
423
  */
@@ -383,7 +476,7 @@ export class InfoOverlay implements Component {
383
476
  /**
384
477
  * Render footer with navigation hints.
385
478
  */
386
- private renderFooter(width: number, hasScroll?: boolean): string {
479
+ private renderFooter(width: number, _hasScroll?: boolean): string {
387
480
  const hints = [
388
481
  `${ansi.cyan}←/→${ansi.reset} tabs`,
389
482
  ];
package/types.ts CHANGED
@@ -55,6 +55,8 @@ export interface InfoScreenSettings {
55
55
  bootTimeoutMs: number;
56
56
  /** Per-group settings */
57
57
  groups: Record<string, GroupSettings>;
58
+ /** Group display order (array of group ids) */
59
+ groupOrder?: string[];
58
60
  }
59
61
 
60
62
  /** Settings for a single group */
@@ -70,4 +72,5 @@ export const DEFAULT_SETTINGS: InfoScreenSettings = {
70
72
  showOnBoot: true,
71
73
  bootTimeoutMs: 2000,
72
74
  groups: {},
75
+ groupOrder: [],
73
76
  };