@pi-unipi/info-screen 0.1.1 → 0.1.3

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/core-groups.ts CHANGED
@@ -173,6 +173,59 @@ function discoverExtensions(): Array<{ name: string; source: string; version: st
173
173
  return extensions;
174
174
  }
175
175
 
176
+ /**
177
+ * Load time tracking.
178
+ */
179
+ const loadTimes: Array<{ name: string; type: string; ms: number }> = [];
180
+ let totalLoadTimeMs = 0;
181
+ let loadTrackingStarted = false;
182
+ let loadTrackingStartMs = 0;
183
+
184
+ /** Start load time tracking */
185
+ export function startLoadTracking(): void {
186
+ if (!loadTrackingStarted) {
187
+ loadTrackingStartMs = Date.now();
188
+ loadTrackingStarted = true;
189
+ }
190
+ }
191
+
192
+ /** Record a load time */
193
+ export function recordLoadTime(name: string, type: string, ms: number): void {
194
+ loadTimes.push({ name, type, ms });
195
+ totalLoadTimeMs += ms;
196
+ }
197
+
198
+ /** Finish load tracking */
199
+ export function finishLoadTracking(): void {
200
+ if (loadTrackingStarted) {
201
+ totalLoadTimeMs = Date.now() - loadTrackingStartMs;
202
+ }
203
+ }
204
+
205
+ /** Get load times */
206
+ export function getLoadTimes(): Array<{ name: string; type: string; ms: number }> {
207
+ return [...loadTimes];
208
+ }
209
+
210
+ /** Get total load time */
211
+ export function getTotalLoadTime(): number {
212
+ return totalLoadTimeMs;
213
+ }
214
+
215
+ /**
216
+ * Additional skill directories registered by extensions.
217
+ */
218
+ const extraSkillDirs: string[] = [];
219
+
220
+ /**
221
+ * Register an additional skill directory (from extensions).
222
+ */
223
+ export function registerSkillDir(dir: string): void {
224
+ if (!extraSkillDirs.includes(dir)) {
225
+ extraSkillDirs.push(dir);
226
+ }
227
+ }
228
+
176
229
  /**
177
230
  * Discover loaded skills by scanning filesystem.
178
231
  */
@@ -185,6 +238,8 @@ function discoverSkills(): Array<{ name: string; source: string }> {
185
238
  const skillDirs = [
186
239
  join(homeDir, ".pi", "agent", "skills"),
187
240
  join(cwd, ".pi", "skills"),
241
+ // Add extra dirs from extensions
242
+ ...extraSkillDirs,
188
243
  ];
189
244
 
190
245
  const counted = new Set<string>();
@@ -204,10 +259,14 @@ function discoverSkills(): Array<{ name: string; source: string }> {
204
259
  const skillPath = join(dir, name, "SKILL.md");
205
260
  if (existsSync(skillPath)) {
206
261
  counted.add(name);
207
- skills.push({
208
- name,
209
- source: dir.includes(homeDir) ? "global" : "project",
210
- });
262
+ // Determine source based on path
263
+ let source = "extension";
264
+ if (dir.includes(join(homeDir, ".pi"))) {
265
+ source = "global";
266
+ } else if (dir === join(cwd, ".pi", "skills")) {
267
+ source = "project";
268
+ }
269
+ skills.push({ name, source });
211
270
  }
212
271
  }
213
272
  } catch {
@@ -228,6 +287,18 @@ const announcedModules: Array<{ name: string; version: string }> = [];
228
287
  */
229
288
  const registeredTools: Array<{ name: string; source: string }> = [];
230
289
 
290
+ /**
291
+ * Reference to pi API for getting tools.
292
+ */
293
+ let piApi: any = null;
294
+
295
+ /**
296
+ * Set the pi API reference.
297
+ */
298
+ export function setPiApi(api: any): void {
299
+ piApi = api;
300
+ }
301
+
231
302
  /**
232
303
  * Add a module to the announced list.
233
304
  */
@@ -299,6 +370,43 @@ export function registerCoreGroups(): void {
299
370
  },
300
371
  });
