@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/config.ts +171 -0
- package/core-groups.ts +482 -0
- package/index.ts +191 -0
- package/package.json +50 -0
- package/registry.ts +183 -0
- package/settings/settings-tui.ts +287 -0
- package/tui/info-overlay.ts +406 -0
- package/types.ts +73 -0
- package/usage-parser.ts +308 -0
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
|
+
}
|