@pi-unipi/info-screen 0.1.10 → 0.1.12
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/README.md +1 -0
- package/index.ts +58 -103
- package/package.json +1 -1
- package/registry.ts +141 -52
- package/tui/info-overlay.ts +274 -349
package/tui/info-overlay.ts
CHANGED
|
@@ -1,81 +1,135 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @pi-unipi/info-screen — TUI Overlay Component
|
|
2
|
+
* @pi-unipi/info-screen — TUI Overlay Component (Cache-First Reactive)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Opens immediately with cached data.
|
|
5
|
+
* Each group loads independently in the background.
|
|
6
|
+
* Reactive: re-renders as data arrives.
|
|
7
|
+
* Shows humanized "last updated" timestamps.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
import type { Component } from "@mariozechner/pi-tui";
|
|
9
|
-
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
11
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
12
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
10
13
|
import { infoRegistry } from "../registry.js";
|
|
11
14
|
import { getInfoSettings } from "../config.js";
|
|
12
15
|
import type { InfoGroup, GroupData } from "../types.js";
|
|
13
16
|
|
|
14
|
-
/** ANSI escape codes */
|
|
15
|
-
const ansi = {
|
|
16
|
-
reset: "\x1b[0m",
|
|
17
|
-
bold: "\x1b[1m",
|
|
18
|
-
dim: "\x1b[2m",
|
|
19
|
-
underline: "\x1b[4m",
|
|
20
|
-
// Colors
|
|
21
|
-
blue: "\x1b[34m",
|
|
22
|
-
cyan: "\x1b[36m",
|
|
23
|
-
green: "\x1b[32m",
|
|
24
|
-
yellow: "\x1b[33m",
|
|
25
|
-
magenta: "\x1b[35m",
|
|
26
|
-
white: "\x1b[37m",
|
|
27
|
-
red: "\x1b[31m",
|
|
28
|
-
gray: "\x1b[90m",
|
|
29
|
-
|
|
30
|
-
};
|
|
31
|
-
|
|
32
17
|
/** Tab color palette */
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
ansi.blue,
|
|
18
|
+
const TAB_FG: Array<"accent" | "success" | "warning" | "error"> = [
|
|
19
|
+
"accent",
|
|
20
|
+
"success",
|
|
21
|
+
"warning",
|
|
22
|
+
"error",
|
|
39
23
|
];
|
|
40
24
|
|
|
25
|
+
/** Humanize a duration in ms to a short string */
|
|
26
|
+
function humanizeAge(ms: number): string {
|
|
27
|
+
if (ms <= 0) return "never";
|
|
28
|
+
const seconds = Math.floor(ms / 1000);
|
|
29
|
+
if (seconds < 5) return "just now";
|
|
30
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
31
|
+
const minutes = Math.floor(seconds / 60);
|
|
32
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
33
|
+
const hours = Math.floor(minutes / 60);
|
|
34
|
+
return `${hours}h ago`;
|
|
35
|
+
}
|
|
36
|
+
|
|
41
37
|
/**
|
|
42
|
-
* Info overlay component with
|
|
38
|
+
* Info overlay component with cache-first reactive model.
|
|
43
39
|
*/
|
|
44
40
|
export class InfoOverlay implements Component {
|
|
45
41
|
private groups: InfoGroup[] = [];
|
|
46
42
|
private activeTabIndex = 0;
|
|
47
43
|
private groupData = new Map<string, GroupData>();
|
|
48
|
-
private
|
|
49
|
-
private error: string | null = null;
|
|
44
|
+
private groupLoading = new Map<string, boolean>();
|
|
50
45
|
private scrollOffset = 0;
|
|
51
|
-
/** Tab scroll offset for windowed scrolling */
|
|
52
46
|
private tabScrollOffset = 0;
|
|
53
|
-
|
|
47
|
+
private lastGlobalUpdate = 0;
|
|
48
|
+
private unsubscribers: Array<() => void> = [];
|
|
49
|
+
private _destroyed = false;
|
|
50
|
+
|
|
54
51
|
onClose?: () => void;
|
|
52
|
+
requestRender?: () => void;
|
|
53
|
+
|
|
54
|
+
private theme: Theme | null = null;
|
|
55
|
+
|
|
56
|
+
setTheme(theme: Theme): void {
|
|
57
|
+
this.theme = theme;
|
|
58
|
+
}
|
|
55
59
|
|
|
56
60
|
constructor() {
|
|
57
|
-
//
|
|
58
|
-
this.
|
|
61
|
+
// Load groups synchronously (they're already registered)
|
|
62
|
+
this.groups = infoRegistry.getAllGroups();
|
|
63
|
+
this.applyOrder();
|
|
64
|
+
|
|
65
|
+
// Seed cache with any existing data (instant display)
|
|
66
|
+
for (const group of this.groups) {
|
|
67
|
+
const cached = infoRegistry.getCachedData(group.id);
|
|
68
|
+
if (cached) {
|
|
69
|
+
this.groupData.set(group.id, cached);
|
|
70
|
+
}
|
|
71
|
+
this.groupLoading.set(group.id, true);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Subscribe to per-group updates for reactive rendering
|
|
75
|
+
this.unsubscribers.push(
|
|
76
|
+
infoRegistry.subscribeAll((groupId, data) => {
|
|
77
|
+
if (this._destroyed) return;
|
|
78
|
+
this.groupData.set(groupId, data);
|
|
79
|
+
this.groupLoading.set(groupId, false);
|
|
80
|
+
this.lastGlobalUpdate = Date.now();
|
|
81
|
+
this.requestRender?.();
|
|
82
|
+
})
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// Start background fetch for all groups (non-blocking)
|
|
86
|
+
this.fetchAllBackground();
|
|
59
87
|
}
|
|
60
88
|
|
|
61
89
|
/**
|
|
62
|
-
*
|
|
90
|
+
* Fetch all groups in background. Each resolves independently.
|
|
63
91
|
*/
|
|
64
|
-
|
|
65
|
-
|
|
92
|
+
private async fetchAllBackground(): Promise<void> {
|
|
93
|
+
for (const group of this.groups) {
|
|
94
|
+
// Fire each fetch independently — don't await sequentially
|
|
95
|
+
infoRegistry.getGroupData(group.id).then(() => {
|
|
96
|
+
this.groupLoading.set(group.id, false);
|
|
97
|
+
}).catch(() => {
|
|
98
|
+
this.groupLoading.set(group.id, false);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
66
101
|
}
|
|
67
102
|
|
|
68
103
|
/**
|
|
69
|
-
*
|
|
104
|
+
* Handle late-arriving groups (e.g., subagents announces after boot).
|
|
70
105
|
*/
|
|
71
|
-
private
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
106
|
+
private syncGroups(): void {
|
|
107
|
+
const allGroups = infoRegistry.getAllGroups();
|
|
108
|
+
if (allGroups.length !== this.groups.length) {
|
|
109
|
+
this.groups = allGroups;
|
|
110
|
+
this.applyOrder();
|
|
111
|
+
|
|
112
|
+
// Seed new groups from cache
|
|
113
|
+
for (const group of this.groups) {
|
|
114
|
+
if (!this.groupData.has(group.id)) {
|
|
115
|
+
const cached = infoRegistry.getCachedData(group.id);
|
|
116
|
+
if (cached) {
|
|
117
|
+
this.groupData.set(group.id, cached);
|
|
118
|
+
} else {
|
|
119
|
+
this.groupLoading.set(group.id, true);
|
|
120
|
+
// Fetch this new group
|
|
121
|
+
infoRegistry.getGroupData(group.id).then(() => {
|
|
122
|
+
this.groupLoading.set(group.id, false);
|
|
123
|
+
}).catch(() => {
|
|
124
|
+
this.groupLoading.set(group.id, false);
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private applyOrder(): void {
|
|
79
133
|
const settings = getInfoSettings();
|
|
80
134
|
if (settings.groupOrder && settings.groupOrder.length > 0) {
|
|
81
135
|
const order = settings.groupOrder;
|
|
@@ -85,107 +139,69 @@ export class InfoOverlay implements Component {
|
|
|
85
139
|
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
|
86
140
|
});
|
|
87
141
|
}
|
|
142
|
+
}
|
|
88
143
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
]);
|
|
97
|
-
this.groupData.set(group.id, data);
|
|
98
|
-
} catch {
|
|
99
|
-
// Use empty data on timeout/error
|
|
100
|
-
this.groupData.set(group.id, {});
|
|
101
|
-
}
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
await Promise.all(loadPromises);
|
|
105
|
-
} catch (error) {
|
|
106
|
-
this.error = error instanceof Error ? error.message : String(error);
|
|
144
|
+
/**
|
|
145
|
+
* Cleanup subscriptions.
|
|
146
|
+
*/
|
|
147
|
+
destroy(): void {
|
|
148
|
+
this._destroyed = true;
|
|
149
|
+
for (const unsub of this.unsubscribers) {
|
|
150
|
+
unsub();
|
|
107
151
|
}
|
|
152
|
+
this.unsubscribers = [];
|
|
153
|
+
}
|
|
108
154
|
|
|
109
|
-
|
|
155
|
+
invalidate(): void {
|
|
156
|
+
this.syncGroups();
|
|
110
157
|
}
|
|
111
158
|
|
|
112
|
-
/**
|
|
113
|
-
* Handle keyboard input.
|
|
114
|
-
*/
|
|
115
159
|
handleInput(data: string): void {
|
|
116
|
-
if (this.loading) return;
|
|
117
|
-
|
|
118
|
-
// Arrow keys for tab navigation
|
|
119
160
|
if (data === "\x1b[C" || data === "l") {
|
|
120
|
-
// Right arrow - switch tab
|
|
121
161
|
this.activeTabIndex = (this.activeTabIndex + 1) % this.groups.length;
|
|
122
|
-
this.scrollOffset = 0;
|
|
123
|
-
this.ensureTabVisible();
|
|
162
|
+
this.scrollOffset = 0;
|
|
124
163
|
} else if (data === "\x1b[D" || data === "h") {
|
|
125
|
-
// Left arrow - switch tab
|
|
126
164
|
this.activeTabIndex = (this.activeTabIndex - 1 + this.groups.length) % this.groups.length;
|
|
127
|
-
this.scrollOffset = 0;
|
|
128
|
-
this.ensureTabVisible();
|
|
165
|
+
this.scrollOffset = 0;
|
|
129
166
|
} else if (data === "\x1b[B" || data === "j") {
|
|
130
|
-
// Down arrow - scroll down
|
|
131
167
|
this.scrollOffset++;
|
|
132
168
|
} else if (data === "\x1b[A" || data === "k") {
|
|
133
|
-
// Up arrow - scroll up
|
|
134
169
|
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
135
170
|
} else if (data === "g") {
|
|
136
|
-
// g - go to top
|
|
137
171
|
this.scrollOffset = 0;
|
|
138
172
|
} else if (data === "G") {
|
|
139
|
-
// G - go to bottom (will be clamped in render)
|
|
140
173
|
this.scrollOffset = Infinity;
|
|
174
|
+
} else if (data === "r") {
|
|
175
|
+
// Manual refresh
|
|
176
|
+
this.refreshActiveGroup();
|
|
177
|
+
} else if (data === "R") {
|
|
178
|
+
// Refresh all
|
|
179
|
+
this.refreshAll();
|
|
141
180
|
} else if (data === "q" || data === "\x1b") {
|
|
142
|
-
|
|
181
|
+
this.destroy();
|
|
143
182
|
this.onClose?.();
|
|
144
183
|
}
|
|
145
184
|
}
|
|
146
185
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
186
|
+
private refreshActiveGroup(): void {
|
|
187
|
+
const group = this.groups[this.activeTabIndex];
|
|
188
|
+
if (!group) return;
|
|
189
|
+
this.groupLoading.set(group.id, true);
|
|
190
|
+
this.requestRender?.();
|
|
191
|
+
infoRegistry.refreshGroup(group.id);
|
|
153
192
|
}
|
|
154
193
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
render(width: number): string[] {
|
|
159
|
-
// While loading, show loading state
|
|
160
|
-
if (this.loading) {
|
|
161
|
-
return this.renderLoading(width);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (this.error) {
|
|
165
|
-
return this.renderError(width);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Check for new groups and re-fetch data
|
|
169
|
-
const allGroups = infoRegistry.getAllGroups();
|
|
170
|
-
const groupIds = allGroups.map(g => g.id).join(",");
|
|
171
|
-
const currentIds = this.groups.map(g => g.id).join(",");
|
|
172
|
-
|
|
173
|
-
if (groupIds !== currentIds || this.groups.length !== allGroups.length) {
|
|
174
|
-
this.groups = allGroups;
|
|
175
|
-
// Apply saved order
|
|
176
|
-
const settings = getInfoSettings();
|
|
177
|
-
if (settings.groupOrder && settings.groupOrder.length > 0) {
|
|
178
|
-
const order = settings.groupOrder;
|
|
179
|
-
this.groups.sort((a, b) => {
|
|
180
|
-
const ai = order.indexOf(a.id);
|
|
181
|
-
const bi = order.indexOf(b.id);
|
|
182
|
-
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
|
183
|
-
});
|
|
184
|
-
}
|
|
194
|
+
private refreshAll(): void {
|
|
195
|
+
for (const group of this.groups) {
|
|
196
|
+
this.groupLoading.set(group.id, true);
|
|
185
197
|
}
|
|
198
|
+
this.requestRender?.();
|
|
199
|
+
infoRegistry.refreshAll();
|
|
200
|
+
}
|
|
186
201
|
|
|
187
|
-
|
|
188
|
-
|
|
202
|
+
render(width: number): string[] {
|
|
203
|
+
// Sync groups in case late arrivals
|
|
204
|
+
this.syncGroups();
|
|
189
205
|
|
|
190
206
|
if (this.groups.length === 0) {
|
|
191
207
|
return this.renderEmpty(width);
|
|
@@ -194,290 +210,191 @@ export class InfoOverlay implements Component {
|
|
|
194
210
|
return this.renderDashboard(width);
|
|
195
211
|
}
|
|
196
212
|
|
|
197
|
-
|
|
198
|
-
* Load data for groups we don't have data for yet.
|
|
199
|
-
*/
|
|
200
|
-
private async loadDataForNewGroups(groups: InfoGroup[]): Promise<void> {
|
|
201
|
-
for (const group of groups) {
|
|
202
|
-
if (!this.groupData.has(group.id)) {
|
|
203
|
-
try {
|
|
204
|
-
const data = await infoRegistry.getGroupData(group.id);
|
|
205
|
-
this.groupData.set(group.id, data);
|
|
206
|
-
} catch {
|
|
207
|
-
// Silently skip groups with errors
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Callback set by wrapper to trigger re-render.
|
|
215
|
-
*/
|
|
216
|
-
requestRender?: () => void;
|
|
213
|
+
// ─── Theme helpers ───────────────────────────────────────────────────
|
|
217
214
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
// Fetch fresh data (non-blocking)
|
|
226
|
-
infoRegistry.getGroupData(group.id).then(data => {
|
|
227
|
-
const old = this.groupData.get(group.id);
|
|
228
|
-
const oldStr = JSON.stringify(old);
|
|
229
|
-
const newStr = JSON.stringify(data);
|
|
230
|
-
this.groupData.set(group.id, data);
|
|
231
|
-
// Trigger re-render if data changed
|
|
232
|
-
if (oldStr !== newStr && this.requestRender) {
|
|
233
|
-
this.requestRender();
|
|
234
|
-
}
|
|
235
|
-
}).catch(() => {
|
|
236
|
-
// Ignore errors
|
|
237
|
-
});
|
|
238
|
-
}
|
|
215
|
+
private fg(color: string, text: string): string {
|
|
216
|
+
if (this.theme) return this.theme.fg(color as any, text);
|
|
217
|
+
const c: Record<string, string> = {
|
|
218
|
+
accent: "\x1b[36m", success: "\x1b[32m", warning: "\x1b[33m",
|
|
219
|
+
error: "\x1b[31m", dim: "\x1b[2m", borderMuted: "\x1b[90m",
|
|
220
|
+
};
|
|
221
|
+
return `${c[color] ?? ""}${text}\x1b[0m`;
|
|
239
222
|
}
|
|
240
223
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
private renderLoading(width: number): string[] {
|
|
245
|
-
const lines: string[] = [];
|
|
246
|
-
const padding = " ".repeat(Math.max(0, Math.floor((width - 20) / 2)));
|
|
224
|
+
private bold(text: string): string {
|
|
225
|
+
return this.theme ? this.theme.bold(text) : `\x1b[1m${text}\x1b[0m`;
|
|
226
|
+
}
|
|
247
227
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
228
|
+
private bg(color: string, text: string): string {
|
|
229
|
+
return this.theme ? this.theme.bg(color as any, text) : text;
|
|
230
|
+
}
|
|
251
231
|
|
|
252
|
-
|
|
232
|
+
private frameLine(content: string, innerWidth: number): string {
|
|
233
|
+
const truncated = truncateToWidth(content, innerWidth, "");
|
|
234
|
+
const padding = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
235
|
+
return `${this.fg("borderMuted", "│")}${truncated}${" ".repeat(padding)}${this.fg("borderMuted", "│")}`;
|
|
253
236
|
}
|
|
254
237
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
private renderError(width: number): string[] {
|
|
259
|
-
const lines: string[] = [];
|
|
260
|
-
const padding = " ".repeat(Math.max(0, Math.floor((width - 20) / 2)));
|
|
238
|
+
private ruleLine(innerWidth: number): string {
|
|
239
|
+
return this.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`);
|
|
240
|
+
}
|
|
261
241
|
|
|
262
|
-
|
|
263
|
-
|
|
242
|
+
private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
|
|
243
|
+
const left = edge === "top" ? "┌" : "└";
|
|
244
|
+
const right = edge === "top" ? "┐" : "┘";
|
|
245
|
+
return this.fg("borderMuted", `${left}${"─".repeat(innerWidth)}${right}`);
|
|
246
|
+
}
|
|
264
247
|
|
|
265
|
-
|
|
248
|
+
private getDialogHeight(): number {
|
|
249
|
+
const terminalRows = process.stdout.rows ?? 30;
|
|
250
|
+
return Math.max(18, Math.min(32, Math.floor(terminalRows * 0.78)));
|
|
266
251
|
}
|
|
267
252
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
*/
|
|
253
|
+
// ─── State views ─────────────────────────────────────────────────────
|
|
254
|
+
|
|
271
255
|
private renderEmpty(width: number): string[] {
|
|
256
|
+
const innerWidth = Math.max(22, width - 2);
|
|
272
257
|
const lines: string[] = [];
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
lines.push(
|
|
276
|
-
lines.push("");
|
|
277
|
-
lines.push(
|
|
278
|
-
|
|
279
|
-
|
|
258
|
+
lines.push(this.borderLine(innerWidth, "top"));
|
|
259
|
+
lines.push(this.frameLine(this.fg("accent", this.bold("📊 UniPi Info Screen")), innerWidth));
|
|
260
|
+
lines.push(this.ruleLine(innerWidth));
|
|
261
|
+
lines.push(this.frameLine(this.fg("dim", "No groups registered."), innerWidth));
|
|
262
|
+
lines.push(this.frameLine(this.fg("dim", "Modules will register groups on startup."), innerWidth));
|
|
263
|
+
for (let i = 0; i < 4; i++) lines.push(this.frameLine("", innerWidth));
|
|
264
|
+
lines.push(this.ruleLine(innerWidth));
|
|
265
|
+
lines.push(this.frameLine(this.fg("dim", "q/Esc close · r refresh"), innerWidth));
|
|
266
|
+
lines.push(this.borderLine(innerWidth, "bottom"));
|
|
280
267
|
return lines;
|
|
281
268
|
}
|
|
282
269
|
|
|
283
|
-
|
|
284
|
-
* Pad a line to fill a target visual width with background.
|
|
285
|
-
*/
|
|
286
|
-
private padToWidth(line: string, targetWidth: number, bg?: string): string {
|
|
287
|
-
const visLen = visibleWidth(line);
|
|
288
|
-
const pad = Math.max(0, targetWidth - visLen);
|
|
289
|
-
if (bg) {
|
|
290
|
-
return bg + line + " ".repeat(pad) + ansi.reset;
|
|
291
|
-
}
|
|
292
|
-
return line + " ".repeat(pad);
|
|
293
|
-
}
|
|
270
|
+
// ─── Dashboard ───────────────────────────────────────────────────────
|
|
294
271
|
|
|
295
|
-
/**
|
|
296
|
-
* Render the full dashboard.
|
|
297
|
-
*/
|
|
298
272
|
private renderDashboard(width: number): string[] {
|
|
299
|
-
const
|
|
273
|
+
const innerWidth = Math.max(22, width - 2);
|
|
300
274
|
const group = this.groups[this.activeTabIndex];
|
|
301
275
|
const data = this.groupData.get(group.id) ?? {};
|
|
302
|
-
|
|
303
|
-
const innerWidth = width - 2;
|
|
276
|
+
const isLoading = this.groupLoading.get(group.id) ?? false;
|
|
304
277
|
|
|
305
|
-
// Fixed content height for stability
|
|
306
278
|
const CONTENT_HEIGHT = 12;
|
|
279
|
+
const lines: string[] = [];
|
|
307
280
|
|
|
308
|
-
|
|
309
|
-
lines.push(`${ansi.dim}╭${"─".repeat(innerWidth)}╮${ansi.reset}`);
|
|
281
|
+
lines.push(this.borderLine(innerWidth, "top"));
|
|
310
282
|
|
|
311
|
-
// Header
|
|
312
|
-
|
|
313
|
-
|
|
283
|
+
// Header: group name + loading indicator
|
|
284
|
+
const loadingDot = isLoading
|
|
285
|
+
? ` ${this.fg("warning", "●")}`
|
|
286
|
+
: ` ${this.fg("success", "●")}`;
|
|
287
|
+
const headerText = this.fg("accent", this.bold(` ${group.icon} ${group.name} `)) + loadingDot;
|
|
288
|
+
lines.push(this.frameLine(headerText, innerWidth));
|
|
289
|
+
lines.push(this.ruleLine(innerWidth));
|
|
314
290
|
|
|
315
|
-
// Tab bar
|
|
316
|
-
lines.push(
|
|
317
|
-
lines.push(
|
|
291
|
+
// Tab bar
|
|
292
|
+
lines.push(this.frameLine(this.renderTabBar(innerWidth), innerWidth));
|
|
293
|
+
lines.push(this.ruleLine(innerWidth));
|
|
318
294
|
|
|
319
|
-
// Content with scrolling
|
|
295
|
+
// Content with scrolling
|
|
320
296
|
const contentLines = this.renderGroupContent(innerWidth, group, data);
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const maxScroll = Math.max(0, contentLines.length - CONTENT_HEIGHT);
|
|
297
|
+
const wrapped = this.wrapLines(contentLines, innerWidth);
|
|
298
|
+
const maxScroll = Math.max(0, wrapped.length - CONTENT_HEIGHT);
|
|
324
299
|
this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const visibleContent = contentLines.slice(this.scrollOffset, this.scrollOffset + CONTENT_HEIGHT);
|
|
328
|
-
|
|
329
|
-
// Render content lines (pad to fixed height)
|
|
300
|
+
|
|
301
|
+
const visible = wrapped.slice(this.scrollOffset, this.scrollOffset + CONTENT_HEIGHT);
|
|
330
302
|
for (let i = 0; i < CONTENT_HEIGHT; i++) {
|
|
331
|
-
|
|
332
|
-
lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(line, innerWidth)}${ansi.dim}│${ansi.reset}`);
|
|
303
|
+
lines.push(this.frameLine(visible[i] ?? "", innerWidth));
|
|
333
304
|
}
|
|
334
305
|
|
|
335
|
-
// Footer
|
|
336
|
-
lines.push(
|
|
337
|
-
lines.push(
|
|
338
|
-
lines.push(
|
|
306
|
+
// Footer
|
|
307
|
+
lines.push(this.ruleLine(innerWidth));
|
|
308
|
+
lines.push(this.frameLine(this.renderFooter(innerWidth, wrapped.length, CONTENT_HEIGHT), innerWidth));
|
|
309
|
+
lines.push(this.borderLine(innerWidth, "bottom"));
|
|
339
310
|
|
|
340
311
|
return lines;
|
|
341
312
|
}
|
|
342
313
|
|
|
343
|
-
/**
|
|
344
|
-
* Render header with title and group info.
|
|
345
|
-
*/
|
|
346
|
-
private renderHeader(width: number, group: InfoGroup): string {
|
|
347
|
-
const title = `${group.icon} ${group.name}`;
|
|
348
|
-
const paddedTitle = ` ${title} `;
|
|
349
|
-
const visLen = visibleWidth(paddedTitle);
|
|
350
|
-
|
|
351
|
-
if (visLen >= width - 4) {
|
|
352
|
-
return ansi.bold + truncateToWidth(paddedTitle, width - 4) + ansi.reset;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// Center the title
|
|
356
|
-
const leftPad = Math.floor((width - visLen) / 2);
|
|
357
|
-
|
|
358
|
-
return " ".repeat(leftPad) + ansi.bold + paddedTitle + ansi.reset;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/**
|
|
362
|
-
* Render tab bar with windowed scrolling.
|
|
363
|
-
* Window slides only when active tab reaches the edge.
|
|
364
|
-
* Example: abcde → user on 'e' presses right → efghi
|
|
365
|
-
*/
|
|
366
314
|
private renderTabBar(width: number): string {
|
|
367
315
|
if (this.groups.length === 0) return "";
|
|
368
316
|
|
|
369
|
-
// Calculate tab widths
|
|
370
317
|
const tabWidths = this.groups.map(g => visibleWidth(` ${g.icon} ${g.name} `));
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
// Find how many tabs fit (account for potential scroll indicators)
|
|
374
|
-
const indicatorSpace = 3; // Space for ◀ or ▶
|
|
318
|
+
const sepW = visibleWidth(this.fg("borderMuted", "│"));
|
|
319
|
+
const indicatorSpace = 3;
|
|
375
320
|
let maxTabs = 0;
|
|
376
|
-
let
|
|
321
|
+
let totalW = 0;
|
|
377
322
|
for (let i = 0; i < this.groups.length; i++) {
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
if (totalWidth + sepW + tabW > width - 2 - indicatorSpace) break;
|
|
382
|
-
totalWidth += sepW + tabW;
|
|
323
|
+
const add = (i > 0 ? sepW : 0) + tabWidths[i]!;
|
|
324
|
+
if (totalW + add > width - 2 - indicatorSpace) break;
|
|
325
|
+
totalW += add;
|
|
383
326
|
maxTabs = i + 1;
|
|
384
327
|
}
|
|
385
328
|
|
|
386
|
-
// If all tabs fit, show all
|
|
387
329
|
if (maxTabs >= this.groups.length) {
|
|
388
|
-
return this.renderAllTabs(
|
|
330
|
+
return this.renderAllTabs();
|
|
389
331
|
}
|
|
390
332
|
|
|
391
|
-
// Windowed scrolling: slide only when active tab reaches edge
|
|
392
|
-
// Initialize tabScrollOffset if needed
|
|
393
|
-
if (this.tabScrollOffset === undefined) {
|
|
394
|
-
this.tabScrollOffset = 0;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Ensure active tab is visible within the window
|
|
398
333
|
if (this.activeTabIndex < this.tabScrollOffset) {
|
|
399
|
-
// Active tab is before window - slide left
|
|
400
334
|
this.tabScrollOffset = this.activeTabIndex;
|
|
401
335
|
} else if (this.activeTabIndex >= this.tabScrollOffset + maxTabs) {
|
|
402
|
-
// Active tab is after window - slide right
|
|
403
336
|
this.tabScrollOffset = this.activeTabIndex - maxTabs + 1;
|
|
404
337
|
}
|
|
405
|
-
|
|
406
|
-
// Clamp scroll offset
|
|
407
338
|
this.tabScrollOffset = Math.max(0, Math.min(this.tabScrollOffset, this.groups.length - maxTabs));
|
|
408
|
-
|
|
409
|
-
// Build visible tabs
|
|
339
|
+
|
|
410
340
|
const tabs: string[] = [];
|
|
411
341
|
for (let i = this.tabScrollOffset; i < this.tabScrollOffset + maxTabs && i < this.groups.length; i++) {
|
|
412
|
-
const
|
|
342
|
+
const g = this.groups[i]!;
|
|
413
343
|
const isActive = i === this.activeTabIndex;
|
|
414
|
-
const color =
|
|
344
|
+
const color = TAB_FG[i % TAB_FG.length]!;
|
|
345
|
+
// Per-tab loading indicator
|
|
346
|
+
const isLoading = this.groupLoading.get(g.id) ?? false;
|
|
347
|
+
const dot = isLoading ? this.fg("warning", "●") : "";
|
|
415
348
|
|
|
416
349
|
if (isActive) {
|
|
417
|
-
tabs.push(
|
|
350
|
+
tabs.push(this.fg(color, this.bold(` ${g.icon} ${g.name} ${dot}`)));
|
|
418
351
|
} else {
|
|
419
|
-
tabs.push(
|
|
352
|
+
tabs.push(this.fg("dim", ` ${g.icon} ${g.name} ${dot}`));
|
|
420
353
|
}
|
|
421
354
|
}
|
|
422
355
|
|
|
423
|
-
const tabStr = tabs.join(
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const hasLeft = this.tabScrollOffset > 0;
|
|
427
|
-
const hasRight = this.tabScrollOffset + maxTabs < this.groups.length;
|
|
428
|
-
|
|
429
|
-
if (hasLeft) {
|
|
430
|
-
return `${ansi.dim}◀${ansi.reset} ${tabStr}`;
|
|
431
|
-
}
|
|
432
|
-
if (hasRight) {
|
|
433
|
-
return `${tabStr} ${ansi.dim}▶${ansi.reset}`;
|
|
434
|
-
}
|
|
435
|
-
|
|
356
|
+
const tabStr = tabs.join(this.fg("borderMuted", "│"));
|
|
357
|
+
if (this.tabScrollOffset > 0) return `${this.fg("dim", "◀")} ${tabStr}`;
|
|
358
|
+
if (this.tabScrollOffset + maxTabs < this.groups.length) return `${tabStr} ${this.fg("dim", "▶")}`;
|
|
436
359
|
return tabStr;
|
|
437
360
|
}
|
|
438
361
|
|
|
439
|
-
|
|
440
|
-
* Render all tabs (when they all fit).
|
|
441
|
-
*/
|
|
442
|
-
private renderAllTabs(width: number): string {
|
|
362
|
+
private renderAllTabs(): string {
|
|
443
363
|
const tabs: string[] = [];
|
|
444
|
-
|
|
445
364
|
for (let i = 0; i < this.groups.length; i++) {
|
|
446
|
-
const
|
|
365
|
+
const g = this.groups[i]!;
|
|
447
366
|
const isActive = i === this.activeTabIndex;
|
|
448
|
-
const color =
|
|
367
|
+
const color = TAB_FG[i % TAB_FG.length]!;
|
|
368
|
+
const isLoading = this.groupLoading.get(g.id) ?? false;
|
|
369
|
+
const dot = isLoading ? this.fg("warning", "●") : "";
|
|
449
370
|
|
|
450
371
|
if (isActive) {
|
|
451
|
-
tabs.push(
|
|
372
|
+
tabs.push(this.fg(color, this.bold(` ${g.icon} ${g.name} ${dot}`)));
|
|
452
373
|
} else {
|
|
453
|
-
tabs.push(
|
|
374
|
+
tabs.push(this.fg("dim", ` ${g.icon} ${g.name} ${dot}`));
|
|
454
375
|
}
|
|
455
376
|
}
|
|
456
|
-
|
|
457
|
-
const tabStr = tabs.join(`${ansi.dim}│${ansi.reset}`);
|
|
458
|
-
const visLen = visibleWidth(tabStr);
|
|
459
|
-
|
|
460
|
-
// Truncate if too wide (shouldn't happen if maxTabs calculation is correct)
|
|
461
|
-
if (visLen > width - 2) {
|
|
462
|
-
return truncateToWidth(tabStr, width - 2);
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
return tabStr;
|
|
377
|
+
return tabs.join(this.fg("borderMuted", "│"));
|
|
466
378
|
}
|
|
467
379
|
|
|
468
|
-
/**
|
|
469
|
-
* Render group content.
|
|
470
|
-
*/
|
|
471
380
|
private renderGroupContent(width: number, group: InfoGroup, data: GroupData): string[] {
|
|
472
381
|
const lines: string[] = [];
|
|
382
|
+
const isLoading = this.groupLoading.get(group.id) ?? false;
|
|
473
383
|
const visibleStats = infoRegistry.getVisibleStats(group.id);
|
|
474
384
|
|
|
475
385
|
if (visibleStats.length === 0) {
|
|
476
|
-
lines.push(` ${
|
|
386
|
+
lines.push(` ${this.fg("dim", "No stats configured for this group.")}`);
|
|
387
|
+
return lines;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// If no data yet and loading, show placeholder per stat
|
|
391
|
+
if (Object.keys(data).length === 0 && isLoading) {
|
|
392
|
+
for (const stat of visibleStats) {
|
|
393
|
+
lines.push(` ${this.fg("dim", `${stat.label}:`)} ${this.fg("warning", "···")}`);
|
|
394
|
+
}
|
|
477
395
|
return lines;
|
|
478
396
|
}
|
|
479
397
|
|
|
480
|
-
// Calculate label width for alignment
|
|
481
398
|
const maxLabelLen = Math.max(...visibleStats.map((s) => s.label.length));
|
|
482
399
|
|
|
483
400
|
for (const stat of visibleStats) {
|
|
@@ -486,16 +403,13 @@ export class InfoOverlay implements Component {
|
|
|
486
403
|
const detail = statData?.detail;
|
|
487
404
|
|
|
488
405
|
const label = `${stat.label}:`.padEnd(maxLabelLen + 1);
|
|
489
|
-
let line = ` ${
|
|
406
|
+
let line = ` ${this.fg("dim", label)} ${this.bold(value)}`;
|
|
490
407
|
|
|
491
|
-
// Handle multi-line detail
|
|
492
408
|
if (detail) {
|
|
493
409
|
const detailLines = detail.split("\n");
|
|
494
410
|
if (detailLines.length === 1) {
|
|
495
|
-
|
|
496
|
-
line += ` ${ansi.dim}(${detail})${ansi.reset}`;
|
|
411
|
+
line += ` ${this.fg("dim", `(${detail})`)}`;
|
|
497
412
|
} else {
|
|
498
|
-
// Multiple lines - show value on first line, details indented below
|
|
499
413
|
lines.push(line);
|
|
500
414
|
for (const dLine of detailLines) {
|
|
501
415
|
const indent = " ".repeat(maxLabelLen + 4);
|
|
@@ -509,7 +423,6 @@ export class InfoOverlay implements Component {
|
|
|
509
423
|
}
|
|
510
424
|
}
|
|
511
425
|
|
|
512
|
-
// Truncate if too wide
|
|
513
426
|
if (visibleWidth(line) > width - 2) {
|
|
514
427
|
line = truncateToWidth(line, width - 2);
|
|
515
428
|
}
|
|
@@ -520,38 +433,50 @@ export class InfoOverlay implements Component {
|
|
|
520
433
|
return lines;
|
|
521
434
|
}
|
|
522
435
|
|
|
523
|
-
|
|
524
|
-
* Render footer with navigation hints and scroll indicator.
|
|
525
|
-
*/
|
|
526
|
-
private renderFooterWithScroll(width: number, totalLines: number, visibleHeight: number): string {
|
|
527
|
-
// Left side: scroll indicator
|
|
436
|
+
private renderFooter(width: number, totalLines: number, visibleHeight: number): string {
|
|
528
437
|
const hasScroll = totalLines > visibleHeight;
|
|
529
438
|
let scrollStr = "";
|
|
530
439
|
if (hasScroll) {
|
|
531
|
-
scrollStr =
|
|
440
|
+
scrollStr = this.fg("dim", `${this.scrollOffset + 1}-${Math.min(this.scrollOffset + visibleHeight, totalLines)}/${totalLines}`);
|
|
532
441
|
}
|
|
533
442
|
|
|
534
|
-
//
|
|
443
|
+
// Last updated for active group
|
|
444
|
+
const group = this.groups[this.activeTabIndex];
|
|
445
|
+
const lastUp = infoRegistry.getLastUpdated(group?.id ?? "");
|
|
446
|
+
const age = lastUp > 0 ? humanizeAge(Date.now() - lastUp) : "loading…";
|
|
447
|
+
|
|
535
448
|
const hints = [
|
|
536
|
-
`${
|
|
537
|
-
`${
|
|
538
|
-
`${
|
|
449
|
+
`${this.fg("accent", "←/→")} tabs`,
|
|
450
|
+
`${this.fg("success", "↑/↓")} scroll`,
|
|
451
|
+
`${this.fg("warning", "r")} refresh`,
|
|
452
|
+
`${this.fg("error", "q/Esc")} close`,
|
|
539
453
|
];
|
|
540
454
|
|
|
541
|
-
const hintStr = hints.join(` ${
|
|
542
|
-
|
|
543
|
-
|
|
455
|
+
const hintStr = hints.join(` ${this.fg("borderMuted", "•")} `);
|
|
456
|
+
|
|
457
|
+
// Build right side: age + hints
|
|
458
|
+
const ageStr = this.fg("dim", `⏱ ${age}`);
|
|
459
|
+
const rightStr = `${ageStr} ${this.fg("borderMuted", "│")} ${hintStr}`;
|
|
544
460
|
|
|
545
|
-
|
|
461
|
+
const scrollW = visibleWidth(scrollStr);
|
|
462
|
+
const rightW = visibleWidth(rightStr);
|
|
546
463
|
const gap = 4;
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
if (
|
|
550
|
-
|
|
551
|
-
return truncateToWidth(hintStr, width - 2);
|
|
464
|
+
const totalW = scrollW + gap + rightW;
|
|
465
|
+
|
|
466
|
+
if (totalW >= width - 2) {
|
|
467
|
+
return truncateToWidth(rightStr, width - 2);
|
|
552
468
|
}
|
|
553
469
|
|
|
554
|
-
const padding = " ".repeat(width - 2 -
|
|
555
|
-
return scrollStr + padding +
|
|
470
|
+
const padding = " ".repeat(Math.max(0, width - 2 - totalW));
|
|
471
|
+
return scrollStr + padding + rightStr;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private wrapLines(lines: string[], innerWidth: number): string[] {
|
|
475
|
+
const wrapped: string[] = [];
|
|
476
|
+
for (const line of lines) {
|
|
477
|
+
if (!line) { wrapped.push(""); continue; }
|
|
478
|
+
wrapped.push(...wrapTextWithAnsi(line, Math.max(1, innerWidth)));
|
|
479
|
+
}
|
|
480
|
+
return wrapped;
|
|
556
481
|
}
|
|
557
482
|
}
|