@pi-unipi/info-screen 0.1.2 → 0.1.4

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,78 @@ 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
+ const moduleStartTimes = new Map<string, number>();
184
+
185
+ /** Start load time tracking */
186
+ export function startLoadTracking(): void {
187
+ if (!loadTrackingStarted) {
188
+ loadTrackingStartMs = Date.now();
189
+ loadTrackingStarted = true;
190
+ }
191
+ }
192
+
193
+ /** Record when a module starts loading */
194
+ export function recordModuleStart(name: string): void {
195
+ moduleStartTimes.set(name, Date.now());
196
+ }
197
+
198
+ /** Record a load time */
199
+ export function recordLoadTime(name: string, type: string, ms?: number): void {
200
+ // If no ms provided, calculate from start time
201
+ if (ms === undefined || ms === 0) {
202
+ const startTime = moduleStartTimes.get(name);
203
+ if (startTime) {
204
+ ms = Date.now() - startTime;
205
+ } else {
206
+ ms = 0;
207
+ }
208
+ }
209
+ // Avoid duplicates
210
+ const existing = loadTimes.find(t => t.name === name && t.type === type);
211
+ if (!existing) {
212
+ loadTimes.push({ name, type, ms });
213
+ totalLoadTimeMs += ms;
214
+ }
215
+ }
216
+
217
+ /** Finish load tracking */
218
+ export function finishLoadTracking(): void {
219
+ if (loadTrackingStarted) {
220
+ totalLoadTimeMs = Date.now() - loadTrackingStartMs;
221
+ }
222
+ }
223
+
224
+ /** Get load times */
225
+ export function getLoadTimes(): Array<{ name: string; type: string; ms: number }> {
226
+ return [...loadTimes];
227
+ }
228
+
229
+ /** Get total load time */
230
+ export function getTotalLoadTime(): number {
231
+ return totalLoadTimeMs > 0 ? totalLoadTimeMs : (loadTrackingStarted ? Date.now() - loadTrackingStartMs : 0);
232
+ }
233
+
234
+ /**
235
+ * Additional skill directories registered by extensions.
236
+ */
237
+ const extraSkillDirs: string[] = [];
238
+
239
+ /**
240
+ * Register an additional skill directory (from extensions).
241
+ */
242
+ export function registerSkillDir(dir: string): void {
243
+ if (!extraSkillDirs.includes(dir)) {
244
+ extraSkillDirs.push(dir);
245
+ }
246
+ }
247
+
176
248
  /**
177
249
  * Discover loaded skills by scanning filesystem.
178
250
  */
@@ -185,6 +257,8 @@ function discoverSkills(): Array<{ name: string; source: string }> {
185
257
  const skillDirs = [
186
258
  join(homeDir, ".pi", "agent", "skills"),
187
259
  join(cwd, ".pi", "skills"),
260
+ // Add extra dirs from extensions
261
+ ...extraSkillDirs,
188
262
  ];
189
263
 
190
264
  const counted = new Set<string>();
@@ -204,10 +278,14 @@ function discoverSkills(): Array<{ name: string; source: string }> {
204
278
  const skillPath = join(dir, name, "SKILL.md");
205
279
  if (existsSync(skillPath)) {
206
280
  counted.add(name);
207
- skills.push({
208
- name,
209
- source: dir.includes(homeDir) ? "global" : "project",
210
- });
281
+ // Determine source based on path
282
+ let source = "extension";
283
+ if (dir.includes(join(homeDir, ".pi"))) {
284
+ source = "global";
285
+ } else if (dir === join(cwd, ".pi", "skills")) {
286
+ source = "project";
287
+ }
288
+ skills.push({ name, source });
211
289
  }
212
290
  }
213
291
  } catch {
@@ -228,6 +306,18 @@ const announcedModules: Array<{ name: string; version: string }> = [];
228
306
  */
229
307
  const registeredTools: Array<{ name: string; source: string }> = [];
230
308
 
309
+ /**
310
+ * Reference to pi API for getting tools.
311
+ */
312
+ let piApi: any = null;
313
+
314
+ /**
315
+ * Set the pi API reference.
316
+ */
317
+ export function setPiApi(api: any): void {
318
+ piApi = api;
319
+ }
320
+
231
321
  /**
232
322
  * Add a module to the announced list.
233
323
  */
@@ -299,6 +389,43 @@ export function registerCoreGroups(): void {
299
389
  },
300
390
  });
