@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 +146 -14
- package/index.ts +19 -11
- package/package.json +1 -1
- package/tui/info-overlay.ts +66 -49
- package/types.ts +1 -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)) {
|
|
@@ -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;
|
|
@@ -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
|
-
//
|
|
267
|
-
const
|
|
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
|
|
274
|
-
lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderHeader(innerWidth, group), innerWidth
|
|
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
|
|
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 -
|
|
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 +
|
|
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
|
-
//
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
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}`);
|
|
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.
|
|
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
|
|
331
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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 =
|
|
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 =
|
|
380
|
-
const hasRight =
|
|
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
|
|
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
|
|
501
|
+
const hintWidth = visibleWidth(hintStr);
|
|
502
|
+
const scrollWidth = visibleWidth(scrollStr);
|
|
490
503
|
|
|
491
|
-
|
|
492
|
-
|
|
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
|
|
496
|
-
|
|
497
|
-
return " ".repeat(leftPad) + hintStr;
|
|
513
|
+
const padding = " ".repeat(width - 2 - totalWidth);
|
|
514
|
+
return scrollStr + padding + hintStr;
|
|
498
515
|
}
|
|
499
516
|
}
|