@pi-unipi/info-screen 0.1.1

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/index.ts ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * @pi-unipi/info-screen — Extension entry
3
+ *
4
+ * Dashboard and module registry for Unipi.
5
+ * Shows configurable info overlay on boot and via /unipi:info command.
6
+ *
7
+ * Usage:
8
+ * /unipi:info - Show info dashboard
9
+ * /unipi:info-settings - Configure info display
10
+ */
11
+
12
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
13
+ import { UNIPI_EVENTS, MODULES, UNIPI_PREFIX, emitEvent, getPackageVersion } from "@pi-unipi/core";
14
+ import { infoRegistry } from "./registry.js";
15
+ import { registerCoreGroups, trackModule, trackTool } from "./core-groups.js";
16
+
17
+ /** Re-export infoRegistry for external use */
18
+ export { infoRegistry };
19
+ import { getInfoSettings } from "./config.js";
20
+ import { InfoOverlay } from "./tui/info-overlay.js";
21
+ import { SettingsOverlay } from "./settings/settings-tui.js";
22
+
23
+ /** Package version */
24
+ const VERSION = getPackageVersion(new URL(".", import.meta.url).pathname);
25
+
26
+ /** Module ready tracking */
27
+ let moduleReady = false;
28
+ let moduleReadyResolve: (() => void) | null = null;
29
+ const moduleReadyPromise = new Promise<void>((resolve) => {
30
+ moduleReadyResolve = resolve;
31
+ });
32
+
33
+ /** Timeout for waiting for modules */
34
+ const MODULE_WAIT_TIMEOUT_MS = 2000;
35
+
36
+ /**
37
+ * Wait for all modules to announce, then return.
38
+ */
39
+ async function waitForModules(): Promise<void> {
40
+ const settings = getInfoSettings();
41
+ const timeoutMs = settings.bootTimeoutMs;
42
+
43
+ // Wait for module ready or timeout
44
+ await Promise.race([
45
+ moduleReadyPromise,
46
+ new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
47
+ ]);
48
+ }
49
+
50
+ export default function (pi: ExtensionAPI) {
51
+ // Register core groups on load
52
+ registerCoreGroups();
53
+
54
+
55
+
56
+ // Listen for module announcements
57
+ pi.events.on(UNIPI_EVENTS.MODULE_READY, (event: any) => {
58
+ if (event.name && event.name !== MODULES.INFO_SCREEN) {
59
+ // Track the module
60
+ trackModule(event.name, event.version || "unknown");
61
+
62
+ // Track tools from this module
63
+ if (event.tools && Array.isArray(event.tools)) {
64
+ for (const tool of event.tools) {
65
+ trackTool(tool, event.name);
66
+ }
67
+ }
68
+
69
+ // Signal that a module has announced
70
+ if (!moduleReady) {
71
+ moduleReady = true;
72
+ moduleReadyResolve?.();
73
+ }
74
+ }
75
+ });
76
+
77
+ // Listen for info group registrations via events
78
+ pi.events.on(UNIPI_EVENTS.INFO_GROUP_REGISTERED, (_event: any) => {
79
+ // Group already registered via globalThis in registerGroup()
80
+ });
81
+
82
+ // Also track built-in tools by intercepting tool calls
83
+ const trackedBuiltinTools = new Set<string>();
84
+ pi.on("tool_call", async (event, _ctx) => {
85
+ const toolName = event.toolName;
86
+ if (!trackedBuiltinTools.has(toolName)) {
87
+ trackedBuiltinTools.add(toolName);
88
+ trackTool(toolName, "builtin");
89
+ }
90
+ return undefined; // Don't block the tool call
91
+ });
92
+
93
+ // Session lifecycle
94
+ pi.on("session_start", async (_event, ctx) => {
95
+ const settings = getInfoSettings();
96
+
97
+ // Show dashboard on boot if enabled
98
+ if (settings.showOnBoot) {
99
+ // Wait for other modules to announce
100
+ await waitForModules();
101
+
102
+ // Show the overlay
103
+ ctx.ui.custom(
104
+ (tui, _theme, _keybindings, done) => {
105
+ const overlay = new InfoOverlay();
106
+ overlay.onClose = () => done(undefined);
107
+ // Wrap handleInput to trigger re-render after state changes
108
+ const originalHandleInput = overlay.handleInput?.bind(overlay);
109
+ overlay.handleInput = (data: string) => {
110
+ originalHandleInput?.(data);
111
+ tui.requestRender();
112
+ };
113
+ return overlay;
114
+ },
115
+ {
116
+ overlay: true,
117
+ overlayOptions: {
118
+ width: "80%",
119
+ minWidth: 60,
120
+ anchor: "center",
121
+ margin: 2,
122
+ },
123
+ }
124
+ );
125
+ }
126
+
127
+ // Announce module
128
+ emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
129
+ name: MODULES.INFO_SCREEN,
130
+ version: VERSION,
131
+ commands: ["unipi:info", "unipi:info-settings"],
132
+ tools: [],
133
+ });
134
+ });
135
+
136
+ // Register /unipi:info command
137
+ pi.registerCommand(`${UNIPI_PREFIX}info`, {
138
+ description: "Show info screen dashboard",
139
+ handler: async (_args, ctx) => {
140
+ ctx.ui.custom(
141
+ (tui, _theme, _keybindings, done) => {
142
+ const overlay = new InfoOverlay();
143
+ overlay.onClose = () => done(undefined);
144
+ const originalHandleInput = overlay.handleInput?.bind(overlay);
145
+ overlay.handleInput = (data: string) => {
146
+ originalHandleInput?.(data);
147
+ tui.requestRender();
148
+ };
149
+ return overlay;
150
+ },
151
+ {
152
+ overlay: true,
153
+ overlayOptions: {
154
+ width: "80%",
155
+ minWidth: 60,
156
+ anchor: "center",
157
+ margin: 2,
158
+ },
159
+ }
160
+ );
161
+ },
162
+ });
163
+
164
+ // Register /unipi:info-settings command
165
+ pi.registerCommand(`${UNIPI_PREFIX}info-settings`, {
166
+ description: "Configure info screen display",
167
+ handler: async (_args, ctx) => {
168
+ ctx.ui.custom(
169
+ (tui, _theme, _keybindings, done) => {
170
+ const overlay = new SettingsOverlay();
171
+ overlay.onClose = () => done(undefined);
172
+ const originalHandleInput = overlay.handleInput?.bind(overlay);
173
+ overlay.handleInput = (data: string) => {
174
+ originalHandleInput?.(data);
175
+ tui.requestRender();
176
+ };
177
+ return overlay;
178
+ },
179
+ {
180
+ overlay: true,
181
+ overlayOptions: {
182
+ width: "60%",
183
+ minWidth: 50,
184
+ anchor: "center",
185
+ margin: 2,
186
+ },
187
+ }
188
+ );
189
+ },
190
+ });
191
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@pi-unipi/info-screen",
3
+ "version": "0.1.1",
4
+ "description": "Dashboard and module registry for Unipi — configurable info overlay with tabbed groups",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Neuron Mr White",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Neuron-Mr-White/unipi.git",
11
+ "directory": "packages/info-screen"
12
+ },
13
+ "homepage": "https://github.com/Neuron-Mr-White/unipi#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/Neuron-Mr-White/unipi/issues"
16
+ },
17
+ "keywords": [
18
+ "pi-package",
19
+ "pi-extension",
20
+ "pi-coding-agent",
21
+ "unipi",
22
+ "info-screen",
23
+ "dashboard"
24
+ ],
25
+ "pi": {
26
+ "extensions": [
27
+ "index.ts"
28
+ ]
29
+ },
30
+ "files": [
31
+ "*.ts",
32
+ "tui/*.ts",
33
+ "settings/*.ts",
34
+ "README.md"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "dependencies": {
40
+ "@pi-unipi/core": "*"
41
+ },
42
+ "peerDependencies": {
43
+ "@mariozechner/pi-coding-agent": "*",
44
+ "@mariozechner/pi-tui": "*",
45
+ "@sinclair/typebox": "*"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^25.6.0"
49
+ }
50
+ }
package/registry.ts ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * @pi-unipi/info-screen — Registry
3
+ *
4
+ * Central registry for info groups. Core groups register at startup.
5
+ * External modules call registerGroup() to add their groups.
6
+ */
7
+
8
+ import type { InfoGroup, GroupData } from "./types.js";
9
+ import { getInfoSettings, isGroupEnabled, isStatEnabled } from "./config.js";
10
+
11
+ /**
12
+ * Registry for info-screen groups.
13
+ *
14
+ * Usage:
15
+ * ```ts
16
+ * import { infoRegistry } from "@pi-unipi/info-screen";
17
+ *
18
+ * // Register a group
19
+ * infoRegistry.registerGroup({
20
+ * id: "memory",
21
+ * name: "Memory",
22
+ * icon: "🧠",
23
+ * priority: 60,
24
+ * config: {
25
+ * showByDefault: true,
26
+ * stats: [
27
+ * { id: "total", label: "Total", show: true },
28
+ * ],
29
+ * },
30
+ * dataProvider: async () => ({
31
+ * total: { value: "42" },
32
+ * }),
33
+ * });
34
+ *
35
+ * // Get all registered groups
36
+ * const groups = infoRegistry.getGroups();
37
+ * ```
38
+ */
39
+ class InfoRegistry {
40
+ /** Registered groups by id */
41
+ private groups = new Map<string, InfoGroup>();
42
+
43
+ /** Cached data per group */
44
+ private dataCache = new Map<string, GroupData>();
45
+
46
+ /** Cache TTL in ms */
47
+ private cacheTtlMs = 5000;
48
+
49
+ /** Last cache update per group */
50
+ private cacheTimestamps = new Map<string, number>();
51
+
52
+ /**
53
+ * Register an info group.
54
+ * If a group with the same id exists, it's replaced.
55
+ */
56
+ registerGroup(group: InfoGroup): void {
57
+ this.groups.set(group.id, group);
58
+ }
59
+
60
+ /**
61
+ * Unregister an info group.
62
+ */
63
+ unregisterGroup(groupId: string): void {
64
+ this.groups.delete(groupId);
65
+ this.dataCache.delete(groupId);
66
+ this.cacheTimestamps.delete(groupId);
67
+ }
68
+
69
+ /**
70
+ * Get all registered groups, sorted by priority.
71
+ * Respects config — groups with show: false are excluded.
72
+ */
73
+ getGroups(): InfoGroup[] {
74
+ const settings = getInfoSettings();
75
+ const allGroups = Array.from(this.groups.values());
76
+
77
+ return allGroups
78
+ .filter((group) => {
79
+ // Check group-level visibility
80
+ const groupSettings = settings.groups[group.id];
81
+ if (groupSettings && !groupSettings.show) return false;
82
+ // If no settings, use group's default
83
+ if (!groupSettings && !group.config.showByDefault) return false;
84
+ return true;
85
+ })
86
+ .sort((a, b) => a.priority - b.priority);
87
+ }
88
+
89
+ /**
90
+ * Get all registered groups (including hidden ones).
91
+ */
92
+ getAllGroups(): InfoGroup[] {
93
+ return Array.from(this.groups.values())
94
+ .sort((a, b) => a.priority - b.priority);
95
+ }
96
+
97
+ /**
98
+ * Get a specific group by id.
99
+ */
100
+ getGroup(groupId: string): InfoGroup | undefined {
101
+ return this.groups.get(groupId);
102
+ }
103
+
104
+ /**
105
+ * Get data for a group, using cache if fresh.
106
+ */
107
+ async getGroupData(groupId: string): Promise<GroupData> {
108
+ const group = this.groups.get(groupId);
109
+ if (!group) return {};
110
+
111
+ // Check cache freshness
112
+ const now = Date.now();
113
+ const lastUpdate = this.cacheTimestamps.get(groupId) ?? 0;
114
+ if (now - lastUpdate < this.cacheTtlMs) {
115
+ const cached = this.dataCache.get(groupId);
116
+ if (cached) return cached;
117
+ }
118
+
119
+ // Fetch fresh data
120
+ try {
121
+ const data = await group.dataProvider();
122
+ this.dataCache.set(groupId, data);
123
+ this.cacheTimestamps.set(groupId, now);
124
+ return data;
125
+ } catch (error) {
126
+ // Silently fall back to cached data
127
+ return this.dataCache.get(groupId) ?? {};
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Manually update data for a group (for live updates).
133
+ */
134
+ updateGroupData(groupId: string, data: GroupData): void {
135
+ this.dataCache.set(groupId, data);
136
+ this.cacheTimestamps.set(groupId, Date.now());
137
+ }
138
+
139
+ /**
140
+ * Get filtered stats for a group based on config.
141
+ * Returns stats that are enabled in both group config and settings.
142
+ */
143
+ getVisibleStats(groupId: string): Array<{ id: string; label: string }> {
144
+ const group = this.groups.get(groupId);
145
+ if (!group) return [];
146
+
147
+ return group.config.stats.filter((stat) => {
148
+ // Check stat-level visibility from settings
149
+ if (!isStatEnabled(groupId, stat.id)) return false;
150
+ // Check stat's own default
151
+ return stat.show;
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Invalidate cache for a group.
157
+ */
158
+ invalidateCache(groupId: string): void {
159
+ this.dataCache.delete(groupId);
160
+ this.cacheTimestamps.delete(groupId);
161
+ }
162
+
163
+ /**
164
+ * Invalidate all caches.
165
+ */
166
+ invalidateAllCaches(): void {
167
+ this.dataCache.clear();
168
+ this.cacheTimestamps.clear();
169
+ }
170
+ }
171
+
172
+ /** Singleton registry instance */
173
+ export const infoRegistry = new InfoRegistry();
174
+
175
+ // Expose globally so other modules can access without direct imports
176
+ // (pi loads extensions independently, so imports may not resolve)
177
+ const globalObj = globalThis as any;
178
+ if (!globalObj.__unipi_info_registry) {
179
+ globalObj.__unipi_info_registry = infoRegistry;
180
+ }
181
+ export const getGlobalRegistry = (): InfoRegistry => {
182
+ return globalObj.__unipi_info_registry || infoRegistry;
183
+ };
@@ -0,0 +1,287 @@
1
+ /**
2
+ * @pi-unipi/info-screen — Settings TUI Component
3
+ *
4
+ * Interactive settings editor for group and stat visibility.
5
+ * Displays all groups with toggle switches.
6
+ */
7
+
8
+ import type { Component } from "@mariozechner/pi-tui";
9
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
10
+ import { infoRegistry } from "../registry.js";
11
+ import { getInfoSettings, saveInfoSettings, getGroupSettings, setGroupSettings } from "../config.js";
12
+ import type { InfoScreenSettings, GroupSettings } from "../types.js";
13
+
14
+ /** ANSI escape codes */
15
+ const ansi = {
16
+ reset: "\x1b[0m",
17
+ bold: "\x1b[1m",
18
+ dim: "\x1b[2m",
19
+ // Colors
20
+ cyan: "\x1b[36m",
21
+ green: "\x1b[32m",
22
+ yellow: "\x1b[33m",
23
+ red: "\x1b[31m",
24
+ gray: "\x1b[90m",
25
+ };
26
+
27
+ /** Toggle symbols */
28
+ const TOGGLE_ON = `${ansi.green}●${ansi.reset}`;
29
+ const TOGGLE_OFF = `${ansi.dim}○${ansi.reset}`;
30
+
31
+ /**
32
+ * Settings overlay component.
33
+ */
34
+ export class SettingsOverlay implements Component {
35
+ private settings: InfoScreenSettings;
36
+ private groups: Array<{ id: string; name: string; icon: string }>;
37
+ private selectedIndex = 0;
38
+ private mode: "groups" | "stats" = "groups";
39
+ private selectedGroupId: string | null = null;
40
+ /** Callback when overlay should close */
41
+ onClose?: () => void;
42
+
43
+ constructor() {
44
+ this.settings = getInfoSettings();
45
+ this.groups = infoRegistry.getAllGroups().map((g) => ({
46
+ id: g.id,
47
+ name: g.name,
48
+ icon: g.icon,
49
+ }));
50
+ }
51
+
52
+ /**
53
+ * Invalidate cached render state.
54
+ */
55
+ invalidate(): void {
56
+ // No cached state to invalidate
57
+ }
58
+
59
+ /**
60
+ * Handle keyboard input.
61
+ */
62
+ handleInput(data: string): void {
63
+ if (this.mode === "groups") {
64
+ this.handleGroupsInput(data);
65
+ } else {
66
+ this.handleStatsInput(data);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Handle input in groups mode.
72
+ */
73
+ private handleGroupsInput(data: string): void {
74
+ switch (data) {
75
+ case "\x1b[A": // Up
76
+ case "k":
77
+ this.selectedIndex = (this.selectedIndex - 1 + this.groups.length) % this.groups.length;
78
+ break;
79
+ case "\x1b[B": // Down
80
+ case "j":
81
+ this.selectedIndex = (this.selectedIndex + 1) % this.groups.length;
82
+ break;
83
+ case " ": // Space - toggle visibility
84
+ case "\r": // Enter - toggle visibility
85
+ this.toggleGroupVisibility(this.groups[this.selectedIndex].id);
86
+ break;
87
+ case "\x1b[C": // Right - enter stats mode
88
+ case "l":
89
+ this.enterStatsMode(this.groups[this.selectedIndex].id);
90
+ break;
91
+ case "q": // Quit
92
+ case "\x1b": // Escape
93
+ this.onClose?.();
94
+ break;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Handle input in stats mode.
100
+ */
101
+ private handleStatsInput(data: string): void {
102
+ if (!this.selectedGroupId) return;
103
+
104
+ const group = infoRegistry.getGroup(this.selectedGroupId);
105
+ if (!group) return;
106
+
107
+ switch (data) {
108
+ case "\x1b[A": // Up
109
+ case "k":
110
+ this.selectedIndex = (this.selectedIndex - 1 + group.config.stats.length) % group.config.stats.length;
111
+ break;
112
+ case "\x1b[B": // Down
113
+ case "j":
114
+ this.selectedIndex = (this.selectedIndex + 1) % group.config.stats.length;
115
+ break;
116
+ case " ": // Space - toggle stat
117
+ case "\r": // Enter - toggle stat
118
+ this.toggleStatVisibility(this.selectedGroupId, group.config.stats[this.selectedIndex].id);
119
+ break;
120
+ case "\x1b[D": // Left - back to groups
121
+ case "h":
122
+ this.mode = "groups";
123
+ this.selectedGroupId = null;
124
+ this.selectedIndex = this.groups.findIndex((g) => g.id === this.selectedGroupId) ?? 0;
125
+ break;
126
+ case "q": // Quit from stats mode
127
+ case "\x1b":
128
+ this.onClose?.();
129
+ break;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Toggle group visibility.
135
+ */
136
+ private toggleGroupVisibility(groupId: string): void {
137
+ const groupSettings = getGroupSettings(groupId);
138
+ groupSettings.show = !groupSettings.show;
139
+ setGroupSettings(groupId, groupSettings);
140
+
141
+ // Update local settings
142
+ this.settings.groups[groupId] = groupSettings;
143
+ }
144
+
145
+ /**
146
+ * Toggle stat visibility.
147
+ */
148
+ private toggleStatVisibility(groupId: string, statId: string): void {
149
+ const groupSettings = getGroupSettings(groupId);
150
+ if (!groupSettings.stats) {
151
+ groupSettings.stats = {};
152
+ }
153
+ groupSettings.stats[statId] = !(groupSettings.stats[statId] ?? true);
154
+ setGroupSettings(groupId, groupSettings);
155
+
156
+ // Update local settings
157
+ this.settings.groups[groupId] = groupSettings;
158
+ }
159
+
160
+ /**
161
+ * Enter stats editing mode for a group.
162
+ */
163
+ private enterStatsMode(groupId: string): void {
164
+ this.mode = "stats";
165
+ this.selectedGroupId = groupId;
166
+ this.selectedIndex = 0;
167
+ }
168
+
169
+ /**
170
+ * Render the component.
171
+ */
172
+ render(width: number): string[] {
173
+ if (this.mode === "groups") {
174
+ return this.renderGroupsMode(width);
175
+ } else {
176
+ return this.renderStatsMode(width);
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Render groups mode.
182
+ */
183
+ private renderGroupsMode(width: number): string[] {
184
+ const lines: string[] = [];
185
+
186
+ // Header
187
+ lines.push("");
188
+ lines.push(this.renderCentered(`${ansi.bold}⚙️ Info Screen Settings${ansi.reset}`, width));
189
+ lines.push("");
190
+
191
+ // Separator
192
+ lines.push(ansi.dim + "─".repeat(width) + ansi.reset);
193
+ lines.push("");
194
+
195
+ // Group list
196
+ for (let i = 0; i < this.groups.length; i++) {
197
+ const group = this.groups[i];
198
+ const isSelected = i === this.selectedIndex;
199
+ const groupSettings = getGroupSettings(group.id);
200
+ const isEnabled = groupSettings.show;
201
+
202
+ const toggle = isEnabled ? TOGGLE_ON : TOGGLE_OFF;
203
+ const indicator = isSelected ? `${ansi.cyan}▸${ansi.reset}` : " ";
204
+
205
+ let line = ` ${indicator} ${toggle} ${group.icon} ${group.name}`;
206
+
207
+ if (isSelected) {
208
+ line += ` ${ansi.dim}→ stats${ansi.reset}`;
209
+ }
210
+
211
+ if (visibleWidth(line) > width - 2) {
212
+ line = truncateToWidth(line, width - 2);
213
+ }
214
+
215
+ lines.push(line);
216
+ }
217
+
218
+ // Footer
219
+ lines.push("");
220
+ lines.push(ansi.dim + "─".repeat(width) + ansi.reset);
221
+ lines.push(this.renderCentered(`${ansi.dim}↑↓ select Space toggle → stats q close${ansi.reset}`, width));
222
+ lines.push("");
223
+
224
+ return lines;
225
+ }
226
+
227
+ /**
228
+ * Render stats mode.
229
+ */
230
+ private renderStatsMode(width: number): string[] {
231
+ const lines: string[] = [];
232
+ const group = this.selectedGroupId ? infoRegistry.getGroup(this.selectedGroupId) : null;
233
+
234
+ if (!group) {
235
+ lines.push(`${ansi.red}Group not found${ansi.reset}`);
236
+ return lines;
237
+ }
238
+
239
+ const groupSettings = getGroupSettings(group.id);
240
+
241
+ // Header
242
+ lines.push("");
243
+ lines.push(this.renderCentered(`${group.icon} ${group.name} Stats`, width));
244
+ lines.push("");
245
+
246
+ // Separator
247
+ lines.push(ansi.dim + "─".repeat(width) + ansi.reset);
248
+ lines.push("");
249
+
250
+ // Stats list
251
+ for (let i = 0; i < group.config.stats.length; i++) {
252
+ const stat = group.config.stats[i];
253
+ const isSelected = i === this.selectedIndex;
254
+ const isEnabled = groupSettings.stats?.[stat.id] ?? stat.show;
255
+
256
+ const toggle = isEnabled ? TOGGLE_ON : TOGGLE_OFF;
257
+ const indicator = isSelected ? `${ansi.cyan}▸${ansi.reset}` : " ";
258
+
259
+ let line = ` ${indicator} ${toggle} ${stat.label}`;
260
+
261
+ if (visibleWidth(line) > width - 2) {
262
+ line = truncateToWidth(line, width - 2);
263
+ }
264
+
265
+ lines.push(line);
266
+ }
267
+
268
+ // Footer
269
+ lines.push("");
270
+ lines.push(ansi.dim + "─".repeat(width) + ansi.reset);
271
+ lines.push(this.renderCentered(`${ansi.dim}↑↓ select Space toggle ← back q close${ansi.reset}`, width));
272
+ lines.push("");
273
+
274
+ return lines;
275
+ }
276
+
277
+ /**
278
+ * Center text within width.
279
+ */
280
+ private renderCentered(text: string, width: number): string {
281
+ const visLen = visibleWidth(text);
282
+ if (visLen >= width) return text;
283
+
284
+ const leftPad = Math.floor((width - visLen) / 2);
285
+ return " ".repeat(leftPad) + text;
286
+ }
287
+ }