301
391
 
392
+ // 1b. Load time group
393
+ infoRegistry.registerGroup({
394
+ id: "loadtime",
395
+ name: "Load Time",
396
+ icon: "⏱️",
397
+ priority: 15,
398
+ config: {
399
+ showByDefault: true,
400
+ stats: [
401
+ { id: "total", label: "Total Load Time", show: true },
402
+ { id: "count", label: "Items Loaded", show: true },
403
+ { id: "list", label: "Load Times", show: true },
404
+ ],
405
+ },
406
+ dataProvider: async () => {
407
+ const times = getLoadTimes();
408
+ const total = getTotalLoadTime();
409
+
410
+ // Sort by load time descending
411
+ const sorted = [...times].sort((a, b) => b.ms - a.ms);
412
+
413
+ // Build list as comma-separated values
414
+ const listStr = sorted.length > 0
415
+ ? sorted.map(t => `${t.name} (${t.ms}ms)`).join(", ")
416
+ : "none";
417
+
418
+ return {
419
+ total: { value: `${total}ms` },
420
+ count: { value: String(times.length) },
421
+ list: {
422
+ value: sorted.length > 0 ? `${sorted[0].name} (${sorted[0].ms}ms)` : "none",
423
+ detail: sorted.length > 1 ? sorted.slice(1).map(t => `${t.name} (${t.ms}ms)`).join(", ") : undefined,
424
+ },
425
+ };
426
+ },
427
+ });
428
+
302
429
  // 2. Usage group