301
372
 
373
+ // 1b. Load time group
374
+ infoRegistry.registerGroup({
375
+ id: "loadtime",
376
+ name: "Load Time",
377
+ icon: "⏱️",
378
+ priority: 15,
379
+ config: {
380
+ showByDefault: true,
381
+ stats: [
382
+ { id: "total", label: "Total Load Time", show: true },
383
+ { id: "count", label: "Items Loaded", show: true },
384
+ { id: "list", label: "Load Times", show: true },
385
+ ],
386
+ },
387
+ dataProvider: async () => {
388
+ const times = getLoadTimes();
389
+ const total = getTotalLoadTime();
390
+
391
+ // Sort by load time descending
392
+ const sorted = [...times].sort((a, b) => b.ms - a.ms);
393
+
394
+ // Build list as comma-separated values
395
+ const listStr = sorted.length > 0
396
+ ? sorted.map(t => `${t.name} (${t.ms}ms)`).join(", ")
397
+ : "none";
398
+
399
+ return {
400
+ total: { value: `${total}ms` },
401
+ count: { value: String(times.length) },
402
+ list: {
403
+ value: sorted.length > 0 ? `${sorted[0].name} (${sorted[0].ms}ms)` : "none",
404
+ detail: sorted.length > 1 ? sorted.slice(1).map(t => `${t.name} (${t.ms}ms)`).join(", ") : undefined,
405
+ },
406
+ };
407
+ },
408
+ });
409
+
302
410
  // 2. Usage group
