avtc-pi-settings-ui 1.0.0

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.
@@ -0,0 +1,321 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // SPDX-FileCopyrightText: 2026 avtc <tarasenkov@gmail.com>
3
+
4
+ /**
5
+ * High-level settings modal and command registration.
6
+ *
7
+ * {@link registerSettingsCommand} is the single public entry point: it creates the settings
8
+ * handle internally, registers the /<name> command + modal, wires auto-load (registration +
9
+ * every session_start), and returns the handle for runtime reads. Consumers only need to
10
+ * define a schema and call it once.
11
+ *
12
+ * Usage:
13
+ * ```ts
14
+ * const handle = registerSettingsCommand(pi, MY_SCHEMA, {
15
+ * commandName: "my-settings",
16
+ * title: "My Extension Settings",
17
+ * titleRight: "avtc-pi-my-extension",
18
+ * envVar: "PI_MY_SETTINGS",
19
+ * });
20
+ * ```
21
+ */
22
+
23
+ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
24
+ import { captureCtx } from "../ctx-store.js";
25
+ import { createSettingsExtension } from "../factory.js";
26
+ import { resolveFunctionPresets } from "../preset-resolver.js";
27
+ import { normalizeStorageLevels, type SettingsSchema, type StorageLevel } from "../schema.js";
28
+
29
+ export type { StorageLevel } from "../schema.js";
30
+ // Re-export for the existing test/index imports (StorageLevel + normalizeStorageLevels live in schema.ts).
31
+ export { normalizeStorageLevels } from "../schema.js";
32
+
33
+ import { subscribeToDialogCoordinator, withCoordinator } from "../snippets/vendored/subscribe-to-dialog-coordinator.js";
34
+ import { ModalFrame } from "./modal-frame.js";
35
+ import { buildSchemaTabGroups } from "./schema-tabs.js";
36
+ import { SettingsTabsModal } from "./settings-modal.js";
37
+
38
+ /** Sentinel for "no global-dir override" — passed to the handle's load/save so they use the default (~/.pi). */
39
+ const NO_GLOBAL_DIR: string | undefined = undefined;
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Types
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /** Result of createSettingsExtension — the full settings API object (internal; modal + registerSettingsCommand use it). */
46
+ export interface SettingsHandleInternal<S = Record<string, unknown>> {
47
+ getSettings: () => S;
48
+ updateSetting: (key: string, value: unknown, opts?: { level?: StorageLevel }) => void;
49
+ loadSettingsIntoMemory: (cwd?: string, globalDir?: string) => void;
50
+ saveGlobalSettings: (settings: Record<string, unknown>, globalDir?: string) => void;
51
+ saveProjectSettings: (settings: Record<string, unknown>, cwd: string) => void;
52
+ computeDefaults: () => S;
53
+ schema: SettingsSchema;
54
+ /** Which persistence levels this handle offers (source of truth; the modal inherits it). */
55
+ storageLevels: StorageLevel[];
56
+ }
57
+
58
+ /**
59
+ * The public settings handle returned by {@link registerSettingsCommand}. Consumers read settings
60
+ * via `getSettings()`, write via `updateSetting()`, and inspect `storageLevels`. Loading
61
+ * (registration + every `session_start`) and persistence are handled internally — no manual
62
+ * load/save is exposed.
63
+ */
64
+ export interface SettingsHandle<S = Record<string, unknown>> {
65
+ /** Read the current settings (normalized by the schema). */
66
+ getSettings: () => S;
67
+ /** Write one setting (mode-aware: buffer or immediate file write; persists + syncs env var). */
68
+ updateSetting: (key: string, value: unknown, opts?: { level?: StorageLevel }) => void;
69
+ /** Which persistence levels this handle offers (`session`/`project`/`global`). */
70
+ storageLevels: StorageLevel[];
71
+ }
72
+
73
+ export interface SettingsModalOptions {
74
+ /** Command name registered on pi (e.g. "subagent-settings"). */
75
+ commandName: string;
76
+ /** Title displayed in the modal frame border. */
77
+ title: string;
78
+ /** Optional right-aligned subtitle in the border (e.g. "avtc-pi-subagent"). */
79
+ titleRight?: string;
80
+ /**
81
+ * Optional hook called after a setting changes. Receives (id, newValue).
82
+ * Use for side effects beyond the standard updateSetting (which handles persistence +
83
+ * env-var serialization itself).
84
+ */
85
+ onAfterChange?: (id: string, newValue: unknown) => void;
86
+ /**
87
+ * Optional hook called before the modal opens. Receives the command context.
88
+ * Use for stashing context data (e.g. newSession function) needed by other handlers.
89
+ */
90
+ beforeOpen?: (ctx: ExtensionCommandContext) => void;
91
+ }
92
+
93
+ /**
94
+ * Options for {@link registerSettingsCommand} — combines the settings store (storageLevels,
95
+ * clampFn, envVar, onLoad) with the command/modal (commandName, title, titleRight, …).
96
+ * The store fields are passed to the internally-created handle; the modal fields drive the UI.
97
+ */
98
+ export interface RegisterSettingsOptions {
99
+ // Command / modal
100
+ /** Command name registered on pi (e.g. "my-extension:settings"). */
101
+ commandName: string;
102
+ /** Title displayed in the modal frame border. */
103
+ title: string;
104
+ /** Optional right-aligned subtitle in the border (e.g. "avtc-pi-my-extension"). */
105
+ titleRight?: string;
106
+ /** Optional hook called after a setting changes. Receives (id, newValue). */
107
+ onAfterChange?: (id: string, newValue: unknown) => void;
108
+ /** Optional hook called before the modal opens. Receives the command context. */
109
+ beforeOpen?: (ctx: ExtensionCommandContext) => void;
110
+ // Settings store
111
+ /** Which storage levels the handle offers (default: all three). Source of truth for the modal. */
112
+ storageLevels?: StorageLevel[];
113
+ /** Optional cross-field constraint applied after normalization. */
114
+ clampFn?: (result: Record<string, unknown>) => void;
115
+ /** Optional env var for propagating settings to subagents (active when `session` is a level). */
116
+ envVar?: string;
117
+ /** Optional hook called after the handle loads settings (registration + each session_start). */
118
+ onLoad?: () => void;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Constants
123
+ // ---------------------------------------------------------------------------
124
+
125
+ const SAVE_AS_PROJECT_KEY = "\x13"; // Ctrl+S
126
+ const SAVE_AS_DEFAULT_KEY = "\x04"; // Ctrl+D
127
+
128
+ const LEVEL_PROJECT: StorageLevel = "project";
129
+ const LEVEL_GLOBAL: StorageLevel = "global";
130
+
131
+ const HELP_NAV_LINE = "Esc | ↑↓: navigate | ←→: filter cursor | Tab/Shift+Tab: switch tab | Enter: change";
132
+
133
+ /** Build the modal help text. Always composes the navigation line, plus a storage portion.
134
+ * Single level → an auto-persist note (stateless handles write each edit immediately via
135
+ * updateSetting; a session-only handle is in-memory); multiple levels → the enabled save keys
136
+ * only (Ctrl+S project, Ctrl+D global). */
137
+ export function buildHelpText(storageLevels: StorageLevel[]): string {
138
+ if (storageLevels.length === 1) {
139
+ return `${HELP_NAV_LINE} · Changes save to ${storageLevels[0]} automatically`;
140
+ }
141
+ const keys: string[] = [];
142
+ if (storageLevels.includes(LEVEL_PROJECT)) keys.push("Ctrl+S: save project");
143
+ if (storageLevels.includes(LEVEL_GLOBAL)) keys.push("Ctrl+D: save global");
144
+ return keys.length > 0 ? `${HELP_NAV_LINE} | ${keys.join(" | ")}` : HELP_NAV_LINE;
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // openSettingsModal
149
+ // ---------------------------------------------------------------------------
150
+
151
+ /**
152
+ * Open the settings modal for a given settings handle + options.
153
+ *
154
+ * Handles: ModalFrame wrapping, SettingsTabsModal, onChange with
155
+ * updateSetting + env var serialization, Ctrl+S/D save persistence.
156
+ */
157
+ export async function openSettingsModal<S = Record<string, unknown>>(
158
+ ctx: ExtensionCommandContext,
159
+ handle: SettingsHandleInternal<S>,
160
+ options: SettingsModalOptions,
161
+ ): Promise<void> {
162
+ if (!ctx.hasUI) {
163
+ ctx.ui.notify(`/${options.commandName} requires interactive TUI mode.`, "warning");
164
+ return;
165
+ }
166
+
167
+ const overlayOptions = { anchor: "center" as const, width: "90%" as const, maxHeight: "90%" as const, margin: 1 };
168
+ const { onAfterChange, beforeOpen } = options;
169
+
170
+ // Storage levels are inherited from the handle (the factory is the single source of truth):
171
+ // dedup + drop non-union/typos before the count rule. single-level = auto-persist note + no
172
+ // save keys (a stateless handle writes each edit immediately via updateSetting; session-only
173
+ // is in-memory); multi-level = save-key mode (Ctrl+S/D for the enabled file levels).
174
+ const normalizedLevels = normalizeStorageLevels(handle.storageLevels);
175
+ const singleLevel = normalizedLevels.length === 1;
176
+
177
+ beforeOpen?.(ctx);
178
+
179
+ // Stash the command context (modelRegistry + cwd) for resolvers that read it later
180
+ // (the model type-def's preset resolver). Runs before the resolvers are invoked below.
181
+ captureCtx(ctx);
182
+
183
+ let settingsTabs: SettingsTabsModal | undefined;
184
+
185
+ await withCoordinator(async () => {
186
+ await ctx.ui.custom<void>(
187
+ async (tui, theme: Theme, _keybindings, done) => {
188
+ // Resolve every function-presets source once, at open (the model resolver's live list).
189
+ // Closed over by the still-synchronous buildTabs below so re-invocations (tab sync,
190
+ // filter rebuilds) reuse the single per-open map instead of re-hitting the registry.
191
+ const { pairs: resolvedPairs, failedIds } = await resolveFunctionPresets(handle.schema);
192
+ // A resolver that threw/rejected degrades to defaults (modal still opens); tell the user
193
+ // which setting's options could not load so an empty picker is not a mystery.
194
+ if (failedIds.length > 0) {
195
+ ctx.ui.notify(`⚠ Could not load options for: ${failedIds.join(", ")}. Showing defaults.`, "warning");
196
+ }
197
+
198
+ settingsTabs = new SettingsTabsModal(
199
+ {
200
+ title: options.title,
201
+ buildTabs: () => {
202
+ const settings = handle.getSettings() as unknown as Record<string, unknown>;
203
+ return buildSchemaTabGroups(settings, handle.schema, resolvedPairs);
204
+ },
205
+ onChange: (id, newValue) => {
206
+ if (id.startsWith("__")) return;
207
+
208
+ // updateSetting owns ALL persistence: stateless handles read-modify-write the file
209
+ // immediately; buffer handles update the buffer + serialize the env var (when session
210
+ // is included). The modal adds nothing here — onAfterChange is for consumer side
211
+ // effects beyond the standard update path.
212
+ handle.updateSetting(id, newValue);
213
+ onAfterChange?.(id, newValue);
214
+
215
+ if (settingsTabs) {
216
+ settingsTabs.syncActiveTab();
217
+ }
218
+ },
219
+ onClose: () => done(),
220
+ enableSearch: false,
221
+ },
222
+ theme,
223
+ tui,
224
+ );
225
+
226
+ const modal = new ModalFrame({
227
+ title: options.title,
228
+ titleRight: options.titleRight,
229
+ content: settingsTabs,
230
+ helpText: buildHelpText(normalizedLevels),
231
+ });
232
+
233
+ return {
234
+ render(width: number) {
235
+ return modal.render(width);
236
+ },
237
+ invalidate() {
238
+ settingsTabs?.invalidate();
239
+ },
240
+ handleInput(data: string) {
241
+ // Ctrl+S / Ctrl+D persist the buffer to a file level (save-key mode). Only offered in
242
+ // MULTI-level mode — a single-level handle auto-persists (stateless writes each edit
243
+ // via updateSetting; session-only is in-memory), so there is nothing to bulk-save. The
244
+ // keys are always consumed here (modal-level) so they never reach the list.
245
+ if (data === SAVE_AS_PROJECT_KEY) {
246
+ if (!singleLevel && normalizedLevels.includes(LEVEL_PROJECT)) {
247
+ handle.saveProjectSettings(handle.getSettings() as unknown as Record<string, unknown>, process.cwd());
248
+ ctx.ui.notify("Settings saved to project.", "info");
249
+ }
250
+ return;
251
+ }
252
+ if (data === SAVE_AS_DEFAULT_KEY) {
253
+ if (!singleLevel && normalizedLevels.includes(LEVEL_GLOBAL)) {
254
+ handle.saveGlobalSettings(handle.getSettings() as unknown as Record<string, unknown>);
255
+ ctx.ui.notify("Settings saved as global default.", "info");
256
+ }
257
+ return;
258
+ }
259
+ settingsTabs?.handleInput(data);
260
+ tui.requestRender();
261
+ },
262
+ };
263
+ },
264
+ { overlay: true, overlayOptions },
265
+ );
266
+ });
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // registerSettingsCommand
271
+ // ---------------------------------------------------------------------------
272
+
273
+ /**
274
+ * Register a settings command + modal and wire up auto-load + session_start handler.
275
+ *
276
+ * Call this in your extension's activate function (where `pi` is available). It creates the
277
+ * settings handle internally (from the schema + store options), so callers register and get a
278
+ * handle in one step. Auto-load runs at registration (process.cwd()) and again on every
279
+ * session_start (ctx.cwd), so settings are fresh for each session — no manual loading. Handles:
280
+ * - /command-name registration
281
+ * - loadSettingsIntoMemory at registration + on session_start (env-var serialization is inside it)
282
+ */
283
+ export function registerSettingsCommand<S extends object = Record<string, unknown>>(
284
+ pi: ExtensionAPI,
285
+ schema: SettingsSchema,
286
+ options: RegisterSettingsOptions,
287
+ ): SettingsHandle<S> {
288
+ // Build the settings handle (the store) — pi-independent; created here so callers register +
289
+ // get a handle in one step. The returned handle is what they read settings from at runtime.
290
+ const handle = createSettingsExtension<S>(schema, {
291
+ storageLevels: options.storageLevels,
292
+ clampFn: options.clampFn,
293
+ envVar: options.envVar,
294
+ onLoad: options.onLoad,
295
+ });
296
+
297
+ // Subscribe to dialog coordinator so openSettingsModal's withCoordinator wrapping works
298
+ subscribeToDialogCoordinator(pi);
299
+
300
+ // Load settings now (registration time) so they're available before the first session — every
301
+ // session_start re-loads them. env-var serialization (when session is included) is inside it.
302
+ handle.loadSettingsIntoMemory(process.cwd(), NO_GLOBAL_DIR);
303
+
304
+ // Register the settings command. `options` carries the modal fields (commandName/title/…).
305
+ pi.registerCommand(options.commandName, {
306
+ description: `Configure ${options.title}`,
307
+ async handler(_args: unknown, ctx: ExtensionCommandContext) {
308
+ await openSettingsModal(ctx, handle, options);
309
+ },
310
+ });
311
+
312
+ // Re-load settings on session start (handles /reload + per-session cwd)
313
+ pi.on("session_start", async (_event: unknown, ctx: unknown) => {
314
+ const cwd = (ctx as { cwd?: string } | undefined)?.cwd;
315
+ if (cwd) {
316
+ handle.loadSettingsIntoMemory(cwd, NO_GLOBAL_DIR);
317
+ }
318
+ });
319
+
320
+ return handle;
321
+ }