303
430
  infoRegistry.registerGroup({
304
431
  id: "usage",
@@ -374,20 +501,56 @@ export function registerCoreGroups(): void {
374
501
  ],
375
502
  },
376
503
  dataProvider: async () => {
377
- const tools = getRegisteredTools();
378
- const builtin = tools.filter((t) => t.source === "builtin");
379
- const custom = tools.filter((t) => t.source === "registered");
504
+ // Use pi.getAllTools() to get actual tools with source info
505
+ let tools: Array<{ name: string; source?: string; sourceInfo?: any }> = [];
506
+
507
+ if (piApi && typeof piApi.getAllTools === "function") {
508
+ try {
509
+ tools = piApi.getAllTools();
510
+ } catch {
511
+ // Fallback to tracked tools
512
+ tools = getRegisteredTools();
513
+ }
514
+ } else {
515
+ tools = getRegisteredTools();
516
+ }
380
517
 
381
- // Build tool list - show all
518
+ // Categorize by source
519
+ const builtin = tools.filter((t) => {
520
+ const source = t.sourceInfo?.source || t.source;
521
+ return source === "builtin";
522
+ });
523
+ const extension = tools.filter((t) => {
524
+ const source = t.sourceInfo?.source || t.source;
525
+ return source !== "builtin" && source !== "sdk";
526
+ });
527
+ const sdk = tools.filter((t) => {
528
+ const source = t.sourceInfo?.source || t.source;
529
+ return source === "sdk";
530
+ });
531
+
532
+ // Build tool list as comma-separated values with wrapping
382
533
  const toolNames = tools.map((t) => `${t.name}`);
534
+ // Split into chunks of ~60 chars for wrapping
535
+ const chunks: string[] = [];
536
+ let current = "";
537
+ for (const name of toolNames) {
538
+ if (current && (current.length + name.length + 2) > 60) {
539
+ chunks.push(current);
540
+ current = name;
541
+ } else {
542
+ current = current ? `${current}, ${name}` : name;
543
+ }
544
+ }
545
+ if (current) chunks.push(current);
383
546
 
384
547
  return {
385
548
  total: { value: String(tools.length) },
386
549
  builtin: { value: String(builtin.length) },
387
- registered: { value: String(custom.length) },
550
+ registered: { value: String(extension.length + sdk.length) },
388
551
  list: {
389
- value: toolNames.length > 0 ? toolNames[0] : "none",
390
- detail: toolNames.length > 1 ? toolNames.slice(1).join("\n") : undefined,
552
+ value: chunks.length > 0 ? chunks[0] : "none",
553
+ detail: chunks.length > 1 ? chunks.slice(1).join("\n") : undefined,
391
554
  },
392
555
  };
393
556
  },
@@ -453,16 +616,28 @@ export function registerCoreGroups(): void {
453
616
  const global = skills.filter((s) => s.source === "global");
454
617
  const project = skills.filter((s) => s.source === "project");
455
618
 
456
- // Build skill list - show all
619
+ // Build skill list as comma-separated values with wrapping
457
620
  const skillNames = skills.map((s) => `${s.name} (${s.source})`);
621
+ // Split into chunks of ~60 chars for wrapping
622
+ const chunks: string[] = [];
623
+ let current = "";
624
+ for (const name of skillNames) {
625
+ if (current && (current.length + name.length + 2) > 60) {
626
+ chunks.push(current);
627
+ current = name;
628
+ } else {
629
+ current = current ? `${current}, ${name}` : name;
630
+ }
631
+ }
632
+ if (current) chunks.push(current);
458
633
 
459
634
  return {
460
635
  count: { value: String(skills.length) },
461
636
  global: { value: String(global.length) },
462
637
  project: { value: String(project.length) },
463
638
  list: {
464
- value: skillNames.length > 0 ? skillNames[0] : "none",
465
- detail: skillNames.length > 1 ? skillNames.slice(1).join("\n") : undefined,
639
+ value: chunks.length > 0 ? chunks[0] : "none",
640
+ detail: chunks.length > 1 ? chunks.slice(1).join("\n") : undefined,
466
641
  },
467
642
  };
468
643
  },
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, recordModuleStart } 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, recordModuleStart };
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);
61
66
 
62
67
  // Track tools from this module
63
68
  if (event.tools && Array.isArray(event.tools)) {
@@ -126,6 +131,9 @@ export default function (pi: ExtensionAPI) {
126
131
  );
127
132
  }
128
133
 
134
+ // Finish load tracking
135
+ finishLoadTracking();
136
+
129
137
  // Announce module
130
138
  emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
131
139
  name: MODULES.INFO_SCREEN,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/info-screen",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Dashboard and module registry for Unipi — configurable info overlay with tabbed groups",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,9 +26,7 @@ const ansi = {
26
26
  white: "\x1b[37m",
27
27
  red: "\x1b[31m",
28
28
  gray: "\x1b[90m",
29
- // Backgrounds
30
- bgDarkGray: "\x1b[48;5;235m",
31
- bgDarkerGray: "\x1b[48;5;233m",
29
+
32
30
  };
33
31
 
34
32
  /** Tab color palette */
@@ -50,7 +48,7 @@ export class InfoOverlay implements Component {
50
48
  private loading = true;
51
49
  private error: string | null = null;
52
50
  private scrollOffset = 0;
53
- /** Tab scroll offset for horizontal scrolling */
51
+ /** Tab scroll offset for windowed scrolling */
54
52
  private tabScrollOffset = 0;
55
53
  /** Callback when overlay should close */
56
54
  onClose?: () => void;
@@ -155,12 +153,12 @@ export class InfoOverlay implements Component {
155
153
  return this.renderError(width);
156
154
  }
157
155
 
158
- // Check for new groups (but don't re-trigger loading)
156
+ // Check for new groups and re-fetch data
159
157
  const allGroups = infoRegistry.getAllGroups();
160
158
  const groupIds = allGroups.map(g => g.id).join(",");
161
159
  const currentIds = this.groups.map(g => g.id).join(",");
162
160
 
163
- if (groupIds !== currentIds) {
161
+ if (groupIds !== currentIds || this.groups.length !== allGroups.length) {
164
162
  this.groups = allGroups;
165
163
  // Apply saved order
166
164
  const settings = getInfoSettings();
@@ -172,10 +170,11 @@ export class InfoOverlay implements Component {
172
170
  return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
173
171
  });
174
172
  }
175
- // Load data for any new groups (non-blocking)
176
- this.loadDataForNewGroups(allGroups);
177
173
  }
