@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 ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @pi-unipi/info-screen — Config system
3
+ *
4
+ * Reads/writes info-screen settings in ~/.pi/agent/settings.json
5
+ * under the "unipi.info" key.
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import type { InfoScreenSettings, GroupSettings } from "./types.js";
12
+ import { DEFAULT_SETTINGS } from "./types.js";
13
+
14
+ /** Settings path */
15
+ const SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");
16
+
17
+ /** Settings key within settings.json */
18
+ const SETTINGS_KEY = "unipi";
19
+
20
+ /** Cached settings */
21
+ let cachedSettings: InfoScreenSettings | null = null;
22
+
23
+ /**
24
+ * Check if value is a plain object.
25
+ */
26
+ function isRecord(value: unknown): value is Record<string, unknown> {
27
+ return typeof value === "object" && value !== null && !Array.isArray(value);
28
+ }
29
+
30
+ /**
31
+ * Read the full settings file.
32
+ */
33
+ function readSettingsFile(): Record<string, unknown> {
34
+ if (!existsSync(SETTINGS_PATH)) return {};
35
+ try {
36
+ const parsed = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
37
+ return isRecord(parsed) ? parsed : {};
38
+ } catch {
39
+ return {};
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Write the full settings file.
45
+ */
46
+ function writeSettingsFile(data: Record<string, unknown>): void {
47
+ const dir = require("node:path").dirname(SETTINGS_PATH);
48
+ if (!existsSync(dir)) {
49
+ require("node:fs").mkdirSync(dir, { recursive: true });
50
+ }
51
+ writeFileSync(SETTINGS_PATH, JSON.stringify(data, null, 2) + "\n", "utf-8");
52
+ }
53
+
54
+ /**
55
+ * Get info-screen settings from settings.json.
56
+ */
57
+ export function getInfoSettings(): InfoScreenSettings {
58
+ if (cachedSettings) return cachedSettings;
59
+
60
+ const settings = readSettingsFile();
61
+ const unipi = settings[SETTINGS_KEY];
62
+
63
+ if (!isRecord(unipi) || !isRecord(unipi.info)) {
64
+ cachedSettings = { ...DEFAULT_SETTINGS };
65
+ return cachedSettings;
66
+ }
67
+
68
+ const info = unipi.info as Record<string, unknown>;
69
+
70
+ cachedSettings = {
71
+ showOnBoot: typeof info.showOnBoot === "boolean" ? info.showOnBoot : DEFAULT_SETTINGS.showOnBoot,
72
+ bootTimeoutMs: typeof info.bootTimeoutMs === "number" ? info.bootTimeoutMs : DEFAULT_SETTINGS.bootTimeoutMs,
73
+ groups: isRecord(info.groups) ? parseGroupSettings(info.groups) : {},
74
+ };
75
+
76
+ return cachedSettings;
77
+ }
78
+
79
+ /**
80
+ * Parse group settings from raw object.
81
+ */
82
+ function parseGroupSettings(raw: Record<string, unknown>): Record<string, GroupSettings> {
83
+ const result: Record<string, GroupSettings> = {};
84
+
85
+ for (const [key, value] of Object.entries(raw)) {
86
+ if (!isRecord(value)) continue;
87
+
88
+ result[key] = {
89
+ show: typeof value.show === "boolean" ? value.show : true,
90
+ stats: isRecord(value.stats) ? parseStatSettings(value.stats) : undefined,
91
+ };
92
+ }
93
+
94
+ return result;
95
+ }
96
+
97
+ /**
98
+ * Parse stat settings from raw object.
99
+ */
100
+ function parseStatSettings(raw: Record<string, unknown>): Record<string, boolean> {
101
+ const result: Record<string, boolean> = {};
102
+ for (const [key, value] of Object.entries(raw)) {
103
+ if (typeof value === "boolean") {
104
+ result[key] = value;
105
+ }
106
+ }
107
+ return result;
108
+ }
109
+
110
+ /**
111
+ * Save info-screen settings to settings.json.
112
+ */
113
+ export function saveInfoSettings(settings: InfoScreenSettings): void {
114
+ const file = readSettingsFile();
115
+
116
+ if (!isRecord(file[SETTINGS_KEY])) {
117
+ file[SETTINGS_KEY] = {};
118
+ }
119
+
120
+ (file[SETTINGS_KEY] as Record<string, unknown>).info = {
121
+ showOnBoot: settings.showOnBoot,
122
+ bootTimeoutMs: settings.bootTimeoutMs,
123
+ groups: settings.groups,
124
+ };
125
+
126
+ writeSettingsFile(file);
127
+ cachedSettings = settings;
128
+ }
129
+
130
+ /**
131
+ * Get settings for a specific group.
132
+ */
133
+ export function getGroupSettings(groupId: string): GroupSettings {
134
+ const settings = getInfoSettings();
135
+ return settings.groups[groupId] ?? { show: true };
136
+ }
137
+
138
+ /**
139
+ * Update settings for a specific group.
140
+ */
141
+ export function setGroupSettings(groupId: string, groupSettings: GroupSettings): void {
142
+ const settings = getInfoSettings();
143
+ settings.groups[groupId] = groupSettings;
144
+ saveInfoSettings(settings);
145
+ }
146
+
147
+ /**
148
+ * Check if a group is enabled.
149
+ */
150
+ export function isGroupEnabled(groupId: string): boolean {
151
+ const settings = getInfoSettings();
152
+ if (!(groupId in settings.groups)) return true; // Default to enabled
153
+ return settings.groups[groupId].show;
154
+ }
155
+
156
+ /**
157
+ * Check if a stat within a group is enabled.
158
+ */
159
+ export function isStatEnabled(groupId: string, statId: string): boolean {
160
+ const groupSettings = getGroupSettings(groupId);
161
+ if (!groupSettings.stats) return true; // Default to enabled
162
+ if (!(statId in groupSettings.stats)) return true;
163
+ return groupSettings.stats[statId];
164
+ }
165
+
166
+ /**
167
+ * Clear cached settings (for testing or reload).
168
+ */
169
+ export function clearSettingsCache(): void {
170
+ cachedSettings = null;
171
+ }
package/core-groups.ts ADDED
@@ -0,0 +1,482 @@
1
+ /**
2
+ * @pi-unipi/info-screen — Core group registrations
3
+ *
4
+ * Registers the 5 core groups: Overview, Usage, Tools, Extensions, Skills.
5
+ * These are always available (subject to config visibility).
6
+ */
7
+
8
+ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
9
+ import { join, basename } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import { infoRegistry } from "./registry.js";
12
+ import { parseUsageStats, formatTokens, formatCost } from "./usage-parser.js";
13
+ import type { InfoGroup } from "./types.js";
14
+
15
+ /**
16
+ * Get package version from package.json.
17
+ */
18
+ function getPackageVersion(packageDir: string): string {
19
+ try {
20
+ const pkgPath = join(packageDir, "package.json");
21
+ if (!existsSync(pkgPath)) return "0.0.0";
22
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
23
+ return pkg?.version ?? "0.0.0";
24
+ } catch {
25
+ return "0.0.0";
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Get pi version from its package.json.
31
+ */
32
+ function getPiVersion(): string {
33
+ // Try to find pi's package.json in various locations
34
+ const possiblePaths = [
35
+ // Global npm install
36
+ join(homedir(), ".local", "share", "mise", "installs", "node", "24.14.1", "lib", "node_modules", "@mariozechner", "pi-coding-agent", "package.json"),
37
+ // Alternative locations
38
+ join(homedir(), ".local", "share", "mise", "installs", "node", "lib", "node_modules", "@mariozechner", "pi-coding-agent", "package.json"),
39
+ ];
40
+
41
+ for (const pkgPath of possiblePaths) {
42
+ try {
43
+ if (existsSync(pkgPath)) {
44
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
45
+ return pkg?.version ?? "unknown";
46
+ }
47
+ } catch {
48
+ // Continue to next path
49
+ }
50
+ }
51
+
52
+ // Fallback: try to run pi --version
53
+ try {
54
+ const { execSync } = require("node:child_process");
55
+ const version = execSync("pi --version 2>/dev/null", { encoding: "utf-8" }).trim();
56
+ // Extract version number from output like "pi v0.42.4"
57
+ const match = version.match(/v([\d.]+)/);
58
+ if (match) return match[1];
59
+ } catch {
60
+ // Ignore
61
+ }
62
+
63
+ return "unknown";
64
+ }
65
+
66
+ /**
67
+ * Discover loaded extensions by scanning filesystem.
68
+ */
69
+ function discoverExtensions(): Array<{ name: string; source: string; version: string }> {
70
+ const extensions: Array<{ name: string; source: string; version: string }> = [];
71
+ const homeDir = process.env.HOME || process.env.USERPROFILE || homedir();
72
+ const cwd = process.cwd();
73
+
74
+ // Check settings.json for package extensions
75
+ const settingsPaths = [
76
+ join(homeDir, ".pi", "agent", "settings.json"),
77
+ join(cwd, ".pi", "settings.json"),
78
+ ];
79
+
80
+ const counted = new Set<string>();
81
+
82
+ for (const settingsPath of settingsPaths) {
83
+ if (!existsSync(settingsPath)) continue;
84
+
85
+ try {
86
+ const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
87
+ if (typeof settings !== "object" || settings === null) continue;
88
+
89
+ const packages = settings.packages;
90
+ if (!Array.isArray(packages)) continue;
91
+
92
+ for (const pkg of packages) {
93
+ let source: string | undefined;
94
+ let extensionsFilter: string[] | undefined;
95
+
96
+ if (typeof pkg === "string") {
97
+ source = pkg;
98
+ } else if (typeof pkg === "object" && pkg !== null) {
99
+ source = pkg.source;
100
+ extensionsFilter = pkg.extensions;
101
+ }
102
+
103
+ if (!source) continue;
104
+
105
+ // Extract package name from source
106
+ let name = source;
107
+ if (source.startsWith("npm:")) {
108
+ const npmPkg = source.slice(4);
109
+ // Handle scoped packages like @scope/name
110
+ if (npmPkg.startsWith("@")) {
111
+ // @scope/name -> name
112
+ const parts = npmPkg.split("/");
113
+ name = parts.length > 1 ? parts[1] : npmPkg;
114
+ } else {
115
+ name = npmPkg.split("@")[0];
116
+ }
117
+ } else if (source.startsWith("git:")) {
118
+ name = source.split("/").pop()?.replace(/\.git$/, "") ?? source;
119
+ }
120
+
121
+ // Skip empty names
122
+ if (!name || name.trim() === "") continue;
123
+ if (counted.has(name)) continue;
124
+ counted.add(name);
125
+
126
+ extensions.push({
127
+ name,
128
+ source: source.startsWith("npm:") ? "npm" : source.startsWith("git:") ? "git" : "local",
129
+ version: "latest",
130
+ });
131
+ }
132
+ } catch {
133
+ // Skip malformed settings
134
+ }
135
+ }
136
+
137
+ // Check extension directories
138
+ const extensionDirs = [
139
+ join(homeDir, ".pi", "agent", "extensions"),
140
+ join(cwd, ".pi", "extensions"),
141
+ ];
142
+
143
+ for (const dir of extensionDirs) {
144
+ if (!existsSync(dir)) continue;
145
+
146
+ try {
147
+ const entries = readdirSync(dir, { withFileTypes: true });
148
+ for (const entry of entries) {
149
+ const name = entry.name;
150
+ if (counted.has(name)) continue;
151
+
152
+ if (entry.isFile() && name.endsWith(".ts")) {
153
+ counted.add(name.replace(".ts", ""));
154
+ extensions.push({
155
+ name: name.replace(".ts", ""),
156
+ source: "local",
157
+ version: "local",
158
+ });
159
+ } else if (entry.isDirectory()) {
160
+ counted.add(name);
161
+ extensions.push({
162
+ name,
163
+ source: "local",
164
+ version: "local",
165
+ });
166
+ }
167
+ }
168
+ } catch {
169
+ // Skip unreadable directories
170
+ }
171
+ }
172
+
173
+ return extensions;
174
+ }
175
+
176
+ /**
177
+ * Discover loaded skills by scanning filesystem.
178
+ */
179
+ function discoverSkills(): Array<{ name: string; source: string }> {
180
+ const skills: Array<{ name: string; source: string }> = [];
181
+ const homeDir = process.env.HOME || process.env.USERPROFILE || homedir();
182
+ const cwd = process.cwd();
183
+
184
+ // Skill directories to scan
185
+ const skillDirs = [
186
+ join(homeDir, ".pi", "agent", "skills"),
187
+ join(cwd, ".pi", "skills"),
188
+ ];
189
+
190
+ const counted = new Set<string>();
191
+
192
+ for (const dir of skillDirs) {
193
+ if (!existsSync(dir)) continue;
194
+
195
+ try {
196
+ const entries = readdirSync(dir, { withFileTypes: true });
197
+ for (const entry of entries) {
198
+ if (!entry.isDirectory()) continue;
199
+
200
+ const name = entry.name;
201
+ if (counted.has(name)) continue;
202
+
203
+ // Check if it has a SKILL.md
204
+ const skillPath = join(dir, name, "SKILL.md");
205
+ if (existsSync(skillPath)) {
206
+ counted.add(name);
207
+ skills.push({
208
+ name,
209
+ source: dir.includes(homeDir) ? "global" : "project",
210
+ });
211
+ }
212
+ }
213
+ } catch {
214
+ // Skip unreadable directories
215
+ }
216
+ }
217
+
218
+ return skills;
219
+ }
220
+
221
+ /**
222
+ * Track announced modules.
223
+ */
224
+ const announcedModules: Array<{ name: string; version: string }> = [];
225
+
226
+ /**
227
+ * Track registered tools.
228
+ */
229
+ const registeredTools: Array<{ name: string; source: string }> = [];
230
+
231
+ /**
232
+ * Add a module to the announced list.
233
+ */
234
+ export function trackModule(name: string, version: string): void {
235
+ if (!announcedModules.find((m) => m.name === name)) {
236
+ announcedModules.push({ name, version });
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Get list of announced modules.
242
+ */
243
+ export function getAnnouncedModules(): Array<{ name: string; version: string }> {
244
+ return [...announcedModules];
245
+ }
246
+
247
+ /**
248
+ * Track a registered tool.
249
+ */
250
+ export function trackTool(name: string, source: string): void {
251
+ if (!registeredTools.find((t) => t.name === name)) {
252
+ registeredTools.push({ name, source });
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Get list of registered tools.
258
+ */
259
+ export function getRegisteredTools(): Array<{ name: string; source: string }> {
260
+ return [...registeredTools];
261
+ }
262
+
263
+ /**
264
+ * Register all core groups.
265
+ */
266
+ export function registerCoreGroups(): void {
267
+ // 1. Overview group
268
+ infoRegistry.registerGroup({
269
+ id: "overview",
270
+ name: "Overview",
271
+ icon: "📊",
272
+ priority: 10,
273
+ config: {
274
+ showByDefault: true,
275
+ stats: [
276
+ { id: "version", label: "Pi Version", show: true },
277
+ { id: "cwd", label: "Working Directory", show: true },
278
+ { id: "modules", label: "Active Modules", show: true },
279
+ { id: "uptime", label: "Session Uptime", show: true },
280
+ ],
281
+ },
282
+ dataProvider: async () => {
283
+ const cwd = process.cwd();
284
+ const homeDir = process.env.HOME || process.env.USERPROFILE || homedir();
285
+ const shortCwd = cwd.startsWith(homeDir) ? `~${cwd.slice(homeDir.length)}` : cwd;
286
+
287
+ const modules = getAnnouncedModules();
288
+ const moduleNames = modules.map((m) => m.name.replace(/^@[^/]+\//, ""));
289
+
290
+ return {
291
+ version: { value: getPiVersion(), detail: "pi" },
292
+ cwd: { value: shortCwd },
293
+ modules: {
294
+ value: String(modules.length),
295
+ detail: moduleNames.slice(0, 4).join(", ") + (moduleNames.length > 4 ? ` +${moduleNames.length - 4} more` : ""),
296
+ },
297
+ uptime: { value: formatUptime(process.uptime()) },
298
+ };
299
+ },
300
+ });
301
+
302
+ // 2. Usage group
303
+ infoRegistry.registerGroup({
304
+ id: "usage",
305
+ name: "Usage",
306
+ icon: "💰",
307
+ priority: 20,
308
+ config: {
309
+ showByDefault: true,
310
+ stats: [
311
+ { id: "tokensToday", label: "Tokens Today", show: true },
312
+ { id: "tokensWeek", label: "Tokens This Week", show: true },
313
+ { id: "tokensMonth", label: "Tokens This Month", show: true },
314
+ { id: "costToday", label: "Cost Today", show: true },
315
+ { id: "costAllTime", label: "Cost All Time", show: true },
316
+ { id: "topModelToday", label: "Top Model Today", show: true },
317
+ { id: "topModelWeek", label: "Top Model Week", show: true },
318
+ { id: "topModelMonth", label: "Top Model Month", show: true },
319
+ { id: "sessions", label: "Total Sessions", show: true },
320
+ ],
321
+ },
322
+ dataProvider: async () => {
323
+ const stats = parseUsageStats();
324
+
325
+ // Find top model for each period
326
+ const findTopModel = (modelStats: Record<string, { tokens: number; cost: number; sessions: number }> | undefined) => {
327
+ if (!modelStats) return { name: "none", cost: 0 };
328
+ let topName = "none";
329
+ let topCost = 0;
330
+ for (const [model, data] of Object.entries(modelStats)) {
331
+ if (data.cost > topCost) {
332
+ topCost = data.cost;
333
+ topName = model;
334
+ }
335
+ }
336
+ // Strip "Claude " prefix for brevity
337
+ if (topName.startsWith("Claude ")) {
338
+ topName = topName.slice(7);
339
+ }
340
+ return { name: topName, cost: topCost };
341
+ };
342
+
343
+ const topToday = findTopModel(stats.byModelToday);
344
+ const topWeek = findTopModel(stats.byModelWeek);
345
+ const topMonth = findTopModel(stats.byModelMonth);
346
+
347
+ return {
348
+ tokensToday: { value: formatTokens(stats.tokens.today) },
349
+ tokensWeek: { value: formatTokens(stats.tokens.week) },
350
+ tokensMonth: { value: formatTokens(stats.tokens.month) },
351
+ costToday: { value: formatCost(stats.cost.today) },
352
+ costAllTime: { value: formatCost(stats.cost.allTime) },
353
+ topModelToday: { value: topToday.name, detail: formatCost(topToday.cost) },
354
+ topModelWeek: { value: topWeek.name, detail: formatCost(topWeek.cost) },
355
+ topModelMonth: { value: topMonth.name, detail: formatCost(topMonth.cost) },
356
+ sessions: { value: String(stats.sessionCount) },
357
+ };
358
+ },
359
+ });
360
+
361
+ // 3. Tools group
362
+ infoRegistry.registerGroup({
363
+ id: "tools",
364
+ name: "Tools",
365
+ icon: "🔧",
366
+ priority: 30,
367
+ config: {
368
+ showByDefault: true,
369
+ stats: [
370
+ { id: "total", label: "Total Tools", show: true },
371
+ { id: "builtin", label: "Built-in", show: true },
372
+ { id: "registered", label: "Registered", show: true },
373
+ { id: "list", label: "Tools", show: true },
374
+ ],
375
+ },
376
+ dataProvider: async () => {
377
+ const tools = getRegisteredTools();
378
+ const builtin = tools.filter((t) => t.source === "builtin");
379
+ const custom = tools.filter((t) => t.source === "registered");
380
+
381
+ // Build tool list - show all
382
+ const toolNames = tools.map((t) => `${t.name}`);
383
+
384
+ return {
385
+ total: { value: String(tools.length) },
386
+ builtin: { value: String(builtin.length) },
387
+ registered: { value: String(custom.length) },
388
+ list: {
389
+ value: toolNames.length > 0 ? toolNames[0] : "none",
390
+ detail: toolNames.length > 1 ? toolNames.slice(1).join("\n") : undefined,
391
+ },
392
+ };
393
+ },
394
+ });
395
+
396
+ // 4. Extensions group
397
+ infoRegistry.registerGroup({
398
+ id: "extensions",
399
+ name: "Extensions",
400
+ icon: "📦",
401
+ priority: 40,
402
+ config: {
403
+ showByDefault: true,
404
+ stats: [
405
+ { id: "count", label: "Total Extensions", show: true },
406
+ { id: "list", label: "Extensions", show: true },
407
+ ],
408
+ },
409
+ dataProvider: async () => {
410
+ const extensions = discoverExtensions();
411
+ const bySource: Record<string, number> = {};
412
+ for (const ext of extensions) {
413
+ bySource[ext.source] = (bySource[ext.source] ?? 0) + 1;
414
+ }
415
+
416
+ const breakdown = Object.entries(bySource)
417
+ .map(([src, count]) => `${count} ${src}`)
418
+ .join(", ");
419
+
420
+ // Build multi-line list - show all
421
+ const listLines: string[] = [];
422
+ for (const ext of extensions) {
423
+ listLines.push(`${ext.name} (${ext.source})`);
424
+ }
425
+
426
+ return {
427
+ count: { value: String(extensions.length), detail: breakdown || "none" },
428
+ list: {
429
+ value: listLines.length > 0 ? listLines[0] : "none",
430
+ detail: listLines.length > 1 ? listLines.slice(1).join("\n") : undefined,
431
+ },
432
+ };
433
+ },
434
+ });
435
+
436
+ // 5. Skills group
437
+ infoRegistry.registerGroup({
438
+ id: "skills",
439
+ name: "Skills",
440
+ icon: "🎯",
441
+ priority: 50,
442
+ config: {
443
+ showByDefault: true,
444
+ stats: [
445
+ { id: "count", label: "Total Skills", show: true },
446
+ { id: "global", label: "Global Skills", show: true },
447
+ { id: "project", label: "Project Skills", show: true },
448
+ { id: "list", label: "Skills", show: true },
449
+ ],
450
+ },
451
+ dataProvider: async () => {
452
+ const skills = discoverSkills();
453
+ const global = skills.filter((s) => s.source === "global");
454
+ const project = skills.filter((s) => s.source === "project");
455
+
456
+ // Build skill list - show all
457
+ const skillNames = skills.map((s) => `${s.name} (${s.source})`);
458
+
459
+ return {
460
+ count: { value: String(skills.length) },
461
+ global: { value: String(global.length) },
462
+ project: { value: String(project.length) },
463
+ list: {
464
+ value: skillNames.length > 0 ? skillNames[0] : "none",
465
+ detail: skillNames.length > 1 ? skillNames.slice(1).join("\n") : undefined,
466
+ },
467
+ };
468
+ },
469
+ });
470
+ }
471
+
472
+ /**
473
+ * Format uptime for display.
474
+ */
475
+ function formatUptime(seconds: number): string {
476
+ const hours = Math.floor(seconds / 3600);
477
+ const minutes = Math.floor((seconds % 3600) / 60);
478
+
479
+ if (hours > 0) return `${hours}h ${minutes}m`;
480
+ if (minutes > 0) return `${minutes}m`;
481
+ return `${Math.floor(seconds)}s`;
482
+ }