@pi-unipi/unipi 0.1.11 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -93,16 +93,17 @@ export const COMMAND_REGISTRY: Record<string, string> = {
93
93
  "unipi:mcp-settings": "mcp",
94
94
  "unipi:mcp-reload": "mcp",
95
95
 
96
- // utility (6 commands)
96
+ // utility (10 commands)
97
97
  "unipi:continue": "utility",
98
98
  "unipi:reload": "utility",
99
99
  "unipi:status": "utility",
100
100
  "unipi:cleanup": "utility",
101
101
  "unipi:env": "utility",
102
102
  "unipi:doctor": "utility",
103
- "unipi:name-badge": "utility",
103
+ "unipi:badge-name": "utility",
104
104
  "unipi:badge-gen": "utility",
105
105
  "unipi:badge-toggle": "utility",
106
+ "unipi:badge-settings": "utility",
106
107
 
107
108
  // ask-user (1 command)
108
109
  "unipi:ask-user-settings": "ask-user",
@@ -136,11 +137,9 @@ export const COMMAND_REGISTRY: Record<string, string> = {
136
137
  "unipi:notify-set-tg": "notify",
137
138
  "unipi:notify-test": "notify",
138
139
 
139
- // kanboard (4 commands)
140
+ // kanboard (3 commands)
140
141
  "unipi:kanboard": "kanboard",
141
142
  "unipi:kanboard-doctor": "kanboard",
142
- "unipi:kanboard-settings": "kanboard",
143
- "unipi:name-gen": "kanboard",
144
143
  };
145
144
 
146
145
  // ─── Description Map ─────────────────────────────────────────────────
@@ -190,11 +189,12 @@ export const COMMAND_DESCRIPTIONS: Record<string, string> = {
190
189
  "unipi:cleanup": "Clean up old sessions and cache",
191
190
  "unipi:env": "Show environment info",
192
191
  "unipi:doctor": "Run diagnostics",
193
- "unipi:name-badge": "Toggle session name badge overlay",
192
+ "unipi:badge-name": "Toggle session name badge overlay",
194
193
  "unipi:badge-gen": "Generate session name via background agent",
195
194
  "unipi:badge-toggle": "Configure badge settings (autoGen, badgeEnabled, agentTool)",
196
- "unipi:name-gen": "Generate session name badge from kanboard context",
197
- "unipi:kanboard-settings": "Configure kanboard module settings",
195
+ "unipi:badge-settings": "Configure badge settings via TUI overlay",
196
+ "unipi:kanboard": "Start the kanboard visualization server",
197
+ "unipi:kanboard-doctor": "Diagnose and fix kanboard parser issues",
198
198
 
199
199
  "unipi:ask-user-settings": "Configure ask-user settings",
200
200
 
@@ -219,9 +219,6 @@ export const COMMAND_DESCRIPTIONS: Record<string, string> = {
219
219
  "unipi:notify-set-tg": "Set up Telegram bot notifications",
220
220
  "unipi:notify-test": "Test all enabled notification platforms",
221
221
 
222
- "unipi:kanboard": "Start the kanboard visualization server",
223
- "unipi:kanboard-doctor": "Diagnose and fix kanboard parser issues",
224
-
225
222
  "unipi:milestone-onboard": "Create MILESTONES.md from existing workflow docs",
226
223
  "unipi:milestone-update": "Sync MILESTONES.md with completed work",
227
224
  };
@@ -8,3 +8,4 @@ export * from "./constants.js";
8
8
  export * from "./events.js";
9
9
  export * from "./sandbox.js";
10
10
  export * from "./utils.js";
11
+ export * from "./model-cache.js";
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Test: Badge Generation Flow
3
+ *
4
+ * Tests the full badge name generation flow to identify and verify
5
+ * fixes for "Generating session name..." getting stuck.
6
+ *
7
+ * BUG 1 — Tool mismatch:
8
+ * Background agent was told to "Call the set_session_name tool" but the tool
9
+ * doesn't exist in the agent's session (only builtin tools available).
10
+ * FIX: Changed prompt to output title directly, parse in onComplete callback.
11
+ *
12
+ * BUG 2 — Wrong event bus:
13
+ * Cross-module events emitted via pi.events.emit() but listeners used pi.on()
14
+ * (extension lifecycle events) — completely different event bus.
15
+ * FIX: Changed all cross-module listeners to pi.events.on().
16
+ */
17
+
18
+ import { describe, it } from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { readFileSync, existsSync } from "node:fs";
21
+ import { join } from "node:path";
22
+
23
+ const ROOT = join(import.meta.dirname, "../../../..");
24
+
25
+ // ─── Helpers ────────────────────────────────────────────────────────
26
+
27
+ function readSource(relativePath: string): string {
28
+ const fullPath = join(ROOT, relativePath);
29
+ if (!existsSync(fullPath)) throw new Error(`File not found: ${fullPath}`);
30
+ return readFileSync(fullPath, "utf-8");
31
+ }
32
+
33
+ // ─── Test: Tool availability in spawned agent ──────────────────────
34
+
35
+ describe("Badge generation — tool availability", () => {
36
+ it("agent-runner uses only builtin tools, NOT extension-registered tools", () => {
37
+ const src = readSource("packages/subagents/src/agent-runner.ts");
38
+
39
+ const builtinMatch = src.match(
40
+ /const BUILTIN_TOOL_NAMES\s*=\s*(\[.*?\])/s,
41
+ );
42
+ assert.ok(builtinMatch, "BUILTIN_TOOL_NAMES should be defined");
43
+
44
+ const builtinTools: string[] = eval(builtinMatch[1]);
45
+ assert.deepStrictEqual(builtinTools, [
46
+ "read", "bash", "edit", "write", "grep", "find", "ls",
47
+ ]);
48
+ });
49
+
50
+ it("set_session_name is NOT in the agent's tool list", () => {
51
+ const src = readSource("packages/subagents/src/agent-runner.ts");
52
+ const builtinMatch = src.match(
53
+ /const BUILTIN_TOOL_NAMES\s*=\s*(\[.*?\])/s,
54
+ );
55
+ const tools: string[] = eval(builtinMatch![1]);
56
+ assert.ok(!tools.includes("set_session_name"));
57
+ });
58
+ });
59
+
60
+ // ─── Test: Prompt no longer references non-existent tool ───────────
61
+
62
+ describe("Badge generation — prompt fix", () => {
63
+ it("prompt asks agent to OUTPUT the title directly (not call a tool)", () => {
64
+ const src = readSource("packages/subagents/src/index.ts");
65
+
66
+ assert.ok(
67
+ src.includes("Reply with ONLY the title"),
68
+ "Prompt should ask agent to reply with only the title",
69
+ );
70
+
71
+ assert.ok(
72
+ !src.includes("Call the set_session_name tool"),
73
+ "Prompt should NOT tell agent to call set_session_name",
74
+ );
75
+ });
76
+ });
77
+
78
+ // ─── Test: onComplete extracts name from result ────────────────────
79
+
80
+ describe("Badge generation — onComplete callback", () => {
81
+ it("onComplete extracts name from agent result and calls pi.setSessionName", () => {
82
+ const src = readSource("packages/subagents/src/index.ts");
83
+
84
+ assert.ok(
85
+ src.includes('record.description === "Generate session name"'),
86
+ "Should detect badge generation agents by description",
87
+ );
88
+
89
+ assert.ok(
90
+ src.includes("pi.setSessionName(name)"),
91
+ "Should call pi.setSessionName with extracted name",
92
+ );
93
+ });
94
+ });
95
+
96
+ // ─── Test: Cross-module event bus — the critical fix ───────────────
97
+
98
+ describe("Badge generation — event bus (CRITICAL FIX)", () => {
99
+ it("emitEvent uses pi.events.emit (not pi.on)", () => {
100
+ const src = readSource("packages/core/utils.ts");
101
+
102
+ assert.ok(
103
+ src.includes("pi.events.emit(eventName, payload)"),
104
+ "emitEvent should use pi.events.emit()",
105
+ );
106
+ });
107
+
108
+ it("subagents listens via pi.events.on (NOT pi.on)", () => {
109
+ const src = readSource("packages/subagents/src/index.ts");
110
+
111
+ // Must use pi.events.on for cross-module events
112
+ assert.ok(
113
+ src.includes("pi.events.on(UNIPI_EVENTS.BADGE_GENERATE_REQUEST"),
114
+ "Subagents should listen via pi.events.on",
115
+ );
116
+
117
+ // Should NOT use pi.on for custom events
118
+ const piOnMatch = src.match(/pi\.on\(UNIPI_EVENTS\.BADGE_GENERATE_REQUEST/g);
119
+ assert.ok(!piOnMatch, "Should NOT use pi.on() for cross-module events");
120
+ });
121
+
122
+ it("utility BADGE_GENERATE_REQUEST listener is removed (input handler already shows overlay)", () => {
123
+ const src = readSource("packages/utility/src/index.ts");
124
+
125
+ // Should NOT have a separate BADGE_GENERATE_REQUEST listener
126
+ // The input handler already shows the overlay and emits the event
127
+ assert.ok(
128
+ !src.includes("pi.events.on(UNIPI_EVENTS.BADGE_GENERATE_REQUEST"),
129
+ "Utility should NOT have a separate BADGE_GENERATE_REQUEST listener",
130
+ );
131
+ });
132
+
133
+ it("workflow listens for MODULE_READY via pi.events.on (NOT pi.on)", () => {
134
+ const src = readSource("packages/workflow/index.ts");
135
+
136
+ assert.ok(
137
+ src.includes("pi.events.on(UNIPI_EVENTS.MODULE_READY"),
138
+ "Workflow should listen via pi.events.on",
139
+ );
140
+
141
+ const piOnMatch = src.match(/pi\.on\(UNIPI_EVENTS\.MODULE_READY/g);
142
+ assert.ok(!piOnMatch, "Should NOT use pi.on() for cross-module events");
143
+ });
144
+
145
+ it("pi.on() is ONLY used for known lifecycle events", () => {
146
+ const subagentsSrc = readSource("packages/subagents/src/index.ts");
147
+ const utilitySrc = readSource("packages/utility/src/index.ts");
148
+
149
+ // These are valid lifecycle events that should use pi.on()
150
+ const validLifecycleEvents = [
151
+ "session_start", "session_shutdown", "input",
152
+ "tool_call", "tool_execution_start",
153
+ ];
154
+
155
+ // Check that pi.on() is only used with lifecycle events
156
+ const piOnPattern = /pi\.on\("([^"]+)"/g;
157
+ let match;
158
+ while ((match = piOnPattern.exec(subagentsSrc)) !== null) {
159
+ assert.ok(
160
+ validLifecycleEvents.includes(match[1]),
161
+ `subagents: pi.on("${match[1]}") should be a lifecycle event, use pi.events.on() for custom events`,
162
+ );
163
+ }
164
+ while ((match = piOnPattern.exec(utilitySrc)) !== null) {
165
+ assert.ok(
166
+ validLifecycleEvents.includes(match[1]),
167
+ `utility: pi.on("${match[1]}") should be a lifecycle event`,
168
+ );
169
+ }
170
+ });
171
+ });
172
+
173
+ // ─── Test: Event flow ──────────────────────────────────────────────
174
+
175
+ describe("Badge generation — event flow", () => {
176
+ it("utility emits BADGE_GENERATE_REQUEST on first input", () => {
177
+ const src = readSource("packages/utility/src/index.ts");
178
+
179
+ assert.ok(src.includes("BADGE_GENERATE_REQUEST"));
180
+ assert.ok(src.includes('source: "input-hook"'));
181
+ });
182
+
183
+ it("BADGE_GENERATE_REQUEST event is defined in core", () => {
184
+ const src = readSource("packages/core/events.ts");
185
+ assert.ok(src.includes("BADGE_GENERATE_REQUEST"));
186
+ });
187
+ });
188
+
189
+ // ─── Test: Model resolution ────────────────────────────────────────
190
+
191
+ describe("Badge generation — model resolution", () => {
192
+ it("reads generationModel from badge.json instead of hardcoding", () => {
193
+ const src = readSource("packages/subagents/src/index.ts");
194
+
195
+ assert.ok(!src.includes('"openai/gpt-oss-20b"'));
196
+ assert.ok(src.includes(".unipi/config/badge.json"));
197
+ assert.ok(src.includes("parsed.generationModel"));
198
+ });
199
+ });
200
+
201
+ // ─── Summary ───────────────────────────────────────────────────────
202
+
203
+ describe("Badge generation — ROOT CAUSE SUMMARY", () => {
204
+ it("BUG 1 FIXED: prompt no longer references non-existent tool", () => {
205
+ const src = readSource("packages/subagents/src/index.ts");
206
+
207
+ assert.ok(!src.includes("Call the set_session_name tool"),
208
+ "FIXED: prompt no longer tells agent to call set_session_name");
209
+ assert.ok(src.includes("Reply with ONLY the title"),
210
+ "FIXED: prompt asks agent to reply with only the title");
211
+ });
212
+
213
+ it("BUG 1 FIXED: onComplete extracts name and sets it directly", () => {
214
+ const src = readSource("packages/subagents/src/index.ts");
215
+
216
+ assert.ok(src.includes('record.description === "Generate session name"'),
217
+ "FIXED: onComplete detects badge generation agents");
218
+ assert.ok(src.includes("pi.setSessionName(name)"),
219
+ "FIXED: onComplete calls pi.setSessionName directly");
220
+ });
221
+
222
+ it("BUG 2 FIXED: cross-module events use pi.events.on, not pi.on", () => {
223
+ const subagentsSrc = readSource("packages/subagents/src/index.ts");
224
+ const utilitySrc = readSource("packages/utility/src/index.ts");
225
+ const workflowSrc = readSource("packages/workflow/index.ts");
226
+
227
+ // Subagents: correct event bus
228
+ assert.ok(
229
+ subagentsSrc.includes("pi.events.on(UNIPI_EVENTS.BADGE_GENERATE_REQUEST"),
230
+ "subagents: must use pi.events.on for BADGE_GENERATE_REQUEST",
231
+ );
232
+
233
+ // Utility: no duplicate listener (input handler already handles it)
234
+ assert.ok(
235
+ !utilitySrc.includes("pi.events.on(UNIPI_EVENTS.BADGE_GENERATE_REQUEST"),
236
+ "utility: no duplicate BADGE_GENERATE_REQUEST listener", );
237
+
238
+ // Workflow: correct event bus
239
+ assert.ok(
240
+ workflowSrc.includes("pi.events.on(UNIPI_EVENTS.MODULE_READY"),
241
+ "workflow: must use pi.events.on for MODULE_READY",
242
+ );
243
+ });
244
+ });
@@ -179,6 +179,16 @@ export default function (pi: ExtensionAPI) {
179
179
  );
180
180
  }
181
181
 
182
+ // Badge generation: extract name from agent result and set directly
183
+ if (record.description === "Generate session name" && record.result && record.status === "completed") {
184
+ const name = record.result.split("\n")[0]?.trim().slice(0, 50) ?? "";
185
+ if (name && !name.startsWith("Error") && !name.includes("error")) {
186
+ try {
187
+ pi.setSessionName(name);
188
+ } catch { /* best effort */ }
189
+ }
190
+ }
191
+
182
192
  pi.events.emit("subagents:completed", {
183
193
  id: record.id,
184
194
  type: record.type,
@@ -339,20 +349,31 @@ export default function (pi: ExtensionAPI) {
339
349
  });
340
350
 
341
351
  // Listen for badge generation requests — spawn background agent
342
- pi.on(UNIPI_EVENTS.BADGE_GENERATE_REQUEST as any, async (event: any) => {
352
+ pi.events.on(UNIPI_EVENTS.BADGE_GENERATE_REQUEST, async (event: any) => {
343
353
  if (!sessionCtx) return;
344
354
 
345
355
  const summary = event?.conversationSummary ?? "";
346
356
  const prompt = summary
347
- ? `Generate a concise session title (MAX 5 WORDS) for this conversation:\n\n"${summary}"\n\nCall the set_session_name tool with the name. Do not explain.`
348
- : `Generate a concise session title (MAX 5 WORDS) for the current session. Call the set_session_name tool. Do not explain.`;
349
-
350
- // Try with openai/gpt-oss-20b, fallback to inherit
351
- const modelInput = "openai/gpt-oss-20b";
357
+ ? `Generate a concise session title (MAX 5 WORDS) for this conversation:\n\n"${summary}"\n\nReply with ONLY the title. No quotes, no explanation, no punctuation.`
358
+ : `Generate a concise session title (MAX 5 WORDS) for the current session. Reply with ONLY the title. No quotes, no explanation, no punctuation.`;
359
+
360
+ // Try with configured model, fallback to inherit
361
+ let modelInput: string | undefined = undefined;
362
+ try {
363
+ const fs = await import("node:fs");
364
+ const path = await import("node:path");
365
+ const configPath = path.resolve(process.cwd(), ".unipi/config/badge.json");
366
+ if (fs.existsSync(configPath)) {
367
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
368
+ if (typeof parsed.generationModel === "string" && parsed.generationModel !== "inherit") {
369
+ modelInput = parsed.generationModel;
370
+ }
371
+ }
372
+ } catch { /* ignore — inherit parent model */ }
352
373
  let resolvedModel: any = undefined;
353
374
 
354
375
  // Check if model is available
355
- if (sessionCtx.modelRegistry) {
376
+ if (modelInput && sessionCtx.modelRegistry) {
356
377
  const { resolveModel } = await import("./model-resolver.js");
357
378
  const result = resolveModel(modelInput, sessionCtx.modelRegistry);
358
379
  if (typeof result !== "string") {
@@ -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
  /**
@@ -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");
@@ -149,7 +149,7 @@ export default function (pi: ExtensionAPI) {
149
149
  });
150
150
 
151
151
  // Listen for ralph module ready event
152
- pi.on(UNIPI_EVENTS.MODULE_READY as any, (event: any) => {
152
+ pi.events.on(UNIPI_EVENTS.MODULE_READY, (event: any) => {
153
153
  if (event?.name === MODULES.RALPH) {
154
154
  ralphDetected = true;
155
155
  }