178
174
 
175
+ // Always re-fetch data for all groups to catch late updates
176
+ this.refreshAllData();
177
+
179
178
  if (this.groups.length === 0) {
180
179
  return this.renderEmpty(width);
181
180
  }
@@ -199,6 +198,22 @@ export class InfoOverlay implements Component {
199
198
  }
200
199
  }
201
200
 
201
+ /**
202
+ * Refresh data for all groups (non-blocking).
203
+ */
204
+ private refreshAllData(): void {
205
+ for (const group of this.groups) {
206
+ // Invalidate cache to get fresh data
207
+ infoRegistry.invalidateCache(group.id);
208
+ // Fetch fresh data (non-blocking)
209
+ infoRegistry.getGroupData(group.id).then(data => {
210
+ this.groupData.set(group.id, data);
211
+ }).catch(() => {
212
+ // Ignore errors
213
+ });
214
+ }
215
+ }
216
+
202
217
  /**
203
218
  * Render loading state.
204
219
  */
@@ -263,46 +278,39 @@ export class InfoOverlay implements Component {
263
278
  // Inner width for content (subtract 2 for left+right borders)
264
279
  const innerWidth = width - 2;
265
280
 
266
- // Background colors
267
- const bgHeader = ansi.bgDarkGray;
268
- const bgContent = ansi.bgDarkerGray;
281
+ // Fixed content height for stability
282
+ const CONTENT_HEIGHT = 12;
269
283
 
270
284
  // Top border
271
285
  lines.push(`${ansi.dim}╭${"─".repeat(innerWidth)}╮${ansi.reset}`);
272
286
 
273
- // Header with background
274
- lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderHeader(innerWidth, group), innerWidth, bgHeader)}${ansi.dim}│${ansi.reset}`);
287
+ // Header
288
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderHeader(innerWidth, group), innerWidth)}${ansi.dim}│${ansi.reset}`);
275
289
  lines.push(`${ansi.dim}├${"─".repeat(innerWidth)}┤${ansi.reset}`);
276
290
 
277
291
  // Tab bar with horizontal scrolling
278
- lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderTabBar(innerWidth), innerWidth, bgHeader)}${ansi.dim}│${ansi.reset}`);
292
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderTabBar(innerWidth), innerWidth)}${ansi.dim}│${ansi.reset}`);
279
293
  lines.push(`${ansi.dim}├${"─".repeat(innerWidth)}┤${ansi.reset}`);
280
294
 
281
- // Content with scrolling
295
+ // Content with scrolling (fixed height)
282
296
  const contentLines = this.renderGroupContent(innerWidth, group, data);
283
- const maxVisibleLines = 15; // Max content lines visible
284
297
 
285
298
  // Clamp scroll offset
286
- const maxScroll = Math.max(0, contentLines.length - maxVisibleLines);
299
+ const maxScroll = Math.max(0, contentLines.length - CONTENT_HEIGHT);
287
300
  this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
288
301
 
289
302
  // Get visible slice
290
- const visibleContent = contentLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines);
303
+ const visibleContent = contentLines.slice(this.scrollOffset, this.scrollOffset + CONTENT_HEIGHT);
291
304
 
