@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 +146 -14
- package/index.ts +42 -28
- package/package.json +1 -1
- package/settings/settings-tui.ts +52 -1
- package/tui/info-overlay.ts +167 -57
- package/types.ts +4 -1
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
//
|
|
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(
|
|
520
|
+
registered: { value: String(extension.length + sdk.length) },
|
|
388
521
|
list: {
|
|
389
|
-
value:
|
|
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 -
|
|
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:
|
|
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 =
|
|
34
|
+
const MODULE_WAIT_TIMEOUT_MS = 5000;
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
* Wait for
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
package/settings/settings-tui.ts
CHANGED
|
@@ -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;
|
package/tui/info-overlay.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
231
|
-
|
|
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
|
|
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}
|
|
238
|
-
lines.push(`${ansi.dim}├${"─".repeat(innerWidth
|
|
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}
|
|
242
|
-
lines.push(`${ansi.dim}├${"─".repeat(innerWidth
|
|
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 -
|
|
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 +
|
|
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
|
-
//
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(
|
|
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
|
-
|
|
267
|
-
lines.push(`${ansi.dim}
|
|
268
|
-
lines.push(`${ansi.dim}
|
|
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
|
|
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
|
|
501
|
+
const hintWidth = visibleWidth(hintStr);
|
|
502
|
+
const scrollWidth = visibleWidth(scrollStr);
|
|
397
503
|
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
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:
|
|
73
|
+
bootTimeoutMs: 5000,
|
|
72
74
|
groups: {},
|
|
75
|
+
groupOrder: [],
|
|
73
76
|
};
|