@pi-unipi/utility 0.1.1 → 0.2.3
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 +135 -21
- package/package.json +16 -7
- package/skills/utility/SKILL.md +70 -0
- package/src/analytics/collector.ts +293 -0
- package/src/cache/ttl-cache.ts +311 -0
- package/src/commands.ts +250 -0
- package/src/diagnostics/engine.ts +298 -0
- package/src/display/capabilities.ts +200 -0
- package/src/display/width.ts +226 -0
- package/src/index.ts +283 -0
- package/src/info-screen.ts +80 -0
- package/src/lifecycle/cleanup.ts +332 -0
- package/src/lifecycle/process.ts +162 -0
- package/src/tools/batch.ts +229 -0
- package/src/tools/env.ts +134 -0
- package/src/tui/badge-settings.ts +103 -0
- package/src/tui/name-badge-state.ts +299 -0
- package/src/tui/name-badge.ts +117 -0
- package/src/tui/settings-inspector.ts +303 -0
- package/src/types.ts +257 -0
- package/commands.ts +0 -38
- package/index.ts +0 -34
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/utility — Settings Inspector
|
|
3
|
+
*
|
|
4
|
+
* Reusable settings inspector overlay pattern.
|
|
5
|
+
* Split-pane layout: list left, editor right.
|
|
6
|
+
* Search/filter, keyboard navigation, JSON editing.
|
|
7
|
+
*
|
|
8
|
+
* Note: This is a data model and rendering helper. The actual TUI
|
|
9
|
+
* rendering depends on the host environment (pi's TUI API).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
SettingSchema,
|
|
14
|
+
SettingsInspectorState,
|
|
15
|
+
} from "../types.js";
|
|
16
|
+
|
|
17
|
+
/** Navigation actions */
|
|
18
|
+
export type InspectorAction =
|
|
19
|
+
| { type: "navigate"; direction: "up" | "down" | "first" | "last" }
|
|
20
|
+
| { type: "search"; query: string }
|
|
21
|
+
| { type: "select"; index: number }
|
|
22
|
+
| { type: "edit"; value: unknown }
|
|
23
|
+
| { type: "toggle_edit" }
|
|
24
|
+
| { type: "save" }
|
|
25
|
+
| { type: "cancel" };
|
|
26
|
+
|
|
27
|
+
/** Result of applying an action */
|
|
28
|
+
export interface InspectorUpdate {
|
|
29
|
+
state: SettingsInspectorState;
|
|
30
|
+
changed: boolean;
|
|
31
|
+
saved: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Create initial inspector state */
|
|
35
|
+
export function createSettingsInspector(
|
|
36
|
+
schemas: SettingSchema[],
|
|
37
|
+
initialValues?: Record<string, unknown>,
|
|
38
|
+
): SettingsInspectorState {
|
|
39
|
+
const values: Record<string, unknown> = {};
|
|
40
|
+
for (const schema of schemas) {
|
|
41
|
+
values[schema.key] = initialValues?.[schema.key] ?? schema.default;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
schemas,
|
|
46
|
+
values,
|
|
47
|
+
selectedIndex: 0,
|
|
48
|
+
searchQuery: "",
|
|
49
|
+
editMode: false,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Get filtered schemas based on search query */
|
|
54
|
+
export function getFilteredSchemas(
|
|
55
|
+
state: SettingsInspectorState,
|
|
56
|
+
): SettingSchema[] {
|
|
57
|
+
if (!state.searchQuery.trim()) {
|
|
58
|
+
return state.schemas;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const query = state.searchQuery.toLowerCase();
|
|
62
|
+
return state.schemas.filter(
|
|
63
|
+
(s) =>
|
|
64
|
+
s.key.toLowerCase().includes(query) ||
|
|
65
|
+
s.description.toLowerCase().includes(query),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Get the currently selected schema */
|
|
70
|
+
export function getSelectedSchema(
|
|
71
|
+
state: SettingsInspectorState,
|
|
72
|
+
): SettingSchema | undefined {
|
|
73
|
+
const filtered = getFilteredSchemas(state);
|
|
74
|
+
return filtered[state.selectedIndex];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Get current value for a key */
|
|
78
|
+
export function getValue(
|
|
79
|
+
state: SettingsInspectorState,
|
|
80
|
+
key: string,
|
|
81
|
+
): unknown {
|
|
82
|
+
return state.values[key];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Apply an action to the inspector state */
|
|
86
|
+
export function applyAction(
|
|
87
|
+
state: SettingsInspectorState,
|
|
88
|
+
action: InspectorAction,
|
|
89
|
+
): InspectorUpdate {
|
|
90
|
+
const newState: SettingsInspectorState = {
|
|
91
|
+
...state,
|
|
92
|
+
values: { ...state.values },
|
|
93
|
+
};
|
|
94
|
+
let changed = false;
|
|
95
|
+
let saved = false;
|
|
96
|
+
|
|
97
|
+
const filtered = getFilteredSchemas(newState);
|
|
98
|
+
|
|
99
|
+
switch (action.type) {
|
|
100
|
+
case "navigate": {
|
|
101
|
+
const maxIndex = Math.max(0, filtered.length - 1);
|
|
102
|
+
switch (action.direction) {
|
|
103
|
+
case "up":
|
|
104
|
+
newState.selectedIndex = Math.max(0, newState.selectedIndex - 1);
|
|
105
|
+
break;
|
|
106
|
+
case "down":
|
|
107
|
+
newState.selectedIndex = Math.min(maxIndex, newState.selectedIndex + 1);
|
|
108
|
+
break;
|
|
109
|
+
case "first":
|
|
110
|
+
newState.selectedIndex = 0;
|
|
111
|
+
break;
|
|
112
|
+
case "last":
|
|
113
|
+
newState.selectedIndex = maxIndex;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
changed = newState.selectedIndex !== state.selectedIndex;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case "search": {
|
|
121
|
+
newState.searchQuery = action.query;
|
|
122
|
+
newState.selectedIndex = 0;
|
|
123
|
+
changed = true;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
case "select": {
|
|
128
|
+
const maxIndex = Math.max(0, filtered.length - 1);
|
|
129
|
+
newState.selectedIndex = Math.max(0, Math.min(maxIndex, action.index));
|
|
130
|
+
changed = newState.selectedIndex !== state.selectedIndex;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
case "edit": {
|
|
135
|
+
const selected = getSelectedSchema(newState);
|
|
136
|
+
if (selected) {
|
|
137
|
+
newState.values[selected.key] = action.value;
|
|
138
|
+
changed = true;
|
|
139
|
+
}
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
case "toggle_edit": {
|
|
144
|
+
newState.editMode = !newState.editMode;
|
|
145
|
+
changed = true;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
case "save": {
|
|
150
|
+
saved = true;
|
|
151
|
+
changed = true;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
case "cancel": {
|
|
156
|
+
newState.editMode = false;
|
|
157
|
+
changed = true;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { state: newState, changed, saved };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Validate a value against its schema */
|
|
166
|
+
export function validateValue(
|
|
167
|
+
schema: SettingSchema,
|
|
168
|
+
value: unknown,
|
|
169
|
+
): string | undefined {
|
|
170
|
+
if (value === undefined || value === null) {
|
|
171
|
+
if (schema.required) {
|
|
172
|
+
return `Required field: ${schema.key}`;
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
switch (schema.type) {
|
|
178
|
+
case "string":
|
|
179
|
+
if (typeof value !== "string") {
|
|
180
|
+
return `Expected string for ${schema.key}`;
|
|
181
|
+
}
|
|
182
|
+
break;
|
|
183
|
+
case "number":
|
|
184
|
+
if (typeof value !== "number" || Number.isNaN(value)) {
|
|
185
|
+
return `Expected number for ${schema.key}`;
|
|
186
|
+
}
|
|
187
|
+
break;
|
|
188
|
+
case "boolean":
|
|
189
|
+
if (typeof value !== "boolean") {
|
|
190
|
+
return `Expected boolean for ${schema.key}`;
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
case "object":
|
|
194
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
195
|
+
return `Expected object for ${schema.key}`;
|
|
196
|
+
}
|
|
197
|
+
break;
|
|
198
|
+
case "array":
|
|
199
|
+
if (!Array.isArray(value)) {
|
|
200
|
+
return `Expected array for ${schema.key}`;
|
|
201
|
+
}
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Validate all values against schemas */
|
|
209
|
+
export function validateAll(
|
|
210
|
+
state: SettingsInspectorState,
|
|
211
|
+
): Record<string, string> {
|
|
212
|
+
const errors: Record<string, string> = {};
|
|
213
|
+
for (const schema of state.schemas) {
|
|
214
|
+
const error = validateValue(schema, state.values[schema.key]);
|
|
215
|
+
if (error) {
|
|
216
|
+
errors[schema.key] = error;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return errors;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Export state values as JSON */
|
|
223
|
+
export function exportToJSON(state: SettingsInspectorState): string {
|
|
224
|
+
return JSON.stringify(state.values, null, 2);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Import values from JSON string */
|
|
228
|
+
export function importFromJSON(
|
|
229
|
+
state: SettingsInspectorState,
|
|
230
|
+
json: string,
|
|
231
|
+
): { state: SettingsInspectorState; errors: Record<string, string> } {
|
|
232
|
+
let parsed: Record<string, unknown>;
|
|
233
|
+
try {
|
|
234
|
+
parsed = JSON.parse(json);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
return {
|
|
237
|
+
state,
|
|
238
|
+
errors: { _parse: `Invalid JSON: ${(err as Error).message}` },
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const newState: SettingsInspectorState = {
|
|
243
|
+
...state,
|
|
244
|
+
values: { ...state.values },
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const errors: Record<string, string> = {};
|
|
248
|
+
for (const schema of state.schemas) {
|
|
249
|
+
if (parsed[schema.key] !== undefined) {
|
|
250
|
+
const error = validateValue(schema, parsed[schema.key]);
|
|
251
|
+
if (error) {
|
|
252
|
+
errors[schema.key] = error;
|
|
253
|
+
} else {
|
|
254
|
+
newState.values[schema.key] = parsed[schema.key];
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { state: newState, errors };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Format a setting for display */
|
|
263
|
+
export function formatSetting(
|
|
264
|
+
schema: SettingSchema,
|
|
265
|
+
value: unknown,
|
|
266
|
+
): string {
|
|
267
|
+
const displayValue = value === undefined ? "(unset)" : JSON.stringify(value);
|
|
268
|
+
const requiredMark = schema.required ? "*" : "";
|
|
269
|
+
return `${schema.key}${requiredMark}: ${displayValue}\n ${schema.description}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Render the inspector as markdown (for non-TUI environments) */
|
|
273
|
+
export function renderAsMarkdown(
|
|
274
|
+
state: SettingsInspectorState,
|
|
275
|
+
): string {
|
|
276
|
+
const filtered = getFilteredSchemas(state);
|
|
277
|
+
const lines = [
|
|
278
|
+
"## ⚙️ Settings",
|
|
279
|
+
"",
|
|
280
|
+
state.searchQuery ? `*Filter: "${state.searchQuery}"*` : "",
|
|
281
|
+
"",
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
for (let i = 0; i < filtered.length; i++) {
|
|
285
|
+
const schema = filtered[i];
|
|
286
|
+
const value = state.values[schema.key];
|
|
287
|
+
const selected = i === state.selectedIndex ? "> " : " ";
|
|
288
|
+
const required = schema.required ? " **(required)**" : "";
|
|
289
|
+
|
|
290
|
+
lines.push(
|
|
291
|
+
`${selected}**${schema.key}**${required} \`${schema.type}\``,
|
|
292
|
+
` ${schema.description}`,
|
|
293
|
+
` Value: \`${JSON.stringify(value)}\``,
|
|
294
|
+
"",
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (filtered.length === 0) {
|
|
299
|
+
lines.push("*No settings match your search.*");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return lines.join("\n");
|
|
303
|
+
}
|