292
- for (const line of visibleContent) {
293
- lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(line, innerWidth, bgContent)}${ansi.dim}│${ansi.reset}`);
294
- }
295
-
296
- // Show scroll indicator if needed
297
- if (contentLines.length > maxVisibleLines) {
298
- const scrollInfo = ` ${this.scrollOffset + 1}-${Math.min(this.scrollOffset + maxVisibleLines, contentLines.length)}/${contentLines.length} `;
299
- lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(scrollInfo, innerWidth, bgContent)}${ansi.dim}│${ansi.reset}`);
305
+ // Render content lines (pad to fixed height)
306
+ for (let i = 0; i < CONTENT_HEIGHT; i++) {
307
+ const line = visibleContent[i] ?? "";
308
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(line, innerWidth)}${ansi.dim}│${ansi.reset}`);
300
309
  }
301
310
 
302
- // Footer
303
- const hasScroll = contentLines.length > maxVisibleLines;
311
+ // Footer with scroll indicator inline
304
312
  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}`);
313
+ lines.push(`${ansi.dim}│${ansi.reset}${this.renderFooterWithScroll(innerWidth, contentLines.length, CONTENT_HEIGHT)}${ansi.dim}│${ansi.reset}`);
306
314
  lines.push(`${ansi.dim}╰${"─".repeat(innerWidth)}╯${ansi.reset}`);
307
315
 
308
316
  return lines;
@@ -327,8 +335,9 @@ export class InfoOverlay implements Component {
327
335
  }
328
336
 
329
337
  /**
330
- * Render tab bar with horizontal scrolling.
331
- * When tabs overflow, slides to keep active tab visible.
338
+ * Render tab bar with windowed scrolling.
339
+ * Window slides only when active tab reaches the edge.
340
+ * Example: abcde → user on 'e' presses right → efghi
332
341
  */
333
342
  private renderTabBar(width: number): string {
334
343
  if (this.groups.length === 0) return "";
@@ -337,13 +346,15 @@ export class InfoOverlay implements Component {
337
346
  const tabWidths = this.groups.map(g => visibleWidth(` ${g.icon} ${g.name} `));
338
347
  const separatorWidth = visibleWidth(`${ansi.dim}│${ansi.reset}`);
339
348
 
340
- // Find how many tabs fit
349
+ // Find how many tabs fit (account for potential scroll indicators)
350
+ const indicatorSpace = 3; // Space for ◀ or ▶
341
351
  let maxTabs = 0;
342
352
  let totalWidth = 0;
343
353
  for (let i = 0; i < this.groups.length; i++) {
344
354
  const tabW = tabWidths[i]!;
345
355
  const sepW = i > 0 ? separatorWidth : 0;
346
- if (totalWidth + sepW + tabW > width - 2) break;
356
+ // Reserve space for scroll indicator on one side
357
+ if (totalWidth + sepW + tabW > width - 2 - indicatorSpace) break;
347
358
  totalWidth += sepW + tabW;
348
359
  maxTabs = i + 1;
349
360
  }
@@ -353,15 +364,27 @@ export class InfoOverlay implements Component {
353
364
  return this.renderAllTabs(width);
354
365
  }
355
366
 
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));
367
+ // Windowed scrolling: slide only when active tab reaches edge
368
+ // Initialize tabScrollOffset if needed
369
+ if (this.tabScrollOffset === undefined) {
370
+ this.tabScrollOffset = 0;
371
+ }
372
+
373
+ // Ensure active tab is visible within the window
374
+ if (this.activeTabIndex < this.tabScrollOffset) {
375
+ // Active tab is before window - slide left
376
+ this.tabScrollOffset = this.activeTabIndex;
377
+ } else if (this.activeTabIndex >= this.tabScrollOffset + maxTabs) {
378
+ // Active tab is after window - slide right
379
+ this.tabScrollOffset = this.activeTabIndex - maxTabs + 1;
380
+ }
381
+
382
+ // Clamp scroll offset
383
+ this.tabScrollOffset = Math.max(0, Math.min(this.tabScrollOffset, this.groups.length - maxTabs));
361
384
 
362
385
  // Build visible tabs
