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.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/assets/images/settings-dropdown.png +0 -0
- package/assets/images/settings.png +0 -0
- package/index.ts +30 -0
- package/package.json +65 -0
- package/src/ctx-store.ts +34 -0
- package/src/custom-validation.ts +45 -0
- package/src/factory.ts +233 -0
- package/src/model-presets.ts +101 -0
- package/src/normalization.ts +421 -0
- package/src/persistence.ts +115 -0
- package/src/preset-resolver.ts +66 -0
- package/src/schema.ts +144 -0
- package/src/snippets/vendored/subscribe-to-dialog-coordinator.ts +98 -0
- package/src/snippets/vendored/subscribe-to-notifications.ts +150 -0
- package/src/type-definitions.ts +301 -0
- package/src/ui/modal-frame.ts +130 -0
- package/src/ui/picker-helpers.ts +68 -0
- package/src/ui/schema-tabs.ts +140 -0
- package/src/ui/settings-command.ts +321 -0
- package/src/ui/settings-modal.ts +414 -0
- package/src/ui/value-picker.ts +353 -0
- package/src/validation.ts +107 -0
|
@@ -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
|
+
}
|