@pi-unipi/info-screen 0.1.2 → 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)) {
@@ -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.3",
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;
@@ -263,46 +261,39 @@ export class InfoOverlay implements Component {
263
261
  // Inner width for content (subtract 2 for left+right borders)
264
262
  const innerWidth = width - 2;
265
263
 
266
- // Background colors
267
- const bgHeader = ansi.bgDarkGray;
268
- const bgContent = ansi.bgDarkerGray;
264
+ // Fixed content height for stability
265
+ const CONTENT_HEIGHT = 12;
269
266
 
270
267
  // Top border
271
268
  lines.push(`${ansi.dim}╭${"─".repeat(innerWidth)}╮${ansi.reset}`);
272
269
 
273
- // Header with background
274
- lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderHeader(innerWidth, group), innerWidth, bgHeader)}${ansi.dim}│${ansi.reset}`);
270
+ // Header
271
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderHeader(innerWidth, group), innerWidth)}${ansi.dim}│${ansi.reset}`);
275
272
  lines.push(`${ansi.dim}├${"─".repeat(innerWidth)}┤${ansi.reset}`);
276
273
 
277
274
  // Tab bar with horizontal scrolling
278
- lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderTabBar(innerWidth), innerWidth, bgHeader)}${ansi.dim}│${ansi.reset}`);
275
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderTabBar(innerWidth), innerWidth)}${ansi.dim}│${ansi.reset}`);
279
276
  lines.push(`${ansi.dim}├${"─".repeat(innerWidth)}┤${ansi.reset}`);
280
277
 
281
- // Content with scrolling
278
+ // Content with scrolling (fixed height)
282
279
  const contentLines = this.renderGroupContent(innerWidth, group, data);
283
- const maxVisibleLines = 15; // Max content lines visible
284
280
 
285
281
  // Clamp scroll offset
286
- const maxScroll = Math.max(0, contentLines.length - maxVisibleLines);
282
+ const maxScroll = Math.max(0, contentLines.length - CONTENT_HEIGHT);
287
283
  this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
288
284
 
289
285
  // Get visible slice
290
- const visibleContent = contentLines.slice(this.scrollOffset, this.scrollOffset + maxVisibleLines);
291
-
292
- for (const line of visibleContent) {
293
- lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(line, innerWidth, bgContent)}${ansi.dim}│${ansi.reset}`);
294
- }
286
+ const visibleContent = contentLines.slice(this.scrollOffset, this.scrollOffset + CONTENT_HEIGHT);
295
287
 
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}`);
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}`);
300
292
  }
301
293
 
302
- // Footer
303
- const hasScroll = contentLines.length > maxVisibleLines;
294
+ // Footer with scroll indicator inline
304
295
  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}`);
296
+ lines.push(`${ansi.dim}│${ansi.reset}${this.renderFooterWithScroll(innerWidth, contentLines.length, CONTENT_HEIGHT)}${ansi.dim}│${ansi.reset}`);
306
297
  lines.push(`${ansi.dim}╰${"─".repeat(innerWidth)}╯${ansi.reset}`);
307
298
 
308
299
  return lines;
@@ -327,8 +318,9 @@ export class InfoOverlay implements Component {
327
318
  }
328
319
 
329
320
  /**
330
- * Render tab bar with horizontal scrolling.
331
- * When tabs overflow, slides to keep active tab visible.
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
332
324
  */
333
325
  private renderTabBar(width: number): string {
334
326
  if (this.groups.length === 0) return "";
@@ -337,13 +329,15 @@ export class InfoOverlay implements Component {
337
329
  const tabWidths = this.groups.map(g => visibleWidth(` ${g.icon} ${g.name} `));
338
330
  const separatorWidth = visibleWidth(`${ansi.dim}│${ansi.reset}`);
339
331
 
340
- // Find how many tabs fit
332
+ // Find how many tabs fit (account for potential scroll indicators)
333
+ const indicatorSpace = 3; // Space for ◀ or ▶
341
334
  let maxTabs = 0;
342
335
  let totalWidth = 0;
343
336
  for (let i = 0; i < this.groups.length; i++) {
344
337
  const tabW = tabWidths[i]!;
345
338
  const sepW = i > 0 ? separatorWidth : 0;
346
- if (totalWidth + sepW + tabW > width - 2) break;
339
+ // Reserve space for scroll indicator on one side
340
+ if (totalWidth + sepW + tabW > width - 2 - indicatorSpace) break;
347
341
  totalWidth += sepW + tabW;
348
342
  maxTabs = i + 1;
349
343
  }
@@ -353,15 +347,27 @@ export class InfoOverlay implements Component {
353
347
  return this.renderAllTabs(width);
354
348
  }
355
349
 
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));
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));
361
367
 
362
368
  // Build visible tabs
363
369
  const tabs: string[] = [];
364
- for (let i = startIdx; i < startIdx + maxTabs && i < this.groups.length; i++) {
370
+ for (let i = this.tabScrollOffset; i < this.tabScrollOffset + maxTabs && i < this.groups.length; i++) {
365
371
  const group = this.groups[i]!;
366
372
  const isActive = i === this.activeTabIndex;
367
373
  const color = TAB_COLORS[i % TAB_COLORS.length]!;
@@ -376,8 +382,8 @@ export class InfoOverlay implements Component {
376
382
  const tabStr = tabs.join(`${ansi.dim}│${ansi.reset}`);
377
383
 
378
384
  // Add scroll indicators
379
- const hasLeft = startIdx > 0;
380
- const hasRight = startIdx + maxTabs < this.groups.length;
385
+ const hasLeft = this.tabScrollOffset > 0;
386
+ const hasRight = this.tabScrollOffset + maxTabs < this.groups.length;
381
387
 
382
388
  if (hasLeft) {
383
389
  return `${ansi.dim}◀${ansi.reset} ${tabStr}`;
@@ -474,26 +480,37 @@ export class InfoOverlay implements Component {
474
480
  }
475
481
 
476
482
  /**
477
- * Render footer with navigation hints.
483
+ * Render footer with navigation hints and scroll indicator.
478
484
  */
479
- 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
480
494
  const hints = [
481
495
  `${ansi.cyan}←/→${ansi.reset} tabs`,
496
+ `${ansi.green}↑/↓${ansi.reset} scroll`,
497
+ `${ansi.red}q/Esc${ansi.reset} close`,
482
498
  ];
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
499
 
488
500
  const hintStr = hints.join(` ${ansi.dim}•${ansi.reset} `);
489
- const visLen = visibleWidth(hintStr);
501
+ const hintWidth = visibleWidth(hintStr);
502
+ const scrollWidth = visibleWidth(scrollStr);
490
503
 
491
- if (visLen >= width - 4) {
492
- 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);
493
511
  }
494
512
 
495
- const leftPad = Math.floor((width - visLen) / 2);
496
-
497
- return " ".repeat(leftPad) + hintStr;
513
+ const padding = " ".repeat(width - 2 - totalWidth);
514
+ return scrollStr + padding + hintStr;
498
515
  }
499
516
  }
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
  };