303
411
  infoRegistry.registerGroup({
304
412
  id: "usage",
@@ -374,20 +482,44 @@ export function registerCoreGroups(): void {
374
482
  ],
375
483
  },
376
484
  dataProvider: async () => {
377
- const tools = getRegisteredTools();
378
- const builtin = tools.filter((t) => t.source === "builtin");
379
- const custom = tools.filter((t) => t.source === "registered");
485
+ // Use pi.getAllTools() to get actual tools with source info
486
+ let tools: Array<{ name: string; source?: string; sourceInfo?: any }> = [];
487
+
488
+ if (piApi && typeof piApi.getAllTools === "function") {
489
+ try {
490
+ tools = piApi.getAllTools();
491
+ } catch {
492
+ // Fallback to tracked tools
493
+ tools = getRegisteredTools();
494
+ }
495
+ } else {
496
+ tools = getRegisteredTools();
497
+ }
380
498
 
381
- // Build tool list - show all
499
+ // Categorize by source
500
+ const builtin = tools.filter((t) => {
501
+ const source = t.sourceInfo?.source || t.source;
502
+ return source === "builtin";
503
+ });
504
+ const extension = tools.filter((t) => {
505
+ const source = t.sourceInfo?.source || t.source;
506
+ return source !== "builtin" && source !== "sdk";
507
+ });
508
+ const sdk = tools.filter((t) => {
509
+ const source = t.sourceInfo?.source || t.source;
510
+ return source === "sdk";
511
+ });
512
+
513
+ // Build tool list as comma-separated values
382
514
  const toolNames = tools.map((t) => `${t.name}`);
515
+ const toolListStr = toolNames.join(", ");
383
516
 
384
517
  return {
385
518
  total: { value: String(tools.length) },
386
519
  builtin: { value: String(builtin.length) },
387
- registered: { value: String(custom.length) },
520
+ registered: { value: String(extension.length + sdk.length) },
388
521
  list: {
389
- value: toolNames.length > 0 ? toolNames[0] : "none",
390
- detail: toolNames.length > 1 ? toolNames.slice(1).join("\n") : undefined,
522
+ value: toolListStr.length > 0 ? toolListStr : "none",
391
523
  },
392
524
  };
393
525
  },
@@ -453,16 +585,16 @@ export function registerCoreGroups(): void {
453
585
  const global = skills.filter((s) => s.source === "global");
454
586
  const project = skills.filter((s) => s.source === "project");
455
587
 
456
- // Build skill list - show all
588
+ // Build skill list as comma-separated values
457
589
  const skillNames = skills.map((s) => `${s.name} (${s.source})`);
590
+ const skillListStr = skillNames.join(", ");
458
591
 
459
592
  return {
460
593
  count: { value: String(skills.length) },
461
594
  global: { value: String(global.length) },
462
595
  project: { value: String(project.length) },
463
596
  list: {
464
- value: skillNames.length > 0 ? skillNames[0] : "none",
465
- detail: skillNames.length > 1 ? skillNames.slice(1).join("\n") : undefined,
597
+ value: skillListStr.length > 0 ? skillListStr : "none",
466
598
  },
467
599
  };
468
600
  },
package/index.ts CHANGED
@@ -12,10 +12,10 @@
12
12
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
13
13
  import { UNIPI_EVENTS, MODULES, UNIPI_PREFIX, emitEvent, getPackageVersion } from "@pi-unipi/core";
14
14
  import { infoRegistry } from "./registry.js";
15
- import { registerCoreGroups, trackModule, trackTool } from "./core-groups.js";
15
+ import { registerCoreGroups, trackModule, trackTool, setPiApi, registerSkillDir, startLoadTracking, recordLoadTime, finishLoadTracking } from "./core-groups.js";
16
16
 
17
- /** Re-export infoRegistry for external use */
18
- export { infoRegistry };
17
+ /** Re-export infoRegistry, registerSkillDir, and load tracking for external use */
18
+ export { infoRegistry, registerSkillDir, startLoadTracking, recordLoadTime, finishLoadTracking };
19
19
  import { getInfoSettings } from "./config.js";
20
20
  import { InfoOverlay } from "./tui/info-overlay.js";
21
21
  import { SettingsOverlay } from "./settings/settings-tui.js";
@@ -31,33 +31,38 @@ const moduleReadyPromise = new Promise<void>((resolve) => {
31
31
  });
32
32
 
33
33
  /** Timeout for waiting for modules */
34
- const MODULE_WAIT_TIMEOUT_MS = 2000;
34
+ const MODULE_WAIT_TIMEOUT_MS = 5000;
35
35
 
36
36
  /**
37
- * Wait for all modules to announce, then return.
37
+ * Wait for modules to announce, then return.
38
38
  */
39
39
  async function waitForModules(): Promise<void> {
40
40
  const settings = getInfoSettings();
41
- const timeoutMs = settings.bootTimeoutMs;
41
+ const timeoutMs = settings.bootTimeoutMs || MODULE_WAIT_TIMEOUT_MS;
42
42
 
43
- // Wait for module ready or timeout
44
- await Promise.race([
45
- moduleReadyPromise,
46
- new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
47
- ]);
43
+ // Wait a bit for modules to announce
44
+ // We wait for the full timeout to give all modules time to emit MODULE_READY
45
+ await new Promise<void>((resolve) => setTimeout(resolve, timeoutMs));
48
46
  }
49
47
 
50
48
  export default function (pi: ExtensionAPI) {
49
+ // Set pi API reference for tools access
50
+ setPiApi(pi);
51
+
51
52
  // Register core groups on load
52
53
  registerCoreGroups();
53
54
 
54
55
 
55
56
 
57
+ // Start load tracking
58
+ startLoadTracking();
59
+
56
60
  // Listen for module announcements
57
61
  pi.events.on(UNIPI_EVENTS.MODULE_READY, (event: any) => {
58
62
  if (event.name && event.name !== MODULES.INFO_SCREEN) {
59
63
  // Track the module
60
64
  trackModule(event.name, event.version || "unknown");
65
+ recordLoadTime(event.name, "module", event.loadTimeMs || 0);
61
66
 
62
67
  // Track tools from this module
63
68
  if (event.tools && Array.isArray(event.tools)) {
@@ -99,18 +104,20 @@ export default function (pi: ExtensionAPI) {
99
104
  // Wait for other modules to announce
100
105
  await waitForModules();
101
106
 
102
- // Show the overlay
107
+ // Show the overlay using three-method object pattern
103
108
  ctx.ui.custom(
104
109
  (tui, _theme, _keybindings, done) => {
105
110
  const overlay = new InfoOverlay();
106
111
  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();
112
+ // Return three-method object as per pi-tui docs
113
+ return {
114
+ render: (w: number) => overlay.render(w),
115
+ invalidate: () => overlay.invalidate(),
116
+ handleInput: (data: string) => {
117
+ overlay.handleInput?.(data);
118
+ tui.requestRender();
119
+ },
112
120
  };
113
- return overlay;
114
121
  },
115
122
  {
116
123
  overlay: true,
@@ -124,6 +131,9 @@ export default function (pi: ExtensionAPI) {
124
131
  );
125
132
  }
126
133
 
134
+ // Finish load tracking
135
+ finishLoadTracking();
136
+
127
137
  // Announce module
128
138
  emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
129
139
  name: MODULES.INFO_SCREEN,
@@ -141,12 +151,14 @@ export default function (pi: ExtensionAPI) {
141
151
  (tui, _theme, _keybindings, done) => {
142
152
  const overlay = new InfoOverlay();
143
153
  overlay.onClose = () => done(undefined);
144
- const originalHandleInput = overlay.handleInput?.bind(overlay);
145
- overlay.handleInput = (data: string) => {
146
- originalHandleInput?.(data);
147
- tui.requestRender();
154
+ return {
155
+ render: (w: number) => overlay.render(w),
156
+ invalidate: () => overlay.invalidate(),
157
+ handleInput: (data: string) => {
158
+ overlay.handleInput?.(data);
159
+ tui.requestRender();
160
+ },
148
161
  };
149
- return overlay;
150
162
  },
151
163
  {
152
164
  overlay: true,
@@ -169,12 +181,14 @@ export default function (pi: ExtensionAPI) {
169
181
  (tui, _theme, _keybindings, done) => {
170
182
  const overlay = new SettingsOverlay();
171
183
  overlay.onClose = () => done(undefined);
172
- const originalHandleInput = overlay.handleInput?.bind(overlay);
173
- overlay.handleInput = (data: string) => {
174
- originalHandleInput?.(data);
175
- tui.requestRender();
184
+ return {
185
+ render: (w: number) => overlay.render(w),
186
+ invalidate: () => overlay.invalidate(),
187
+ handleInput: (data: string) => {
188
+ overlay.handleInput?.(data);
189
+ tui.requestRender();
190
+ },
176
191
  };
177
- return overlay;
178
192
  },
179
193
  {
180
194
  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.3",
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,7 @@ const ansi = {
25
26
  white: "\x1b[37m",
26
27
  red: "\x1b[31m",
27
28
  gray: "\x1b[90m",
29
+
28
30
  };
29
31
 
30
32
  /** Tab color palette */
@@ -46,6 +48,8 @@ export class InfoOverlay implements Component {
46
48
  private loading = true;
47
49
  private error: string | null = null;
48
50
  private scrollOffset = 0;
51
+ /** Tab scroll offset for windowed scrolling */
52
+ private tabScrollOffset = 0;
49
53
  /** Callback when overlay should close */
50
54
  onClose?: () => void;
51
55
 
@@ -67,6 +71,17 @@ export class InfoOverlay implements Component {
67
71
  this.loading = true;
68
72
  // Always re-fetch ALL groups to catch late registrations
69
73
  this.groups = infoRegistry.getAllGroups();
74
+
75
+ // Apply saved order from settings
76
+ const settings = getInfoSettings();
77
+ if (settings.groupOrder && settings.groupOrder.length > 0) {
78
+ const order = settings.groupOrder;
79
+ this.groups.sort((a, b) => {
80
+ const ai = order.indexOf(a.id);
81
+ const bi = order.indexOf(b.id);
82
+ return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
83
+ });
84
+ }
70
85
 
71
86
  try {
72
87
  // Load data for all groups in parallel
@@ -94,10 +109,12 @@ export class InfoOverlay implements Component {
94
109
  // Right arrow - switch tab
95
110
  this.activeTabIndex = (this.activeTabIndex + 1) % this.groups.length;
96
111
  this.scrollOffset = 0; // Reset scroll on tab switch
112
+ this.ensureTabVisible();
97
113
  } else if (data === "\x1b[D" || data === "h") {
98
114
  // Left arrow - switch tab
99
115
  this.activeTabIndex = (this.activeTabIndex - 1 + this.groups.length) % this.groups.length;
100
116
  this.scrollOffset = 0; // Reset scroll on tab switch
117
+ this.ensureTabVisible();
101
118
  } else if (data === "\x1b[B" || data === "j") {
102
119
  // Down arrow - scroll down
103
120
  this.scrollOffset++;
@@ -116,6 +133,14 @@ export class InfoOverlay implements Component {
116
133
  }
117
134
  }
118
135
 
136
+ /**
137
+ * Ensure active tab is visible in the tab bar (horizontal scroll).
138
+ */
139
+ private ensureTabVisible(): void {
140
+ // Tab bar shows ~maxTabsVisible tabs, centered around active
141
+ // This is handled in renderTabBar
142
+ }
143
+
119
144
  /**
120
145
  * Render the component.
121
146
  */
@@ -135,6 +160,16 @@ export class InfoOverlay implements Component {
135
160
 
136
161
  if (groupIds !== currentIds) {
137
162
  this.groups = allGroups;
163
+ // Apply saved order
164
+ const settings = getInfoSettings();
165
+ if (settings.groupOrder && settings.groupOrder.length > 0) {
166
+ const order = settings.groupOrder;
167
+ this.groups.sort((a, b) => {
168
+ const ai = order.indexOf(a.id);
169
+ const bi = order.indexOf(b.id);
170
+ return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
171
+ });
172
+ }
138
173
  // Load data for any new groups (non-blocking)
139
174
  this.loadDataForNewGroups(allGroups);
140
175
  }
@@ -169,11 +204,9 @@ export class InfoOverlay implements Component {
169
204
  const lines: string[] = [];
170
205
  const padding = " ".repeat(Math.max(0, Math.floor((width - 20) / 2)));
171
206
 
172
- lines.push("");
173
207
  lines.push(`${padding}${ansi.cyan}${ansi.bold}📊 UniPi Info Screen${ansi.reset}`);
174
208
  lines.push("");
175
209
  lines.push(`${padding}${ansi.dim}Loading dashboard...${ansi.reset}`);
176
- lines.push("");
177
210
 
178
211
  return lines;
179
212
  }
@@ -185,10 +218,8 @@ export class InfoOverlay implements Component {
185
218
  const lines: string[] = [];
186
219
  const padding = " ".repeat(Math.max(0, Math.floor((width - 20) / 2)));
187
220
 
188
- lines.push("");
189
221
  lines.push(`${padding}${ansi.yellow}${ansi.bold}⚠️ Error${ansi.reset}`);
190
222
  lines.push(`${padding}${ansi.dim}${this.error ?? "Unknown error"}${ansi.reset}`);
191
- lines.push("");
192
223
 
193
224
  return lines;
194
225
  }
@@ -200,22 +231,23 @@ export class InfoOverlay implements Component {
200
231
  const lines: string[] = [];
201
232
  const padding = " ".repeat(Math.max(0, Math.floor((width - 30) / 2)));
202
233
 
203
- lines.push("");
204
234
  lines.push(`${padding}${ansi.cyan}${ansi.bold}📊 UniPi Info Screen${ansi.reset}`);
205
235
  lines.push("");
206
236
  lines.push(`${padding}${ansi.dim}No groups registered.${ansi.reset}`);
207
237
  lines.push(`${padding}${ansi.dim}Modules will register groups on startup.${ansi.reset}`);
208
- lines.push("");
209
238
 
210
239
  return lines;
211
240
  }
212
241
 
213
242
  /**
214
- * Pad a line to fill a target visual width.
243
+ * Pad a line to fill a target visual width with background.
215
244
  */
216
- private padToWidth(line: string, targetWidth: number): string {
245
+ private padToWidth(line: string, targetWidth: number, bg?: string): string {
217
246
  const visLen = visibleWidth(line);
218
247
  const pad = Math.max(0, targetWidth - visLen);
248
+ if (bg) {
249
+ return bg + line + " ".repeat(pad) + ansi.reset;
250
+ }
219
251
  return line + " ".repeat(pad);
220
252
  }
221
253
 
@@ -227,46 +259,42 @@ export class InfoOverlay implements Component {
227
259
  const group = this.groups[this.activeTabIndex];
228
260
  const data = this.groupData.get(group.id) ?? {};
229
261
  // 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;
262
+ const innerWidth = width - 2;
263
+
264
+ // Fixed content height for stability
265
+ const CONTENT_HEIGHT = 12;
232
266
 
233
267
  // Top border
234
- lines.push(`${ansi.dim}╭${"─".repeat(innerWidth + 1)}╮${ansi.reset}`);
268
+ lines.push(`${ansi.dim}╭${"─".repeat(innerWidth)}╮${ansi.reset}`);
235
269
 
236
270
  // 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}`);
271
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderHeader(innerWidth, group), innerWidth)}${ansi.dim}│${ansi.reset}`);
272
+ lines.push(`${ansi.dim}├${"─".repeat(innerWidth)}┤${ansi.reset}`);
239
273
 
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}`);
274
+ // Tab bar with horizontal scrolling
275
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderTabBar(innerWidth), innerWidth)}${ansi.dim}│${ansi.reset}`);
276
+ lines.push(`${ansi.dim}├${"─".repeat(innerWidth)}┤${ansi.reset}`);
243
277
 
244
- // Content with scrolling
278
+ // Content with scrolling (fixed height)
245
279
  const contentLines = this.renderGroupContent(innerWidth, group, data);
246
- const maxVisibleLines = 15; // Max content lines visible
247
280
 
248
281
  // Clamp scroll offset
249
- const maxScroll = Math.max(0, contentLines.length - maxVisibleLines);
282
+ const maxScroll = Math.max(0, contentLines.length - CONTENT_HEIGHT);
250
283
  this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
251
284
 
252
285
  // 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
- }
286
+ const visibleContent = contentLines.slice(this.scrollOffset, this.scrollOffset + CONTENT_HEIGHT);
258
287
 
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}`);
288
+ // Render content lines (pad to fixed height)
289
+ for (let i = 0; i < CONTENT_HEIGHT; i++) {
290
+ const line = visibleContent[i] ?? "";
291
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(line, innerWidth)}${ansi.dim}│${ansi.reset}`);
263
292
  }
264
293
 
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}`);
294
+ // Footer with scroll indicator inline
295
+ lines.push(`${ansi.dim}├${"─".repeat(innerWidth)}┤${ansi.reset}`);
296
+ lines.push(`${ansi.dim}│${ansi.reset}${this.renderFooterWithScroll(innerWidth, contentLines.length, CONTENT_HEIGHT)}${ansi.dim}│${ansi.reset}`);
297
+ lines.push(`${ansi.dim}╰${"─".repeat(innerWidth)}╯${ansi.reset}`);
270
298
 
271
299
  return lines;
272
300
  }
@@ -290,15 +318,93 @@ export class InfoOverlay implements Component {
290
318
  }
291
319
 
292
320
  /**
293
- * Render tab bar.
321
+ * Render tab bar with windowed scrolling.
322
+ * Window slides only when active tab reaches the edge.
323
+ * Example: abcde → user on 'e' presses right → efghi
294
324
  */
295
325
  private renderTabBar(width: number): string {
326
+ if (this.groups.length === 0) return "";
327
+
328
+ // Calculate tab widths
329
+ const tabWidths = this.groups.map(g => visibleWidth(` ${g.icon} ${g.name} `));
330
+ const separatorWidth = visibleWidth(`${ansi.dim}│${ansi.reset}`);
331
+
332
+ // Find how many tabs fit (account for potential scroll indicators)
333
+ const indicatorSpace = 3; // Space for ◀ or ▶
334
+ let maxTabs = 0;
335
+ let totalWidth = 0;
336
+ for (let i = 0; i < this.groups.length; i++) {
337
+ const tabW = tabWidths[i]!;
338
+ const sepW = i > 0 ? separatorWidth : 0;
339
+ // Reserve space for scroll indicator on one side
340
+ if (totalWidth + sepW + tabW > width - 2 - indicatorSpace) break;
341
+ totalWidth += sepW + tabW;
342
+ maxTabs = i + 1;
343
+ }
344
+
345
+ // If all tabs fit, show all
346
+ if (maxTabs >= this.groups.length) {
347
+ return this.renderAllTabs(width);
348
+ }
349
+
350
+ // Windowed scrolling: slide only when active tab reaches edge
351
+ // Initialize tabScrollOffset if needed
352
+ if (this.tabScrollOffset === undefined) {
353
+ this.tabScrollOffset = 0;
354
+ }
355
+
356
+ // Ensure active tab is visible within the window
357
+ if (this.activeTabIndex < this.tabScrollOffset) {
358
+ // Active tab is before window - slide left
359
+ this.tabScrollOffset = this.activeTabIndex;
360
+ } else if (this.activeTabIndex >= this.tabScrollOffset + maxTabs) {
361
+ // Active tab is after window - slide right
362
+ this.tabScrollOffset = this.activeTabIndex - maxTabs + 1;
363
+ }
364
+
365
+ // Clamp scroll offset
366
+ this.tabScrollOffset = Math.max(0, Math.min(this.tabScrollOffset, this.groups.length - maxTabs));
367
+
368
+ // Build visible tabs
369
+ const tabs: string[] = [];
370
+ for (let i = this.tabScrollOffset; i < this.tabScrollOffset + maxTabs && i < this.groups.length; i++) {
371
+ const group = this.groups[i]!;
372
+ const isActive = i === this.activeTabIndex;
373
+ const color = TAB_COLORS[i % TAB_COLORS.length]!;
374
+
375
+ if (isActive) {
376
+ tabs.push(`${color}${ansi.bold} ${group.icon} ${group.name} ${ansi.reset}`);
377
+ } else {
378
+ tabs.push(`${ansi.dim} ${group.icon} ${group.name} ${ansi.reset}`);
379
+ }
380
+ }
381
+
382
+ const tabStr = tabs.join(`${ansi.dim}│${ansi.reset}`);
383
+
384
+ // Add scroll indicators
385
+ const hasLeft = this.tabScrollOffset > 0;
386
+ const hasRight = this.tabScrollOffset + maxTabs < this.groups.length;
387
+
388
+ if (hasLeft) {
389
+ return `${ansi.dim}◀${ansi.reset} ${tabStr}`;
390
+ }
391
+ if (hasRight) {
392
+ return `${tabStr} ${ansi.dim}▶${ansi.reset}`;
393
+ }
394
+
395
+ return tabStr;
396
+ }
397
+
398
+ /**
399
+ * Render all tabs (when they all fit).
400
+ */
401
+ private renderAllTabs(width: number): string {
296
402
  const tabs: string[] = [];
297
403
 
298
404
  for (let i = 0; i < this.groups.length; i++) {
299
- const group = this.groups[i];
405
+ const group = this.groups[i]!;
300
406
  const isActive = i === this.activeTabIndex;
301
- const color = TAB_COLORS[i % TAB_COLORS.length];
407
+ const color = TAB_COLORS[i % TAB_COLORS.length]!;
302
408
 
303
409
  if (isActive) {
304
410
  tabs.push(`${color}${ansi.bold} ${group.icon} ${group.name} ${ansi.reset}`);
@@ -310,7 +416,7 @@ export class InfoOverlay implements Component {
310
416
  const tabStr = tabs.join(`${ansi.dim}│${ansi.reset}`);
311
417
  const visLen = visibleWidth(tabStr);
312
418
 
313
- // Truncate if too wide
419
+ // Truncate if too wide (shouldn't happen if maxTabs calculation is correct)
314
420
  if (visLen > width - 2) {
315
421
  return truncateToWidth(tabStr, width - 2);
316
422
  }
@@ -318,13 +424,6 @@ export class InfoOverlay implements Component {
318
424
  return tabStr;
319
425
  }
320
426
 
321
- /**
322
- * Render a separator line.
323
- */
324
- private renderSeparator(width: number): string {
325
- return ansi.dim + "─".repeat(width) + ansi.reset;
326
- }
327
-
328
427
  /**
329
428
  * Render group content.
330
429
  */
@@ -381,26 +480,37 @@ export class InfoOverlay implements Component {
381
480
  }
382
481
 
383
482
  /**
384
- * Render footer with navigation hints.
483
+ * Render footer with navigation hints and scroll indicator.
385
484
  */
386
- private renderFooter(width: number, hasScroll?: boolean): string {
485
+ private renderFooterWithScroll(width: number, totalLines: number, visibleHeight: number): string {
486
+ // Left side: scroll indicator
487
+ const hasScroll = totalLines > visibleHeight;
488
+ let scrollStr = "";
489
+ if (hasScroll) {
490
+ scrollStr = `${ansi.dim}${this.scrollOffset + 1}-${Math.min(this.scrollOffset + visibleHeight, totalLines)}/${totalLines}${ansi.reset}`;
491
+ }
492
+
493
+ // Right side: navigation hints
387
494
  const hints = [
388
495
  `${ansi.cyan}←/→${ansi.reset} tabs`,
496
+ `${ansi.green}↑/↓${ansi.reset} scroll`,
497
+ `${ansi.red}q/Esc${ansi.reset} close`,
389
498
  ];
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
499
 
395
500
  const hintStr = hints.join(` ${ansi.dim}•${ansi.reset} `);
396
- const visLen = visibleWidth(hintStr);
501
+ const hintWidth = visibleWidth(hintStr);
502
+ const scrollWidth = visibleWidth(scrollStr);
397
503
 
398
- if (visLen >= width - 4) {
399
- return truncateToWidth(hintStr, width - 4);
504
+ // Calculate spacing
505
+ const gap = 4;
506
+ const totalWidth = scrollWidth + gap + hintWidth;
507
+
508
+ if (totalWidth >= width - 2) {
509
+ // Too wide, just show hints
510
+ return truncateToWidth(hintStr, width - 2);
400
511
  }
401
512
 
402
- const leftPad = Math.floor((width - visLen) / 2);
403
-
404
- return " ".repeat(leftPad) + hintStr;
513
+ const padding = " ".repeat(width - 2 - totalWidth);
514
+ return scrollStr + padding + hintStr;
405
515
  }
406
516
  }
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 */
@@ -68,6 +70,7 @@ export interface GroupSettings {
68
70
  /** Default settings */
69
71
  export const DEFAULT_SETTINGS: InfoScreenSettings = {
70
72
  showOnBoot: true,
71
- bootTimeoutMs: 2000,
73
+ bootTimeoutMs: 5000,
72
74
  groups: {},
75
+ groupOrder: [],
73
76
  };