@pi-unipi/utility 0.2.1 → 0.2.4
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/README.md +15 -1
- package/package.json +1 -1
- package/src/commands.ts +101 -0
- package/src/index.ts +127 -9
- package/src/tui/badge-settings-tui.ts +388 -0
- package/src/tui/badge-settings.ts +108 -0
- package/src/tui/name-badge-state.ts +299 -0
- package/src/tui/name-badge.ts +117 -0
package/README.md
CHANGED
|
@@ -14,6 +14,8 @@ Comprehensive utility suite for the Pi coding agent — part of the Unipi extens
|
|
|
14
14
|
| `/unipi:cleanup` | Clean stale DBs, temp files, old sessions |
|
|
15
15
|
| `/unipi:env` | Show environment info (Node, Pi, OS, paths) |
|
|
16
16
|
| `/unipi:doctor` | Run diagnostics across all modules |
|
|
17
|
+
| `/unipi:name-badge` | Toggle name badge overlay (shows session name) |
|
|
18
|
+
| `/unipi:badge-gen` | Generate session name via LLM and enable badge |
|
|
17
19
|
|
|
18
20
|
### Tools
|
|
19
21
|
|
|
@@ -59,6 +61,16 @@ pi install npm:@pi-unipi/unipi
|
|
|
59
61
|
/unipi:doctor # Run diagnostics
|
|
60
62
|
```
|
|
61
63
|
|
|
64
|
+
### Name Badge
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
/unipi:name-badge # Toggle the session name badge on/off
|
|
68
|
+
/unipi:badge-gen # Generate a session name via LLM
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The badge is a persistent HUD overlay in the top-right corner showing the current session name.
|
|
72
|
+
It auto-restores visibility on session restart.
|
|
73
|
+
|
|
62
74
|
### Batch Execution (Code)
|
|
63
75
|
|
|
64
76
|
```typescript
|
|
@@ -125,7 +137,9 @@ packages/utility/src/
|
|
|
125
137
|
│ ├── capabilities.ts # Terminal detection
|
|
126
138
|
│ └── width.ts # Width utilities
|
|
127
139
|
├── tui/
|
|
128
|
-
│
|
|
140
|
+
│ ├── settings-inspector.ts # Settings overlay model
|
|
141
|
+
│ ├── name-badge.ts # Name badge overlay component
|
|
142
|
+
│ └── name-badge-state.ts # Name badge state manager
|
|
129
143
|
└── tools/
|
|
130
144
|
├── batch.ts # Batch execution
|
|
131
145
|
└── env.ts # Environment info
|
package/package.json
CHANGED
package/src/commands.ts
CHANGED
|
@@ -20,6 +20,9 @@ import {
|
|
|
20
20
|
import { cleanupStale, formatCleanupReport } from "./lifecycle/cleanup.js";
|
|
21
21
|
import { runDiagnostics, formatDiagnosticsReport } from "./diagnostics/engine.js";
|
|
22
22
|
import { getEnvironmentInfo, formatEnvironmentInfo } from "./tools/env.js";
|
|
23
|
+
import type { NameBadgeState } from "./tui/name-badge-state.js";
|
|
24
|
+
import { readBadgeSettings, updateBadgeSetting, formatBadgeSettings } from "./tui/badge-settings.js";
|
|
25
|
+
import { BadgeSettingsTui } from "./tui/badge-settings-tui.js";
|
|
23
26
|
|
|
24
27
|
/** Send a markdown response via pi.sendMessage */
|
|
25
28
|
function sendResponse(pi: ExtensionAPI, markdown: string): void {
|
|
@@ -33,6 +36,104 @@ function sendResponse(pi: ExtensionAPI, markdown: string): void {
|
|
|
33
36
|
);
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Register name badge commands: /unipi:badge-name, /unipi:badge-gen.
|
|
41
|
+
*/
|
|
42
|
+
export function registerNameBadgeCommands(
|
|
43
|
+
pi: ExtensionAPI,
|
|
44
|
+
state: NameBadgeState,
|
|
45
|
+
): void {
|
|
46
|
+
// ─── /unipi:badge-name — toggle badge overlay ───────────────────────────
|
|
47
|
+
pi.registerCommand(`${UNIPI_PREFIX}${UTILITY_COMMANDS.BADGE_NAME}`, {
|
|
48
|
+
description: "Toggle session name badge overlay",
|
|
49
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
50
|
+
if (!ctx.hasUI) {
|
|
51
|
+
ctx.ui.notify("Name badge requires an interactive UI.", "warning");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const nowVisible = await state.toggle(pi, ctx);
|
|
56
|
+
ctx.ui.notify(
|
|
57
|
+
nowVisible ? "Name badge enabled" : "Name badge disabled",
|
|
58
|
+
"info",
|
|
59
|
+
);
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ─── /unipi:badge-gen — generate name via background agent ─────────────
|
|
64
|
+
pi.registerCommand(`${UNIPI_PREFIX}${UTILITY_COMMANDS.BADGE_GEN}`, {
|
|
65
|
+
description: "Generate session name via background agent and enable badge",
|
|
66
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
67
|
+
if (!ctx.hasUI) {
|
|
68
|
+
ctx.ui.notify("Badge generation requires an interactive UI.", "warning");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await state.generate(pi, ctx);
|
|
73
|
+
ctx.ui.notify("Generating session name...", "info");
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ─── /unipi:badge-toggle — configure badge settings ─────────────────────
|
|
78
|
+
pi.registerCommand(`${UNIPI_PREFIX}${UTILITY_COMMANDS.BADGE_TOGGLE}`, {
|
|
79
|
+
description: "Configure badge settings (autoGen, badgeEnabled, agentTool)",
|
|
80
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
81
|
+
// Parse args: /unipi:badge-settings [key] [on|off]
|
|
82
|
+
const parts = args.trim().split(/\s+/);
|
|
83
|
+
if (parts.length >= 2 && parts[0]) {
|
|
84
|
+
const key = parts[0] as "autoGen" | "badgeEnabled" | "agentTool";
|
|
85
|
+
const value = parts[1]?.toLowerCase();
|
|
86
|
+
if ("autoGen|badgeEnabled|agentTool".includes(key)) {
|
|
87
|
+
const boolValue = value === "on" || value === "true" || value === "1";
|
|
88
|
+
updateBadgeSetting(key, boolValue);
|
|
89
|
+
ctx.ui.notify(`Badge ${key} set to ${boolValue}`, "info");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Show current settings
|
|
95
|
+
const settings = readBadgeSettings();
|
|
96
|
+
sendResponse(pi, formatBadgeSettings(settings));
|
|
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
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
36
137
|
/**
|
|
37
138
|
* Register all utility commands.
|
|
38
139
|
*/
|
package/src/index.ts
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* @pi-unipi/utility — Extension entry
|
|
3
3
|
*
|
|
4
4
|
* Comprehensive utilities suite for Pi coding agent:
|
|
5
|
-
* - Commands: continue, reload, status, cleanup, env, doctor
|
|
6
|
-
* - Tools: ctx_batch, ctx_env
|
|
5
|
+
* - Commands: continue, reload, status, cleanup, env, doctor, badge
|
|
6
|
+
* - Tools: ctx_batch, ctx_env, set_session_name
|
|
7
7
|
* - Lifecycle: process management, stale cleanup
|
|
8
8
|
* - Cache: TTL cache with optional persistence
|
|
9
9
|
* - Analytics: lightweight event collection
|
|
10
10
|
* - Diagnostics: cross-module health checks
|
|
11
11
|
* - Display: terminal capabilities, width utilities
|
|
12
|
-
* - TUI: settings inspector pattern
|
|
12
|
+
* - TUI: settings inspector pattern, name badge
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
@@ -20,15 +20,24 @@ import {
|
|
|
20
20
|
UTILITY_TOOLS,
|
|
21
21
|
emitEvent,
|
|
22
22
|
getPackageVersion,
|
|
23
|
+
type UnipiBadgeGenerateRequestEvent,
|
|
23
24
|
} from "@pi-unipi/core";
|
|
24
|
-
import { registerUtilityCommands } from "./commands.js";
|
|
25
|
+
import { registerUtilityCommands, registerNameBadgeCommands } from "./commands.js";
|
|
26
|
+
import { NameBadgeState } from "./tui/name-badge-state.js";
|
|
27
|
+
import { readBadgeSettings } from "./tui/badge-settings.js";
|
|
25
28
|
import { getLifecycle } from "./lifecycle/process.js";
|
|
26
29
|
import { getAnalyticsCollector } from "./analytics/collector.js";
|
|
27
30
|
import { registerInfoScreen } from "./info-screen.js";
|
|
28
31
|
|
|
32
|
+
/** Re-export readBadgeSettings for cross-package use */
|
|
33
|
+
export { readBadgeSettings } from "./tui/badge-settings.js";
|
|
34
|
+
|
|
29
35
|
/** Package version */
|
|
30
36
|
const VERSION = getPackageVersion(new URL(".", import.meta.url).pathname);
|
|
31
37
|
|
|
38
|
+
/** Whether we've seen the first user message (for auto badge generation) */
|
|
39
|
+
let firstMessageSeen = false;
|
|
40
|
+
|
|
32
41
|
/** All commands registered by this module */
|
|
33
42
|
const ALL_COMMANDS = [
|
|
34
43
|
UTILITY_COMMANDS.CONTINUE,
|
|
@@ -37,10 +46,14 @@ const ALL_COMMANDS = [
|
|
|
37
46
|
UTILITY_COMMANDS.CLEANUP,
|
|
38
47
|
UTILITY_COMMANDS.ENV,
|
|
39
48
|
UTILITY_COMMANDS.DOCTOR,
|
|
49
|
+
UTILITY_COMMANDS.BADGE_NAME,
|
|
50
|
+
UTILITY_COMMANDS.BADGE_GEN,
|
|
51
|
+
UTILITY_COMMANDS.BADGE_TOGGLE,
|
|
52
|
+
UTILITY_COMMANDS.BADGE_SETTINGS,
|
|
40
53
|
].map((cmd) => `unipi:${cmd}`);
|
|
41
54
|
|
|
42
55
|
/** All tools registered by this module */
|
|
43
|
-
const ALL_TOOLS = [UTILITY_TOOLS.BATCH, UTILITY_TOOLS.ENV];
|
|
56
|
+
const ALL_TOOLS = [UTILITY_TOOLS.BATCH, UTILITY_TOOLS.ENV, UTILITY_TOOLS.SET_SESSION_NAME];
|
|
44
57
|
|
|
45
58
|
export default function (pi: ExtensionAPI) {
|
|
46
59
|
// Initialize lifecycle manager
|
|
@@ -54,17 +67,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
54
67
|
analytics.disable();
|
|
55
68
|
});
|
|
56
69
|
|
|
70
|
+
// Initialize name badge state
|
|
71
|
+
const nameBadgeState = new NameBadgeState();
|
|
72
|
+
|
|
73
|
+
// Capture session context for cross-event use (not needed if BADGE_GENERATE_REQUEST removed)
|
|
74
|
+
|
|
57
75
|
// Register commands
|
|
58
76
|
registerUtilityCommands(pi);
|
|
77
|
+
registerNameBadgeCommands(pi, nameBadgeState);
|
|
59
78
|
|
|
60
79
|
// Register tools
|
|
61
|
-
registerUtilityTools(pi);
|
|
80
|
+
registerUtilityTools(pi, nameBadgeState);
|
|
62
81
|
|
|
63
82
|
// Register info-screen group
|
|
64
83
|
registerInfoScreen(pi);
|
|
65
84
|
|
|
66
|
-
// Session lifecycle — announce module
|
|
67
|
-
pi.on("session_start", async () => {
|
|
85
|
+
// Session lifecycle — announce module + restore badge
|
|
86
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
68
87
|
emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
|
|
69
88
|
name: MODULES.UTILITY,
|
|
70
89
|
version: VERSION,
|
|
@@ -73,6 +92,54 @@ export default function (pi: ExtensionAPI) {
|
|
|
73
92
|
});
|
|
74
93
|
|
|
75
94
|
analytics.recordModuleLoad(MODULES.UTILITY, VERSION);
|
|
95
|
+
|
|
96
|
+
// Restore name badge if it was visible in previous session
|
|
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
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// First-message hook: auto-generate session name on first user message
|
|
110
|
+
pi.on("input", async (_event: any, ctx: any) => {
|
|
111
|
+
// Only trigger on first user message
|
|
112
|
+
if (firstMessageSeen) return;
|
|
113
|
+
firstMessageSeen = true;
|
|
114
|
+
|
|
115
|
+
// Check if auto generation is enabled
|
|
116
|
+
const settings = readBadgeSettings();
|
|
117
|
+
if (!settings.autoGen) return;
|
|
118
|
+
|
|
119
|
+
// Skip if badge already has a name
|
|
120
|
+
const sessionName = pi.getSessionName?.();
|
|
121
|
+
if (sessionName) return;
|
|
122
|
+
|
|
123
|
+
// Get first message text for context
|
|
124
|
+
const messageText = typeof _event?.content === "string"
|
|
125
|
+
? _event.content
|
|
126
|
+
: Array.isArray(_event?.content)
|
|
127
|
+
? _event.content
|
|
128
|
+
.filter((c: any) => c.type === "text")
|
|
129
|
+
.map((c: any) => c.text)
|
|
130
|
+
.join(" ")
|
|
131
|
+
: "";
|
|
132
|
+
|
|
133
|
+
// Emit event for subagents to spawn background agent
|
|
134
|
+
emitEvent(pi, UNIPI_EVENTS.BADGE_GENERATE_REQUEST, {
|
|
135
|
+
source: "input-hook",
|
|
136
|
+
conversationSummary: messageText.slice(0, 500),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Show badge overlay if UI available
|
|
140
|
+
if (ctx?.hasUI && !nameBadgeState.isVisible()) {
|
|
141
|
+
await nameBadgeState.show(pi, ctx);
|
|
142
|
+
}
|
|
76
143
|
});
|
|
77
144
|
|
|
78
145
|
// Track command usage
|
|
@@ -84,6 +151,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
84
151
|
|
|
85
152
|
// Session shutdown cleanup
|
|
86
153
|
pi.on("session_shutdown", async () => {
|
|
154
|
+
nameBadgeState.hide();
|
|
155
|
+
firstMessageSeen = false;
|
|
87
156
|
await lifecycle.shutdown("session_shutdown");
|
|
88
157
|
});
|
|
89
158
|
}
|
|
@@ -91,7 +160,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
91
160
|
/**
|
|
92
161
|
* Register utility tools.
|
|
93
162
|
*/
|
|
94
|
-
function registerUtilityTools(pi: ExtensionAPI): void {
|
|
163
|
+
function registerUtilityTools(pi: ExtensionAPI, nameBadgeState: NameBadgeState): void {
|
|
95
164
|
// ctx_batch — atomic batch execution
|
|
96
165
|
pi.registerTool({
|
|
97
166
|
name: UTILITY_TOOLS.BATCH,
|
|
@@ -169,4 +238,53 @@ function registerUtilityTools(pi: ExtensionAPI): void {
|
|
|
169
238
|
};
|
|
170
239
|
},
|
|
171
240
|
});
|
|
241
|
+
|
|
242
|
+
// set_session_name — set the session name for badge display
|
|
243
|
+
const badgeSettings = readBadgeSettings();
|
|
244
|
+
if (badgeSettings.agentTool) {
|
|
245
|
+
pi.registerTool({
|
|
246
|
+
name: UTILITY_TOOLS.SET_SESSION_NAME,
|
|
247
|
+
label: "Set Session Name",
|
|
248
|
+
description:
|
|
249
|
+
"Set the session name that appears in the badge overlay and session selector. " +
|
|
250
|
+
"Use this to give the current session a descriptive title. " +
|
|
251
|
+
"Name should be concise (max 5 words recommended).",
|
|
252
|
+
promptSnippet: "Set a name/title for the current session.",
|
|
253
|
+
parameters: {
|
|
254
|
+
type: "object",
|
|
255
|
+
properties: {
|
|
256
|
+
name: {
|
|
257
|
+
type: "string",
|
|
258
|
+
description: "The session name to set (max 5 words recommended).",
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
required: ["name"],
|
|
262
|
+
},
|
|
263
|
+
async execute(_toolCallId, params) {
|
|
264
|
+
const { name } = params as { name: string };
|
|
265
|
+
if (!name || typeof name !== "string") {
|
|
266
|
+
return {
|
|
267
|
+
content: [{ type: "text", text: "Error: name parameter is required and must be a string." }],
|
|
268
|
+
details: undefined,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const trimmed = name.trim();
|
|
273
|
+
if (trimmed.length === 0) {
|
|
274
|
+
return {
|
|
275
|
+
content: [{ type: "text", text: "Error: name cannot be empty." }],
|
|
276
|
+
details: undefined,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Set the session name
|
|
281
|
+
nameBadgeState.setSessionName(pi, trimmed);
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
content: [{ type: "text", text: `Session name set to: "${trimmed}"` }],
|
|
285
|
+
details: { name: trimmed },
|
|
286
|
+
};
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
}
|
|
172
290
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/utility — Badge Settings Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages badge configuration stored in .unipi/config/badge.json.
|
|
5
|
+
* Settings: autoGen, badgeEnabled, agentTool
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
|
|
11
|
+
/** Badge settings interface */
|
|
12
|
+
export interface BadgeSettings {
|
|
13
|
+
/** Auto-generate session name on first user message */
|
|
14
|
+
autoGen: boolean;
|
|
15
|
+
/** Show the badge overlay */
|
|
16
|
+
badgeEnabled: boolean;
|
|
17
|
+
/** Enable the set_session_name tool for agents */
|
|
18
|
+
agentTool: boolean;
|
|
19
|
+
/** Model to use for badge name generation. "inherit" = parent model, or "provider/model-id" */
|
|
20
|
+
generationModel: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Default badge settings */
|
|
24
|
+
const DEFAULT_SETTINGS: BadgeSettings = {
|
|
25
|
+
autoGen: true,
|
|
26
|
+
badgeEnabled: true,
|
|
27
|
+
agentTool: true,
|
|
28
|
+
generationModel: "inherit",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Badge settings file name */
|
|
32
|
+
const BADGE_CONFIG_FILE = ".unipi/config/badge.json";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get the config file path relative to cwd.
|
|
36
|
+
*/
|
|
37
|
+
function getConfigPath(): string {
|
|
38
|
+
return path.resolve(process.cwd(), BADGE_CONFIG_FILE);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read badge settings from disk.
|
|
43
|
+
* Returns defaults if file doesn't exist or is malformed.
|
|
44
|
+
*/
|
|
45
|
+
export function readBadgeSettings(): BadgeSettings {
|
|
46
|
+
try {
|
|
47
|
+
const configPath = getConfigPath();
|
|
48
|
+
if (!fs.existsSync(configPath)) return { ...DEFAULT_SETTINGS };
|
|
49
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
50
|
+
return {
|
|
51
|
+
autoGen: typeof parsed.autoGen === "boolean" ? parsed.autoGen : DEFAULT_SETTINGS.autoGen,
|
|
52
|
+
badgeEnabled: typeof parsed.badgeEnabled === "boolean" ? parsed.badgeEnabled : DEFAULT_SETTINGS.badgeEnabled,
|
|
53
|
+
agentTool: typeof parsed.agentTool === "boolean" ? parsed.agentTool : DEFAULT_SETTINGS.agentTool,
|
|
54
|
+
generationModel: typeof parsed.generationModel === "string" ? parsed.generationModel : DEFAULT_SETTINGS.generationModel,
|
|
55
|
+
};
|
|
56
|
+
} catch {
|
|
57
|
+
return { ...DEFAULT_SETTINGS };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Write badge settings to disk.
|
|
63
|
+
* Creates .unipi/config/ directory if needed.
|
|
64
|
+
*/
|
|
65
|
+
export function writeBadgeSettings(settings: BadgeSettings): void {
|
|
66
|
+
try {
|
|
67
|
+
const configPath = getConfigPath();
|
|
68
|
+
const dir = path.dirname(configPath);
|
|
69
|
+
if (!fs.existsSync(dir)) {
|
|
70
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
71
|
+
}
|
|
72
|
+
fs.writeFileSync(configPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
73
|
+
} catch {
|
|
74
|
+
// Best effort
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Update a single badge setting.
|
|
80
|
+
*/
|
|
81
|
+
export function updateBadgeSetting<K extends keyof BadgeSettings>(
|
|
82
|
+
key: K,
|
|
83
|
+
value: BadgeSettings[K],
|
|
84
|
+
): BadgeSettings {
|
|
85
|
+
const settings = readBadgeSettings();
|
|
86
|
+
settings[key] = value;
|
|
87
|
+
writeBadgeSettings(settings);
|
|
88
|
+
return settings;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Format badge settings for display.
|
|
93
|
+
*/
|
|
94
|
+
export function formatBadgeSettings(settings: BadgeSettings): string {
|
|
95
|
+
const toggle = (v: boolean) => (v ? "✓ enabled" : "✗ disabled");
|
|
96
|
+
return [
|
|
97
|
+
"## Badge Settings",
|
|
98
|
+
"",
|
|
99
|
+
`| Setting | Status | Description |`,
|
|
100
|
+
`|---------|--------|-------------|`,
|
|
101
|
+
`| Auto Generate | ${toggle(settings.autoGen)} | Generate name on first message |`,
|
|
102
|
+
`| Badge Enabled | ${toggle(settings.badgeEnabled)} | Show badge overlay |`,
|
|
103
|
+
`| Agent Tool | ${toggle(settings.agentTool)} | Allow agents to call set_session_name |`,
|
|
104
|
+
`| Generation Model | ${settings.generationModel} | Model for badge name generation |`,
|
|
105
|
+
"",
|
|
106
|
+
`Config: \`${BADGE_CONFIG_FILE}\``,
|
|
107
|
+
].join("\n");
|
|
108
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/utility — Name Badge State Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages the name badge overlay lifecycle:
|
|
5
|
+
* - Toggle visibility (persisted via pi.appendEntry)
|
|
6
|
+
* - Poll for session name changes every 1s
|
|
7
|
+
* - Restore visibility on session start
|
|
8
|
+
* - Generate session name via background agent event
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
12
|
+
import { UNIPI_EVENTS, emitEvent } from "@pi-unipi/core";
|
|
13
|
+
import { NameBadgeComponent } from "./name-badge.js";
|
|
14
|
+
import { readBadgeSettings } from "./badge-settings.js";
|
|
15
|
+
|
|
16
|
+
/** Overlay handle from ctx.ui.custom() */
|
|
17
|
+
interface OverlayHandle {
|
|
18
|
+
requestRender?: () => void;
|
|
19
|
+
hide?: () => void;
|
|
20
|
+
setHidden?: (hidden: boolean) => void;
|
|
21
|
+
isHidden?: () => boolean;
|
|
22
|
+
focus?: () => void;
|
|
23
|
+
unfocus?: () => void;
|
|
24
|
+
isFocused?: () => boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Persisted badge state */
|
|
28
|
+
interface BadgePersistedState {
|
|
29
|
+
visible: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Entry type for persistence */
|
|
33
|
+
const BADGE_ENTRY_TYPE = "name-badge";
|
|
34
|
+
|
|
35
|
+
/** Polling interval in ms */
|
|
36
|
+
const POLL_INTERVAL_MS = 1000;
|
|
37
|
+
|
|
38
|
+
/** Name generation timeout in ms */
|
|
39
|
+
const GEN_TIMEOUT_MS = 30_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* NameBadgeState — manages the name badge overlay.
|
|
43
|
+
*
|
|
44
|
+
* Usage:
|
|
45
|
+
* const state = new NameBadgeState();
|
|
46
|
+
* // In session_start: state.restore(pi, ctx);
|
|
47
|
+
* // In session_shutdown: state.hide();
|
|
48
|
+
* // Commands: state.toggle(pi, ctx), state.generate(pi, ctx);
|
|
49
|
+
*/
|
|
50
|
+
export class NameBadgeState {
|
|
51
|
+
private visible = false;
|
|
52
|
+
private currentName: string | null = null;
|
|
53
|
+
private overlayHandle: OverlayHandle | null = null;
|
|
54
|
+
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
55
|
+
private component: NameBadgeComponent | null = null;
|
|
56
|
+
private genTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
57
|
+
|
|
58
|
+
/** Whether the badge is currently visible */
|
|
59
|
+
isVisible(): boolean {
|
|
60
|
+
return this.visible;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Toggle badge visibility.
|
|
65
|
+
* If hidden → show + start polling.
|
|
66
|
+
* If visible → hide + stop polling.
|
|
67
|
+
*/
|
|
68
|
+
async toggle(
|
|
69
|
+
pi: ExtensionAPI,
|
|
70
|
+
ctx: { hasUI: boolean; ui: any; cwd?: string },
|
|
71
|
+
): Promise<boolean> {
|
|
72
|
+
if (this.visible) {
|
|
73
|
+
this.hide();
|
|
74
|
+
this.persist(pi, false);
|
|
75
|
+
return false;
|
|
76
|
+
} else {
|
|
77
|
+
await this.show(pi, ctx);
|
|
78
|
+
this.persist(pi, true);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Show the badge overlay and start polling.
|
|
85
|
+
*/
|
|
86
|
+
async show(
|
|
87
|
+
pi: ExtensionAPI,
|
|
88
|
+
ctx: { hasUI: boolean; ui: any; cwd?: string },
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
if (this.overlayHandle) return; // Already showing
|
|
91
|
+
|
|
92
|
+
const name = this.safeGetName(pi);
|
|
93
|
+
this.currentName = name;
|
|
94
|
+
this.visible = true;
|
|
95
|
+
|
|
96
|
+
// Store tui reference for requestRender wiring
|
|
97
|
+
let tuiRef: any = null;
|
|
98
|
+
|
|
99
|
+
ctx.ui.custom(
|
|
100
|
+
(tui: any, theme: any, _keybindings: any, _done: any) => {
|
|
101
|
+
tuiRef = tui;
|
|
102
|
+
const component = new NameBadgeComponent(name);
|
|
103
|
+
component.setTheme(theme);
|
|
104
|
+
this.component = component;
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
render: (w: number) => component.render(w),
|
|
108
|
+
invalidate: () => component.invalidate(),
|
|
109
|
+
// No handleInput — display-only overlay
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
overlay: true,
|
|
114
|
+
overlayOptions: {
|
|
115
|
+
anchor: "top-center",
|
|
116
|
+
width: "100%",
|
|
117
|
+
nonCapturing: true,
|
|
118
|
+
visible: (termWidth: number) => termWidth >= 20,
|
|
119
|
+
},
|
|
120
|
+
onHandle: (handle: OverlayHandle) => {
|
|
121
|
+
this.overlayHandle = handle;
|
|
122
|
+
// Wire requestRender now that handle exists
|
|
123
|
+
if (tuiRef) {
|
|
124
|
+
(this.overlayHandle as any).requestRender = () => tuiRef.requestRender();
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
this.startPolling(pi);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Hide the badge overlay and stop polling.
|
|
135
|
+
*/
|
|
136
|
+
hide(): void {
|
|
137
|
+
this.stopPolling();
|
|
138
|
+
this.clearGenTimeout();
|
|
139
|
+
|
|
140
|
+
if (this.overlayHandle) {
|
|
141
|
+
try {
|
|
142
|
+
// Use hide() to permanently remove the overlay
|
|
143
|
+
if (typeof this.overlayHandle.hide === "function") {
|
|
144
|
+
this.overlayHandle.hide();
|
|
145
|
+
} else if (typeof this.overlayHandle.setHidden === "function") {
|
|
146
|
+
this.overlayHandle.setHidden(true);
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
// Handle may already be invalid
|
|
150
|
+
}
|
|
151
|
+
this.overlayHandle = null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
this.component = null;
|
|
155
|
+
this.visible = false;
|
|
156
|
+
this.currentName = null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Restore badge visibility from persisted state.
|
|
161
|
+
* Call on session_start.
|
|
162
|
+
*/
|
|
163
|
+
async restore(
|
|
164
|
+
pi: ExtensionAPI,
|
|
165
|
+
ctx: { hasUI: boolean; ui: any; cwd?: string },
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
try {
|
|
168
|
+
const entries = (ctx as any).sessionManager?.getEntries?.() ?? [];
|
|
169
|
+
const badgeEntry = entries.findLast(
|
|
170
|
+
(e: any) => e.type === "custom" && e.customType === BADGE_ENTRY_TYPE,
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
if (badgeEntry?.data?.visible) {
|
|
174
|
+
await this.show(pi, ctx);
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
// If we can't read entries, just don't restore
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Generate a session name via background agent.
|
|
183
|
+
* Emits BADGE_GENERATE_REQUEST event for subagents to handle.
|
|
184
|
+
* Also enables the badge overlay if not visible.
|
|
185
|
+
*/
|
|
186
|
+
async generate(
|
|
187
|
+
pi: ExtensionAPI,
|
|
188
|
+
ctx: { hasUI: boolean; ui: any; cwd?: string },
|
|
189
|
+
conversationSummary?: string,
|
|
190
|
+
): Promise<void> {
|
|
191
|
+
// Enable badge if not visible
|
|
192
|
+
if (!this.visible) {
|
|
193
|
+
await this.show(pi, ctx);
|
|
194
|
+
this.persist(pi, true);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Clear any previous generation timeout
|
|
198
|
+
this.clearGenTimeout();
|
|
199
|
+
|
|
200
|
+
// Emit event for subagents to spawn background agent
|
|
201
|
+
emitEvent(pi, UNIPI_EVENTS.BADGE_GENERATE_REQUEST, {
|
|
202
|
+
source: "command",
|
|
203
|
+
conversationSummary,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Set timeout — if name not set within 30s, give up
|
|
207
|
+
this.genTimeout = setTimeout(() => {
|
|
208
|
+
this.genTimeout = null;
|
|
209
|
+
// If name is still null after timeout, the agent didn't respond
|
|
210
|
+
if (this.currentName === null) {
|
|
211
|
+
// Badge stays with placeholder — no error needed
|
|
212
|
+
}
|
|
213
|
+
}, GEN_TIMEOUT_MS);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Directly set the session name via pi API.
|
|
218
|
+
* Used by the set_session_name tool.
|
|
219
|
+
*/
|
|
220
|
+
setSessionName(pi: ExtensionAPI, name: string): void {
|
|
221
|
+
try {
|
|
222
|
+
pi.setSessionName(name);
|
|
223
|
+
// Update component immediately (don't wait for poll)
|
|
224
|
+
this.currentName = name;
|
|
225
|
+
this.component?.setName(name);
|
|
226
|
+
this.overlayHandle?.requestRender?.();
|
|
227
|
+
// Clear generation timeout if active
|
|
228
|
+
this.clearGenTimeout();
|
|
229
|
+
} catch {
|
|
230
|
+
// Best effort
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─── Private ────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Start polling for name changes.
|
|
238
|
+
*/
|
|
239
|
+
private startPolling(pi: ExtensionAPI): void {
|
|
240
|
+
if (this.pollTimer) return;
|
|
241
|
+
|
|
242
|
+
this.pollTimer = setInterval(() => {
|
|
243
|
+
const name = this.safeGetName(pi);
|
|
244
|
+
|
|
245
|
+
// Check if generation timeout should be cleared
|
|
246
|
+
if (name !== null && this.genTimeout) {
|
|
247
|
+
this.clearGenTimeout();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (name !== this.currentName) {
|
|
251
|
+
this.currentName = name;
|
|
252
|
+
this.component?.setName(name);
|
|
253
|
+
this.overlayHandle?.requestRender?.();
|
|
254
|
+
}
|
|
255
|
+
}, POLL_INTERVAL_MS);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Stop polling.
|
|
260
|
+
*/
|
|
261
|
+
private stopPolling(): void {
|
|
262
|
+
if (this.pollTimer) {
|
|
263
|
+
clearInterval(this.pollTimer);
|
|
264
|
+
this.pollTimer = null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Clear generation timeout.
|
|
270
|
+
*/
|
|
271
|
+
private clearGenTimeout(): void {
|
|
272
|
+
if (this.genTimeout) {
|
|
273
|
+
clearTimeout(this.genTimeout);
|
|
274
|
+
this.genTimeout = null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Safely get session name, returning null on error.
|
|
280
|
+
*/
|
|
281
|
+
private safeGetName(pi: ExtensionAPI): string | null {
|
|
282
|
+
try {
|
|
283
|
+
return pi.getSessionName() ?? null;
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Persist badge visibility state.
|
|
291
|
+
*/
|
|
292
|
+
private persist(pi: ExtensionAPI, visible: boolean): void {
|
|
293
|
+
try {
|
|
294
|
+
pi.appendEntry(BADGE_ENTRY_TYPE, { visible } satisfies BadgePersistedState);
|
|
295
|
+
} catch {
|
|
296
|
+
// Persistence is best-effort
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/utility — Name Badge Component
|
|
3
|
+
*
|
|
4
|
+
* Pure render component for the session name badge overlay.
|
|
5
|
+
* Displays a bordered box with opaque background and session name.
|
|
6
|
+
* Display-only — no input handling, no focus.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Component } from "@mariozechner/pi-tui";
|
|
10
|
+
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
11
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
/** Placeholder text when no session name is set */
|
|
14
|
+
const PLACEHOLDER = "Set a name";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Pad content to exact visible width.
|
|
18
|
+
*/
|
|
19
|
+
function padVisible(content: string, targetWidth: number): string {
|
|
20
|
+
const vw = visibleWidth(content);
|
|
21
|
+
const pad = Math.max(0, targetWidth - vw);
|
|
22
|
+
return content + " ".repeat(pad);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* NameBadgeComponent — bordered box HUD overlay showing session name.
|
|
27
|
+
*
|
|
28
|
+
* Renders a proper box with opaque background:
|
|
29
|
+
* ╭──────────╮
|
|
30
|
+
* │ Best │
|
|
31
|
+
* ╰──────────╯
|
|
32
|
+
*/
|
|
33
|
+
export class NameBadgeComponent implements Component {
|
|
34
|
+
private name: string | null;
|
|
35
|
+
private theme: Theme | null = null;
|
|
36
|
+
private cachedLines: string[] | null = null;
|
|
37
|
+
private cachedWidth = -1;
|
|
38
|
+
|
|
39
|
+
constructor(name: string | null) {
|
|
40
|
+
this.name = name;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Update the displayed name */
|
|
44
|
+
setName(name: string | null): void {
|
|
45
|
+
if (name !== this.name) {
|
|
46
|
+
this.name = name;
|
|
47
|
+
this.invalidate();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Store theme reference for reactive color updates */
|
|
52
|
+
setTheme(theme: Theme): void {
|
|
53
|
+
this.theme = theme;
|
|
54
|
+
this.invalidate();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Clear cached render lines */
|
|
58
|
+
invalidate(): void {
|
|
59
|
+
this.cachedLines = null;
|
|
60
|
+
this.cachedWidth = -1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
render(width: number): string[] {
|
|
64
|
+
// Return cached if width unchanged
|
|
65
|
+
if (this.cachedLines && this.cachedWidth === width) {
|
|
66
|
+
return this.cachedLines;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const lines = this.renderBadge(width);
|
|
70
|
+
this.cachedLines = lines;
|
|
71
|
+
this.cachedWidth = width;
|
|
72
|
+
return this.cachedLines;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private renderBadge(width: number): string[] {
|
|
76
|
+
// Determine display text and color
|
|
77
|
+
let displayText: string;
|
|
78
|
+
let fgColor: string;
|
|
79
|
+
if (this.name) {
|
|
80
|
+
displayText = this.name;
|
|
81
|
+
fgColor = "accent";
|
|
82
|
+
} else {
|
|
83
|
+
displayText = PLACEHOLDER;
|
|
84
|
+
fgColor = "muted";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Full-width box: borders take 2 cols
|
|
88
|
+
const innerWidth = Math.max(1, width - 2);
|
|
89
|
+
const maxTextWidth = Math.max(1, innerWidth - 4); // 2-cell pad each side
|
|
90
|
+
|
|
91
|
+
// Truncate name if needed
|
|
92
|
+
if (visibleWidth(displayText) > maxTextWidth) {
|
|
93
|
+
displayText = truncateToWidth(displayText, maxTextWidth - 1, "…");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Center text within inner width
|
|
97
|
+
const textVw = visibleWidth(displayText);
|
|
98
|
+
const leftPad = Math.floor((innerWidth - textVw) / 2);
|
|
99
|
+
const rightPad = innerWidth - textVw - leftPad;
|
|
100
|
+
|
|
101
|
+
const border = (s: string) => this.theme ? this.theme.fg("accent" as any, s) : s;
|
|
102
|
+
const bgFn = (s: string) => this.theme ? this.theme.bg("customMessageBg" as any, s) : s;
|
|
103
|
+
|
|
104
|
+
const nameStyled = this.theme
|
|
105
|
+
? this.theme.fg(fgColor as any, displayText)
|
|
106
|
+
: displayText;
|
|
107
|
+
|
|
108
|
+
// Build lines with opaque background spanning full width
|
|
109
|
+
const topLine = bgFn(border("╭" + "─".repeat(innerWidth) + "╮"));
|
|
110
|
+
const contentLine = bgFn(
|
|
111
|
+
border("│") + " ".repeat(leftPad) + nameStyled + " ".repeat(rightPad) + border("│"),
|
|
112
|
+
);
|
|
113
|
+
const bottomLine = bgFn(border("╰" + "─".repeat(innerWidth) + "╯"));
|
|
114
|
+
|
|
115
|
+
return [topLine, contentLine, bottomLine];
|
|
116
|
+
}
|
|
117
|
+
}
|