363
386
  const tabs: string[] = [];
364
- for (let i = startIdx; i < startIdx + maxTabs && i < this.groups.length; i++) {
387
+ for (let i = this.tabScrollOffset; i < this.tabScrollOffset + maxTabs && i < this.groups.length; i++) {
365
388
  const group = this.groups[i]!;
366
389
  const isActive = i === this.activeTabIndex;
367
390
  const color = TAB_COLORS[i % TAB_COLORS.length]!;
@@ -376,8 +399,8 @@ export class InfoOverlay implements Component {
376
399
  const tabStr = tabs.join(`${ansi.dim}│${ansi.reset}`);
377
400
 
378
401
  // Add scroll indicators
379
- const hasLeft = startIdx > 0;
380
- const hasRight = startIdx + maxTabs < this.groups.length;
402
+ const hasLeft = this.tabScrollOffset > 0;
403
+ const hasRight = this.tabScrollOffset + maxTabs < this.groups.length;
381
404
 
382
405
  if (hasLeft) {
383
406
  return `${ansi.dim}◀${ansi.reset} ${tabStr}`;
@@ -474,26 +497,37 @@ export class InfoOverlay implements Component {
474
497
  }
475
498
 
476
499
  /**
477
- * Render footer with navigation hints.
500
+ * Render footer with navigation hints and scroll indicator.
478
501
  */
479
- private renderFooter(width: number, _hasScroll?: boolean): string {
502
+ private renderFooterWithScroll(width: number, totalLines: number, visibleHeight: number): string {
503
+ // Left side: scroll indicator
504
+ const hasScroll = totalLines > visibleHeight;
505
+ let scrollStr = "";
506
+ if (hasScroll) {
507
+ scrollStr = `${ansi.dim}${this.scrollOffset + 1}-${Math.min(this.scrollOffset + visibleHeight, totalLines)}/${totalLines}${ansi.reset}`;
508
+ }
509
+
510
+ // Right side: navigation hints
480
511
  const hints = [
481
512
  `${ansi.cyan}←/→${ansi.reset} tabs`,
513
+ `${ansi.green}↑/↓${ansi.reset} scroll`,
514
+ `${ansi.red}q/Esc${ansi.reset} close`,
482
515
  ];
483
-
484
- hints.push(`${ansi.green}↑/↓${ansi.reset} scroll`);
485
- hints.push(`${ansi.yellow}g/G${ansi.reset} top/bottom`);
486
- hints.push(`${ansi.red}q/Esc${ansi.reset} close`);
487
516
 
488
517
  const hintStr = hints.join(` ${ansi.dim}•${ansi.reset} `);
489
- const visLen = visibleWidth(hintStr);
518
+ const hintWidth = visibleWidth(hintStr);
519
+ const scrollWidth = visibleWidth(scrollStr);
490
520
 
491
- if (visLen >= width - 4) {
492
- return truncateToWidth(hintStr, width - 4);
521
+ // Calculate spacing
522
+ const gap = 4;
523
+ const totalWidth = scrollWidth + gap + hintWidth;
524
+
525
+ if (totalWidth >= width - 2) {
526
+ // Too wide, just show hints
527
+ return truncateToWidth(hintStr, width - 2);
493
528
  }
494
529
 
495
- const leftPad = Math.floor((width - visLen) / 2);
496
-
497
- return " ".repeat(leftPad) + hintStr;
530
+ const padding = " ".repeat(width - 2 - totalWidth);
531
+ return scrollStr + padding + hintStr;
498
532
  }
499
533
  }
package/types.ts CHANGED
@@ -70,7 +70,7 @@ export interface GroupSettings {
70
70
  /** Default settings */
71
71
  export const DEFAULT_SETTINGS: InfoScreenSettings = {
72
72
  showOnBoot: true,
73
- bootTimeoutMs: 2000,
73
+ bootTimeoutMs: 5000,
74
74
  groups: {},
75
75
  groupOrder: [],
76
76
  };