@pi-unipi/utility 0.2.3 → 0.2.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/utility",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Utility commands and tools for Pi coding agent — lifecycle, diagnostics, cache, analytics, display, batch execution",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/commands.ts CHANGED
@@ -22,6 +22,7 @@ import { runDiagnostics, formatDiagnosticsReport } from "./diagnostics/engine.js
22
22
  import { getEnvironmentInfo, formatEnvironmentInfo } from "./tools/env.js";
23
23
  import type { NameBadgeState } from "./tui/name-badge-state.js";
24
24
  import { readBadgeSettings, updateBadgeSetting, formatBadgeSettings } from "./tui/badge-settings.js";
25
+ import { BadgeSettingsTui } from "./tui/badge-settings-tui.js";
25
26
 
26
27
  /** Send a markdown response via pi.sendMessage */
27
28
  function sendResponse(pi: ExtensionAPI, markdown: string): void {
@@ -36,14 +37,14 @@ function sendResponse(pi: ExtensionAPI, markdown: string): void {
36
37
  }
37
38
 
38
39
  /**
39
- * Register name badge commands: /unipi:name-badge, /unipi:badge-gen.
40
+ * Register name badge commands: /unipi:badge-name, /unipi:badge-gen.
40
41
  */
41
42
  export function registerNameBadgeCommands(
42
43
  pi: ExtensionAPI,
43
44
  state: NameBadgeState,
44
45
  ): void {
45
- // ─── /unipi:name-badge — toggle badge overlay ───────────────────────────
46
- pi.registerCommand(`${UNIPI_PREFIX}${UTILITY_COMMANDS.NAME_BADGE}`, {
46
+ // ─── /unipi:badge-name — toggle badge overlay ───────────────────────────
47
+ pi.registerCommand(`${UNIPI_PREFIX}${UTILITY_COMMANDS.BADGE_NAME}`, {
47
48
  description: "Toggle session name badge overlay",
48
49
  handler: async (_args: string, ctx: ExtensionContext) => {
49
50
  if (!ctx.hasUI) {
@@ -95,6 +96,42 @@ export function registerNameBadgeCommands(
95
96
  sendResponse(pi, formatBadgeSettings(settings));
96
97
  },
97
98
  });
99
+
100
+ // ─── /unipi:badge-settings — TUI settings overlay ──────────────────────
101
+ pi.registerCommand(`${UNIPI_PREFIX}${UTILITY_COMMANDS.BADGE_SETTINGS}`, {
102
+ description: "Configure badge settings via TUI overlay",
103
+ handler: async (_args: string, ctx: ExtensionContext) => {
104
+ if (!ctx.hasUI) {
105
+ ctx.ui.notify("Badge settings require an interactive UI.", "warning");
106
+ return;
107
+ }
108
+
109
+ ctx.ui.custom(
110
+ (tui: any, _theme: any, _keybindings: any, done: any) => {
111
+ const overlay = new BadgeSettingsTui();
112
+ overlay.onClose = () => done(undefined);
113
+ overlay.requestRender = () => tui.requestRender();
114
+ return {
115
+ render: (w: number) => overlay.render(w),
116
+ invalidate: () => overlay.invalidate(),
117
+ handleInput: (data: string) => {
118
+ overlay.handleInput(data);
119
+ tui.requestRender();
120
+ },
121
+ };
122
+ },
123
+ {
124
+ overlay: true,
125
+ overlayOptions: {
126
+ width: "80%",
127
+ minWidth: 50,
128
+ anchor: "center",
129
+ margin: 2,
130
+ },
131
+ },
132
+ );
133
+ },
134
+ });
98
135
  }
99
136
 
100
137
  /**
package/src/index.ts CHANGED
@@ -29,6 +29,9 @@ import { getLifecycle } from "./lifecycle/process.js";
29
29
  import { getAnalyticsCollector } from "./analytics/collector.js";
30
30
  import { registerInfoScreen } from "./info-screen.js";
31
31
 
32
+ /** Re-export readBadgeSettings for cross-package use */
33
+ export { readBadgeSettings } from "./tui/badge-settings.js";
34
+
32
35
  /** Package version */
33
36
  const VERSION = getPackageVersion(new URL(".", import.meta.url).pathname);
34
37
 
@@ -43,9 +46,10 @@ const ALL_COMMANDS = [
43
46
  UTILITY_COMMANDS.CLEANUP,
44
47
  UTILITY_COMMANDS.ENV,
45
48
  UTILITY_COMMANDS.DOCTOR,
46
- UTILITY_COMMANDS.NAME_BADGE,
49
+ UTILITY_COMMANDS.BADGE_NAME,
47
50
  UTILITY_COMMANDS.BADGE_GEN,
48
51
  UTILITY_COMMANDS.BADGE_TOGGLE,
52
+ UTILITY_COMMANDS.BADGE_SETTINGS,
49
53
  ].map((cmd) => `unipi:${cmd}`);
50
54
 
51
55
  /** All tools registered by this module */
@@ -66,6 +70,8 @@ export default function (pi: ExtensionAPI) {
66
70
  // Initialize name badge state
67
71
  const nameBadgeState = new NameBadgeState();
68
72
 
73
+ // Capture session context for cross-event use (not needed if BADGE_GENERATE_REQUEST removed)
74
+
69
75
  // Register commands
70
76
  registerUtilityCommands(pi);
71
77
  registerNameBadgeCommands(pi, nameBadgeState);
@@ -89,6 +95,15 @@ export default function (pi: ExtensionAPI) {
89
95
 
90
96
  // Restore name badge if it was visible in previous session
91
97
  await nameBadgeState.restore(pi, ctx);
98
+
99
+ // Write model cache for TUI components
100
+ if ((ctx as any).modelRegistry) {
101
+ const { writeModelCache } = await import("@pi-unipi/core");
102
+ const registry = (ctx as any).modelRegistry;
103
+ const models = (registry.getAvailable?.() ?? registry.getAll())
104
+ .map((m: any) => ({ provider: m.provider, id: m.id, name: m.name }));
105
+ writeModelCache(models);
106
+ }
92
107
  });
93
108
 
94
109
  // First-message hook: auto-generate session name on first user message
@@ -127,14 +142,6 @@ export default function (pi: ExtensionAPI) {
127
142
  }
128
143
  });
129
144
 
130
- // Listen for badge generation requests from other modules (e.g., kanboard)
131
- pi.on(UNIPI_EVENTS.BADGE_GENERATE_REQUEST as any, async (_event: any, ctx: any) => {
132
- // Show badge overlay if not already visible
133
- if (!nameBadgeState.isVisible() && ctx?.hasUI) {
134
- await nameBadgeState.show(pi, ctx);
135
- }
136
- });
137
-
138
145
  // Track command usage
139
146
  pi.on("tool_call", async (event) => {
140
147
  if (event.toolName.startsWith("unipi:")) {
@@ -0,0 +1,388 @@
1
+ /**
2
+ * @pi-unipi/utility — Badge Settings TUI Overlay
3
+ *
4
+ * Interactive settings overlay for badge configuration.
5
+ * Three settings: auto-generate toggle, badge-enabled toggle, generation model selector.
6
+ * Model list loaded from shared model cache (~/.unipi/config/models-cache.json).
7
+ */
8
+
9
+ import type { Component } from "@mariozechner/pi-tui";
10
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
11
+ import type { CachedModel } from "@pi-unipi/core";
12
+ import { readModelCache } from "@pi-unipi/core";
13
+ import {
14
+ readBadgeSettings,
15
+ writeBadgeSettings,
16
+ type BadgeSettings,
17
+ } from "./badge-settings.js";
18
+
19
+ /** ANSI escape codes */
20
+ const ansi = {
21
+ reset: "\x1b[0m",
22
+ bold: "\x1b[1m",
23
+ dim: "\x1b[2m",
24
+ cyan: "\x1b[36m",
25
+ green: "\x1b[32m",
26
+ yellow: "\x1b[33m",
27
+ red: "\x1b[31m",
28
+ gray: "\x1b[90m",
29
+ white: "\x1b[37m",
30
+ };
31
+
32
+ /** Toggle symbols */
33
+ const TOGGLE_ON = `${ansi.green}●${ansi.reset}`;
34
+ const TOGGLE_OFF = `${ansi.dim}○${ansi.reset}`;
35
+
36
+ /** Active mode */
37
+ type Mode = "settings" | "model-picker";
38
+
39
+ /** Setting row types */
40
+ interface BooleanSetting {
41
+ type: "boolean";
42
+ key: keyof BadgeSettings;
43
+ label: string;
44
+ description: string;
45
+ getValue: (s: BadgeSettings) => boolean;
46
+ }
47
+
48
+ interface ModelSetting {
49
+ type: "model";
50
+ key: "generationModel";
51
+ label: string;
52
+ description: string;
53
+ }
54
+
55
+ type SettingItem = BooleanSetting | ModelSetting;
56
+
57
+ /** Settings list */
58
+ const SETTINGS: SettingItem[] = [
59
+ {
60
+ type: "boolean",
61
+ key: "autoGen",
62
+ label: "Auto generate",
63
+ description: "Generate session name on first user message",
64
+ getValue: (s) => s.autoGen,
65
+ },
66
+ {
67
+ type: "boolean",
68
+ key: "badgeEnabled",
69
+ label: "Badge enabled",
70
+ description: "Show the name badge overlay",
71
+ getValue: (s) => s.badgeEnabled,
72
+ },
73
+ {
74
+ type: "model",
75
+ key: "generationModel",
76
+ label: "Generation model",
77
+ description: "Model to use for badge name generation",
78
+ },
79
+ ];
80
+
81
+ /**
82
+ * Badge Settings TUI overlay.
83
+ * Implements the Component interface for use with ctx.ui.custom().
84
+ */
85
+ export class BadgeSettingsTui implements Component {
86
+ private settings: BadgeSettings;
87
+ private mode: Mode = "settings";
88
+ private selectedIndex = 0;
89
+ private modelScrollOffset = 0;
90
+ private models: CachedModel[] = [];
91
+
92
+ /** Theme reference for rendering (set externally) */
93
+ private _theme: any = null;
94
+
95
+ /** Callback when overlay should close */
96
+ onClose?: () => void;
97
+
98
+ /** Callback to request a re-render */
99
+ requestRender?: () => void;
100
+
101
+ constructor() {
102
+ this.settings = readBadgeSettings();
103
+ this.models = readModelCache();
104
+ }
105
+
106
+ /**
107
+ * Set the pi-tui theme for styled rendering.
108
+ */
109
+ setTheme(theme: any): void {
110
+ this._theme = theme;
111
+ }
112
+
113
+ /**
114
+ * Invalidate cached render state.
115
+ */
116
+ invalidate(): void {
117
+ // No cached state to invalidate
118
+ }
119
+
120
+ /**
121
+ * Handle keyboard input.
122
+ */
123
+ handleInput(data: string): void {
124
+ if (this.mode === "settings") {
125
+ this.handleSettingsInput(data);
126
+ } else {
127
+ this.handleModelPickerInput(data);
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Handle input in settings mode.
133
+ */
134
+ private handleSettingsInput(data: string): void {
135
+ switch (data) {
136
+ case "\x1b[A": // Up arrow
137
+ case "k":
138
+ this.selectedIndex =
139
+ (this.selectedIndex - 1 + SETTINGS.length) % SETTINGS.length;
140
+ break;
141
+ case "\x1b[B": // Down arrow
142
+ case "j":
143
+ this.selectedIndex = (this.selectedIndex + 1) % SETTINGS.length;
144
+ break;
145
+ case " ": // Space — toggle boolean settings
146
+ this.toggleCurrentSetting();
147
+ break;
148
+ case "\r": // Enter — open model picker or toggle
149
+ if (SETTINGS[this.selectedIndex].type === "model") {
150
+ this.enterModelPicker();
151
+ } else {
152
+ this.toggleCurrentSetting();
153
+ }
154
+ break;
155
+ case "\x1b": // Escape — close and save
156
+ this.save();
157
+ this.onClose?.();
158
+ break;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Handle input in model picker mode.
164
+ */
165
+ private handleModelPickerInput(data: string): void {
166
+ const allModels = this.getModelList();
167
+
168
+ switch (data) {
169
+ case "\x1b[A": // Up arrow
170
+ case "k":
171
+ this.selectedIndex =
172
+ (this.selectedIndex - 1 + allModels.length) % allModels.length;
173
+ this.adjustModelScroll(allModels.length);
174
+ break;
175
+ case "\x1b[B": // Down arrow
176
+ case "j":
177
+ this.selectedIndex = (this.selectedIndex + 1) % allModels.length;
178
+ this.adjustModelScroll(allModels.length);
179
+ break;
180
+ case "\r": // Enter — select model
181
+ this.selectModel();
182
+ break;
183
+ case "\x1b": // Escape — cancel picker
184
+ this.mode = "settings";
185
+ this.selectedIndex = SETTINGS.findIndex(
186
+ (s) => s.key === "generationModel",
187
+ );
188
+ break;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Toggle the currently selected boolean setting.
194
+ */
195
+ private toggleCurrentSetting(): void {
196
+ const item = SETTINGS[this.selectedIndex];
197
+ if (item.type !== "boolean") return;
198
+
199
+ const current = item.getValue(this.settings);
200
+ (this.settings as any)[item.key] = !current;
201
+ this.save();
202
+ }
203
+
204
+ /**
205
+ * Enter model picker mode.
206
+ */
207
+ private enterModelPicker(): void {
208
+ this.mode = "model-picker";
209
+ const allModels = this.getModelList();
210
+ const currentModel = this.settings.generationModel;
211
+ this.selectedIndex = allModels.findIndex((m) => m.id === currentModel);
212
+ if (this.selectedIndex < 0) this.selectedIndex = 0;
213
+ this.modelScrollOffset = 0;
214
+ this.adjustModelScroll(allModels.length);
215
+ }
216
+
217
+ /**
218
+ * Select the current model in the picker.
219
+ */
220
+ private selectModel(): void {
221
+ const allModels = this.getModelList();
222
+ const selected = allModels[this.selectedIndex];
223
+ if (selected) {
224
+ this.settings.generationModel = selected.id;
225
+ this.save();
226
+ }
227
+ this.mode = "settings";
228
+ this.selectedIndex = SETTINGS.findIndex((s) => s.key === "generationModel");
229
+ }
230
+
231
+ /**
232
+ * Get model list with "inherit" as first entry.
233
+ */
234
+ private getModelList(): Array<{ id: string; label: string }> {
235
+ const list: Array<{ id: string; label: string }> = [
236
+ { id: "inherit", label: "inherit (use parent model)" },
237
+ ];
238
+ for (const m of this.models) {
239
+ const fullId = `${m.provider}/${m.id}`;
240
+ list.push({
241
+ id: fullId,
242
+ label: m.name ? `${fullId} (${m.name})` : fullId,
243
+ });
244
+ }
245
+ return list;
246
+ }
247
+
248
+ /**
249
+ * Adjust scroll offset so the selected item is visible.
250
+ */
251
+ private adjustModelScroll(totalItems: number): void {
252
+ // Reserve ~10 lines for visible model list
253
+ const visibleLines = 10;
254
+ if (this.selectedIndex < this.modelScrollOffset) {
255
+ this.modelScrollOffset = this.selectedIndex;
256
+ } else if (this.selectedIndex >= this.modelScrollOffset + visibleLines) {
257
+ this.modelScrollOffset = this.selectedIndex - visibleLines + 1;
258
+ }
259
+ // Clamp
260
+ this.modelScrollOffset = Math.max(
261
+ 0,
262
+ Math.min(this.modelScrollOffset, totalItems - visibleLines),
263
+ );
264
+ }
265
+
266
+ /**
267
+ * Save settings to disk.
268
+ */
269
+ private save(): void {
270
+ writeBadgeSettings(this.settings);
271
+ }
272
+
273
+ /**
274
+ * Render the overlay.
275
+ */
276
+ render(width: number): string[] {
277
+ const lines: string[] = [];
278
+ const innerWidth = Math.max(44, width - 2);
279
+
280
+ const padVisible = (content: string, targetWidth: number): string => {
281
+ const vw = visibleWidth(content);
282
+ const pad = Math.max(0, targetWidth - vw);
283
+ return content + " ".repeat(pad);
284
+ };
285
+
286
+ const add = (s: string) =>
287
+ lines.push(
288
+ `${ansi.cyan}│${ansi.reset}` +
289
+ padVisible(truncateToWidth(s, innerWidth), innerWidth) +
290
+ `${ansi.cyan}│${ansi.reset}`,
291
+ );
292
+
293
+ const addEmpty = () =>
294
+ lines.push(
295
+ `${ansi.cyan}│${ansi.reset}` +
296
+ " ".repeat(innerWidth) +
297
+ `${ansi.cyan}│${ansi.reset}`,
298
+ );
299
+
300
+ // Top border
301
+ lines.push(`${ansi.cyan}╭${"─".repeat(innerWidth)}╮${ansi.reset}`);
302
+
303
+ // Header
304
+ add(`${ansi.bold}${ansi.cyan}Badge Settings${ansi.reset}`);
305
+ add(`${ansi.dim}Configure badge generation behavior${ansi.reset}`);
306
+ addEmpty();
307
+
308
+ // Settings list
309
+ for (let i = 0; i < SETTINGS.length; i++) {
310
+ const item = SETTINGS[i];
311
+ const isSelected = i === this.selectedIndex && this.mode === "settings";
312
+ const selector = isSelected ? `${ansi.cyan}▸${ansi.reset}` : " ";
313
+
314
+ if (item.type === "boolean") {
315
+ const value = item.getValue(this.settings);
316
+ const toggle = value ? TOGGLE_ON : TOGGLE_OFF;
317
+ const labelColor = isSelected ? ansi.bold : ansi.dim;
318
+
319
+ add(
320
+ `${selector} ${toggle} ${labelColor}${item.label}${ansi.reset}`,
321
+ );
322
+ add(` ${ansi.gray}${item.description}${ansi.reset}`);
323
+ } else if (item.type === "model") {
324
+ const labelColor = isSelected ? ansi.bold : ansi.dim;
325
+ const modelDisplay = this.settings.generationModel;
326
+
327
+ add(
328
+ `${selector} ${ansi.yellow}⚙${ansi.reset} ${labelColor}${item.label}${ansi.reset}: ${ansi.white}${modelDisplay}${ansi.reset}`,
329
+ );
330
+ add(` ${ansi.gray}${item.description}${ansi.reset}`);
331
+ add(` ${ansi.dim}Enter to select model${ansi.reset}`);
332
+ }
333
+ }
334
+
335
+ // Model picker (inline)
336
+ if (this.mode === "model-picker") {
337
+ addEmpty();
338
+ add(`${ansi.bold}${ansi.cyan}── Available Models ──${ansi.reset}`);
339
+
340
+ const allModels = this.getModelList();
341
+ const visibleLines = 10;
342
+ const start = this.modelScrollOffset;
343
+ const end = Math.min(start + visibleLines, allModels.length);
344
+
345
+ // Scroll indicator up
346
+ if (start > 0) {
347
+ add(` ${ansi.dim}▲ ${start} more above${ansi.reset}`);
348
+ }
349
+
350
+ for (let i = start; i < end; i++) {
351
+ const m = allModels[i];
352
+ const isSelected = i === this.selectedIndex;
353
+ const selector = isSelected ? `${ansi.cyan}▸${ansi.reset}` : " ";
354
+ const labelColor = isSelected ? ansi.bold + ansi.white : ansi.dim;
355
+
356
+ add(
357
+ `${selector} ${labelColor}${m.label}${ansi.reset}`,
358
+ );
359
+ }
360
+
361
+ // Scroll indicator down
362
+ if (end < allModels.length) {
363
+ add(
364
+ ` ${ansi.dim}▼ ${allModels.length - end} more below${ansi.reset}`,
365
+ );
366
+ }
367
+ }
368
+
369
+ // Footer
370
+ addEmpty();
371
+
372
+ if (this.mode === "model-picker") {
373
+ add(
374
+ `${ansi.dim}↑↓ navigate • Enter select • Esc cancel${ansi.reset}`,
375
+ );
376
+ } else {
377
+ add(
378
+ `${ansi.dim}↑↓ navigate • Space toggle • Enter select model • Esc close${ansi.reset}`,
379
+ );
380
+ add(`${ansi.dim}Config: .unipi/config/badge.json${ansi.reset}`);
381
+ }
382
+
383
+ // Bottom border
384
+ lines.push(`${ansi.cyan}╰${"─".repeat(innerWidth)}╯${ansi.reset}`);
385
+
386
+ return lines;
387
+ }
388
+ }
@@ -16,6 +16,8 @@ export interface BadgeSettings {
16
16
  badgeEnabled: boolean;
17
17
  /** Enable the set_session_name tool for agents */
18
18
  agentTool: boolean;
19
+ /** Model to use for badge name generation. "inherit" = parent model, or "provider/model-id" */
20
+ generationModel: string;
19
21
  }
20
22
 
21
23
  /** Default badge settings */
@@ -23,6 +25,7 @@ const DEFAULT_SETTINGS: BadgeSettings = {
23
25
  autoGen: true,
24
26
  badgeEnabled: true,
25
27
  agentTool: true,
28
+ generationModel: "inherit",
26
29
  };
27
30
 
28
31
  /** Badge settings file name */
@@ -48,6 +51,7 @@ export function readBadgeSettings(): BadgeSettings {
48
51
  autoGen: typeof parsed.autoGen === "boolean" ? parsed.autoGen : DEFAULT_SETTINGS.autoGen,
49
52
  badgeEnabled: typeof parsed.badgeEnabled === "boolean" ? parsed.badgeEnabled : DEFAULT_SETTINGS.badgeEnabled,
50
53
  agentTool: typeof parsed.agentTool === "boolean" ? parsed.agentTool : DEFAULT_SETTINGS.agentTool,
54
+ generationModel: typeof parsed.generationModel === "string" ? parsed.generationModel : DEFAULT_SETTINGS.generationModel,
51
55
  };
52
56
  } catch {
53
57
  return { ...DEFAULT_SETTINGS };
@@ -97,6 +101,7 @@ export function formatBadgeSettings(settings: BadgeSettings): string {
97
101
  `| Auto Generate | ${toggle(settings.autoGen)} | Generate name on first message |`,
98
102
  `| Badge Enabled | ${toggle(settings.badgeEnabled)} | Show badge overlay |`,
99
103
  `| Agent Tool | ${toggle(settings.agentTool)} | Allow agents to call set_session_name |`,
104
+ `| Generation Model | ${settings.generationModel} | Model for badge name generation |`,
100
105
  "",
101
106
  `Config: \`${BADGE_CONFIG_FILE}\``,
102
107
  ].join("\n");