@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 +189 -14
- package/index.ts +19 -11
- package/package.json +1 -1
- package/tui/info-overlay.ts +87 -53
- package/types.ts +1 -1
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
//
|
|
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(
|
|
550
|
+
registered: { value: String(extension.length + sdk.length) },
|
|
388
551
|
list: {
|
|
389
|
-
value:
|
|
390
|
-
detail:
|
|
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 -
|
|
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:
|
|
465
|
-
detail:
|
|
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 =
|
|
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);
|
|
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
package/tui/info-overlay.ts
CHANGED
|
@@ -26,9 +26,7 @@ const ansi = {
|
|
|
26
26
|
white: "\x1b[37m",
|
|
27
27
|
red: "\x1b[31m",
|
|
28
28
|
gray: "\x1b[90m",
|
|
29
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
267
|
-
const
|
|
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
|
|
274
|
-
lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderHeader(innerWidth, group), innerWidth
|
|
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
|
|
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 -
|
|
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 +
|
|
303
|
+
const visibleContent = contentLines.slice(this.scrollOffset, this.scrollOffset + CONTENT_HEIGHT);
|
|
291
304
|
|
|
292
|
-
|
|
293
|
-
|
|
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.
|
|
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
|
|
331
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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 =
|
|
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 =
|
|
380
|
-
const hasRight =
|
|
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
|
|
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
|
|
518
|
+
const hintWidth = visibleWidth(hintStr);
|
|
519
|
+
const scrollWidth = visibleWidth(scrollStr);
|
|
490
520
|
|
|
491
|
-
|
|
492
|
-
|
|
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
|
|
496
|
-
|
|
497
|
-
return " ".repeat(leftPad) + hintStr;
|
|
530
|
+
const padding = " ".repeat(width - 2 - totalWidth);
|
|
531
|
+
return scrollStr + padding + hintStr;
|
|
498
532
|
}
|
|
499
533
|
}
|