pi-switchback 0.0.0-stage → 2026.10.14

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,201 @@
1
+ /**
2
+ * The primitives layer of the `/switchback-config` dialogue.
3
+ *
4
+ * Everything here is shared by both halves of the dialogue - the shell
5
+ * (`src/dialogue.ts`) and the decision-model wizard
6
+ * (`src/dialogue-classifier.ts`) - and none of it belongs to either:
7
+ *
8
+ * - the session (the loaded layer plus the secret store) and the structural
9
+ * UI types,
10
+ * - the primitives every screen needs: a screen title naming the file being
11
+ * edited, a validated read of that layer, a save that either succeeds or
12
+ * reverts exactly the failed action, and a model lookup,
13
+ * - every prompt (required, name-last, optional) in one place, so the wording
14
+ * of "a value is required" or "empty clears, Esc keeps" is defined once,
15
+ * - the two pure formatters that render a classifier for a menu
16
+ * (`describeJev`) or name its secret entry (`secretNameFor`).
17
+ *
18
+ * It is a module of its own so the two halves can both depend on it without
19
+ * importing each other.
20
+ *
21
+ * See `src/dialogue.ts` for the design of the dialogue as a whole.
22
+ */
23
+
24
+ import { loadLayer, readLayerConfig, saveLayer, type LoadedLayer } from "switchback-core/config-editor.ts";
25
+ import type { ClassifierProviderOption } from "switchback-core/classifier-catalog.ts";
26
+ import type { ProbeResult } from "switchback-core/classifier-probe.ts";
27
+ import type { SecretStore } from "./secrets.ts";
28
+ import type { JevConfig, JevRef, SwitchbackConfig, SwitchbackFileConfig } from "switchback-core/types.ts";
29
+
30
+ /** The dialog surface the dialogue needs (a subset of pi's `ctx.ui`). */
31
+ export interface DialogueUi {
32
+ notify(message: string, type?: "info" | "warning" | "error"): void;
33
+ select(title: string, options: string[]): Promise<string | undefined>;
34
+ confirm(title: string, message: string): Promise<boolean>;
35
+ input(title: string, placeholder?: string): Promise<string | undefined>;
36
+ }
37
+
38
+ /** The command context the dialogue runs in (structurally satisfied by pi's context). */
39
+ export interface DialogueContext {
40
+ hasUI: boolean;
41
+ ui: DialogueUi;
42
+ /**
43
+ * Optional searchable model picker (TUI contexts supply it). When present,
44
+ * "Add fallback..." opens a fuzzy-searchable catalog list instead of asking for
45
+ * `provider/id` from memory; when absent the dialogue falls back to the prompt.
46
+ */
47
+ pickModel?: (title: string, current: readonly string[]) => Promise<string | undefined>;
48
+ /**
49
+ * Classifier endpoints pi knows about, for the decision-model wizard's provider
50
+ * choice and base-URL defaults (see `packages/core/src/classifier-catalog.ts`). Empty or
51
+ * absent falls back to free-text prompts.
52
+ */
53
+ classifierProviders?: readonly ClassifierProviderOption[];
54
+ /**
55
+ * Send the one-shot capability prompt (choice + score + noul) to a configured
56
+ * decision model. Supplied by the command handler, which has the model registry
57
+ * and switchback's transport; absent means the test is not offered.
58
+ */
59
+ probeClassifier?: (jev: JevConfig) => Promise<ProbeResult>;
60
+ }
61
+
62
+ export interface DialogueSession {
63
+ loaded: LoadedLayer;
64
+ secrets: SecretStore;
65
+ }
66
+ /** Describe a model's classifier for menus: reference, inline, or none. */
67
+ export function describeJev(jev: JevRef | undefined): string {
68
+ if (jev === undefined) return "(none - report and cycle)";
69
+ if ("decisionModel" in jev) return `decision model "${jev.decisionModel}"`;
70
+ const direct = jev.baseUrl !== undefined ? ` (direct: ${jev.baseUrl})` : "";
71
+ return `${jev.provider}/${jev.id}${direct}`;
72
+ }
73
+ export function errorMessage(error: unknown): string {
74
+ return error instanceof Error ? error.message : String(error);
75
+ }
76
+
77
+ export function screenTitle(loaded: LoadedLayer, body: string): string {
78
+ const fresh = loaded.existed ? "" : " (new file)";
79
+ return `[${loaded.layer} layer: ${loaded.path}${fresh}]\n${body}`;
80
+ }
81
+ /**
82
+ * The validated config snapshot for menus; a file that fails the loader's
83
+ * rules is reported (with the fix hint) and ends the dialogue - the editor
84
+ * only round-trips config the loader itself accepts.
85
+ */
86
+ export function readConfigOrReport(ctx: DialogueContext, loaded: LoadedLayer): SwitchbackFileConfig | undefined {
87
+ try {
88
+ return readLayerConfig(loaded);
89
+ } catch (error) {
90
+ ctx.ui.notify(
91
+ `${errorMessage(error)}\nThe editor only opens valid config; fix switchback.yaml by hand and retry.`,
92
+ "error",
93
+ );
94
+ return undefined;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Save and report success; on a ConfigError, report the loader's message and
100
+ * reload the layer from disk so exactly the failed action is reverted (the
101
+ * file on disk always holds the last good config). Returns true when saved.
102
+ */
103
+ export async function saveOrRevert(ctx: DialogueContext, session: DialogueSession): Promise<boolean> {
104
+ try {
105
+ saveLayer(session.loaded);
106
+ return true;
107
+ } catch (error) {
108
+ ctx.ui.notify(
109
+ `${screenTitle(session.loaded, "not saved")}\n${errorMessage(error)}\nThe change was reverted - the file on disk is unchanged.`,
110
+ "error",
111
+ );
112
+ try {
113
+ session.loaded = loadLayer(session.loaded.layer);
114
+ } catch (reloadError) {
115
+ ctx.ui.notify(`cannot reload layer after failed save: ${errorMessage(reloadError)}`, "error");
116
+ }
117
+ return false;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * The validated config snapshot, or undefined when the layer has no content
123
+ * yet (fresh file) - callers treat undefined as "nothing to clash with"; the
124
+ * save gate still validates everything that ends up in the file.
125
+ */
126
+ export function tryReadConfig(loaded: LoadedLayer): SwitchbackFileConfig | undefined {
127
+ try {
128
+ return readLayerConfig(loaded);
129
+ } catch {
130
+ return undefined;
131
+ }
132
+ }
133
+ /** Required single-line input: Esc aborts the step, empty input re-prompts. */
134
+ export async function promptRequired(
135
+ ctx: DialogueContext,
136
+ title: string,
137
+ placeholder: string,
138
+ ): Promise<string | undefined> {
139
+ for (;;) {
140
+ const value = await ctx.ui.input(title, placeholder);
141
+ if (value === undefined) return undefined;
142
+ const trimmed = value.trim();
143
+ if (trimmed.length > 0) return trimmed;
144
+ ctx.ui.notify("a value is required (Esc leaves the step)", "warning");
145
+ }
146
+ }
147
+ /**
148
+ * Name-last step of the decision-model wizard: the input arrives prefilled with
149
+ * a derived default, empty input keeps the default, Esc aborts. A name already
150
+ * in use is rejected with a warning and the prompt loops.
151
+ */
152
+ export async function promptNameLast(
153
+ ctx: DialogueContext,
154
+ loaded: LoadedLayer,
155
+ suggestedName: string,
156
+ knownNames: readonly string[],
157
+ ): Promise<string | undefined> {
158
+ for (;;) {
159
+ const value = await ctx.ui.input(
160
+ screenTitle(loaded, `new decision model - name (Enter keeps "${suggestedName}")`),
161
+ suggestedName,
162
+ );
163
+ if (value === undefined) return undefined;
164
+ const trimmed = value.trim();
165
+ const name = trimmed.length === 0 ? suggestedName : trimmed;
166
+ if (knownNames.includes(name)) {
167
+ ctx.ui.notify(`a decision model named "${name}" already exists`, "warning");
168
+ continue;
169
+ }
170
+ return name;
171
+ }
172
+ }
173
+
174
+ /** Optional single-line input: Esc = keep (undefined), empty = clear (null), text = set. */
175
+ export async function promptOptional(
176
+ ctx: DialogueContext,
177
+ title: string,
178
+ current: string | undefined,
179
+ ): Promise<string | null | undefined> {
180
+ const shown = current !== undefined && current.length > 0 ? ` (current: ${current})` : " (not set)";
181
+ const value = await ctx.ui.input(`${title}${shown} - empty clears, Esc keeps`, "value");
182
+ if (value === undefined) return undefined;
183
+ const trimmed = value.trim();
184
+ return trimmed.length === 0 ? null : trimmed;
185
+ }
186
+ export function findModel(config: SwitchbackFileConfig, id: string): SwitchbackConfig | undefined {
187
+ return config.models.find((m) => m.id === id);
188
+ }
189
+ /**
190
+ * Secret names are an internal detail of the encrypted store, so the dialogue
191
+ * derives one instead of asking: `ollama-key`, `local-jev-key`, and so on. A
192
+ * second key for the same entry simply replaces the stored value under that name.
193
+ */
194
+ export function secretNameFor(hint: string): string {
195
+ const cleaned = hint
196
+ .trim()
197
+ .toLowerCase()
198
+ .replace(/[^a-z0-9]+/gu, "-")
199
+ .replace(/^-+|-+$/gu, "");
200
+ return `${cleaned.length === 0 ? "classifier" : cleaned}-key`;
201
+ }
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Interactive `/switchback-config` dialogue: an edit loop over one
3
+ * switchback.yaml layer, built on pi's built-in dialogs (`ctx.ui.select`,
4
+ * `confirm`, `input`, `notify`).
5
+ *
6
+ * This module is the shell: it picks the layer, owns the main menu, the
7
+ * per-model menu and the fallback editor, and hands the classifier work to
8
+ * `src/dialogue-classifier.ts`. Session state, the screen primitives and
9
+ * every prompt live in `src/dialogue-ui.ts`.
10
+ *
11
+ * Design: every mutation goes through `packages/core/src/config-editor.ts` and is saved
12
+ * immediately (`saveLayer`). The file on disk therefore always holds the
13
+ * last good config, which makes failure handling trivial: when a save
14
+ * throws the loader's own ConfigError, the dialogue reports it and reloads
15
+ * the layer from disk - exactly the failed action is reverted, nothing else.
16
+ * No undo bookkeeping, and the document in memory never drifts from a valid
17
+ * state for longer than one action.
18
+ *
19
+ * The dialogue is deliberately thin: it owns prompt/confirm flow and error
20
+ * reporting only. All rules live in the loader (single validator), all file
21
+ * mutation lives in the editor, all secret material lives in the encrypted
22
+ * store - a literal API key is unrepresentable here because the editor only
23
+ * accepts a secret NAME and the value goes straight into the store.
24
+ *
25
+ * UI surface: structural types (`DialogueContext`) instead of pi's command
26
+ * context type. The real command context is assignable to the structural
27
+ * shape, tests can mock it without a TUI, and this module keeps a bare
28
+ * import surface (install-safe: no host-package imports at all).
29
+ *
30
+ * Screen titles always carry the file path being edited, so it is always
31
+ * obvious which layer - project or global - a change lands in.
32
+ */
33
+
34
+ import {
35
+ addFallback,
36
+ addVirtualModel,
37
+ defaultLayer,
38
+ globalConfigPath,
39
+ layerExists,
40
+ loadLayer,
41
+ moveFallback,
42
+ projectConfigPath,
43
+ readLayerConfig,
44
+ removeFallback,
45
+ removeVirtualModel,
46
+ renameVirtualModel,
47
+ setDebug,
48
+ setVirtualModelName,
49
+ type ConfigLayer,
50
+ type LoadedLayer,
51
+ } from "switchback-core/config-editor.ts";
52
+ import { createSecretStore, type SecretStore } from "./secrets.ts";
53
+ import {
54
+ describeJev,
55
+ errorMessage,
56
+ findModel,
57
+ promptRequired,
58
+ readConfigOrReport,
59
+ saveOrRevert,
60
+ screenTitle,
61
+ type DialogueContext,
62
+ type DialogueSession,
63
+ } from "./dialogue-ui.ts";
64
+ import { decisionModelPicker, decisionModelsMenu } from "./dialogue-classifier.ts";
65
+ import type { SwitchbackConfig, SwitchbackFileConfig } from "switchback-core/types.ts";
66
+
67
+ /**
68
+ * Label for one layer in the chooser: which file, and what is in it right now.
69
+ * Paths live in the screen title, so a label stays stable and scriptable.
70
+ */
71
+ export function layerOptionLabel(layer: ConfigLayer): string {
72
+ const name = layer === "global" ? "Global" : "Project";
73
+ return `${name} - ${describeLayerState(layer)}`;
74
+ }
75
+
76
+ /** "not present", "N model(s)" or "unreadable (invalid config)" for a layer. */
77
+ function describeLayerState(layer: ConfigLayer): string {
78
+ if (!layerExists(layer)) return "not present";
79
+ try {
80
+ const models = readLayerConfig(loadLayer(layer)).models.length;
81
+ return `${models} model${models === 1 ? "" : "s"}`;
82
+ } catch {
83
+ return "unreadable (invalid config)";
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Ask which layer to edit.
89
+ *
90
+ * This is the dialogue's first prompt, and the target of "Switch layer...",
91
+ * because the layer changes what every later action means: project entries
92
+ * override global entries with the same model id, so writing to the wrong file
93
+ * either shadows the other config or fails to. The chooser shows both files,
94
+ * what each currently holds, and which one is being edited; the preferred layer
95
+ * is offered first (Enter keeps it). Esc cancels the dialogue.
96
+ */
97
+ async function selectLayer(ctx: DialogueContext, current: ConfigLayer | undefined): Promise<ConfigLayer | undefined> {
98
+ const preferred = current ?? defaultLayer();
99
+ const labels: Record<ConfigLayer, string> = {
100
+ global: layerOptionLabel("global"),
101
+ project: layerOptionLabel("project"),
102
+ };
103
+ const order: ConfigLayer[] = preferred === "global" ? ["global", "project"] : ["project", "global"];
104
+ const title = [
105
+ current === undefined ? "which config layer do you want to edit?" : `switch layer (currently: ${current})`,
106
+ ` global: ${globalConfigPath()}`,
107
+ ` project: ${projectConfigPath()}`,
108
+ "Project entries override global entries with the same model id.",
109
+ ].join("\n");
110
+ const choice = await ctx.ui.select(
111
+ title,
112
+ order.map((layer) => labels[layer]),
113
+ );
114
+ if (choice === undefined) return undefined;
115
+ return choice === labels["global"] ? "global" : "project";
116
+ }
117
+
118
+ /**
119
+ * Run the dialogue. Returns when the user is done; every completed action has
120
+ * already been saved to the layer file by then.
121
+ */
122
+ export async function runDialogue(ctx: DialogueContext, store?: SecretStore): Promise<void> {
123
+ if (!ctx.hasUI) {
124
+ ctx.ui.notify("/switchback-config needs a UI-capable context (TUI or RPC)", "warning");
125
+ return;
126
+ }
127
+ const secrets = store ?? createSecretStore();
128
+ const chosen = await selectLayer(ctx, undefined);
129
+ if (chosen === undefined) return;
130
+ let layer: ConfigLayer = chosen;
131
+ for (;;) {
132
+ const loaded = openLayer(ctx, layer);
133
+ if (loaded === undefined) return;
134
+ const session: DialogueSession = { loaded, secrets };
135
+ const outcome = await mainMenu(ctx, session);
136
+ if (outcome === "done") return;
137
+ const next = await selectLayer(ctx, loaded.layer);
138
+ if (next === undefined) return;
139
+ layer = next;
140
+ }
141
+ }
142
+
143
+ /** Load a layer for editing; a read or YAML failure is reported and ends the dialogue. */
144
+ function openLayer(ctx: DialogueContext, layer: ConfigLayer): LoadedLayer | undefined {
145
+ try {
146
+ return loadLayer(layer);
147
+ } catch (error) {
148
+ ctx.ui.notify(`cannot open ${layer} layer: ${errorMessage(error)}`, "error");
149
+ return undefined;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Ask for one physical model. With a TUI picker available the catalog is
155
+ * searchable (the `/model` experience); otherwise the user types `provider/id`.
156
+ */
157
+ async function chooseFallback(
158
+ ctx: DialogueContext,
159
+ session: DialogueSession,
160
+ title: string,
161
+ current: readonly string[],
162
+ ): Promise<string | undefined> {
163
+ if (ctx.pickModel !== undefined) return ctx.pickModel(title, current);
164
+ return promptRequired(ctx, title, "provider/id");
165
+ }
166
+
167
+ type MainOutcome = "done" | "switch";
168
+
169
+ async function mainMenu(ctx: DialogueContext, session: DialogueSession): Promise<MainOutcome> {
170
+ for (;;) {
171
+ // A layer with no content yet (first save) has no valid config to read;
172
+ // offer the reduced menu until the first virtual model exists.
173
+ if (session.loaded.doc.contents === undefined || session.loaded.doc.contents === null) {
174
+ const choice = await ctx.ui.select(screenTitle(session.loaded, "no config in this layer yet"), [
175
+ "Add virtual model...",
176
+ "Switch layer...",
177
+ "Done",
178
+ ]);
179
+ if (choice === undefined || choice === "Done") return "done";
180
+ if (choice.startsWith("Switch layer")) return "switch";
181
+ await addVirtualModelWizard(ctx, session);
182
+ continue;
183
+ }
184
+ const config = readConfigOrReport(ctx, session.loaded);
185
+ if (config === undefined) return "done";
186
+ const options: string[] = config.models.map((m) => `${m.id} - ${m.name}`);
187
+ const addOption = "Add virtual model...";
188
+ const debugOption = `Toggle debug (currently ${config.debug === true ? "on" : "off"})`;
189
+ const dmOption = `Decision models (${config.decisionModels?.length ?? 0})...`;
190
+ const switchOption = "Switch layer...";
191
+ const doneOption = "Done";
192
+ options.push(addOption, debugOption, dmOption, switchOption, doneOption);
193
+
194
+ const choice = await ctx.ui.select(screenTitle(session.loaded, "switchback configuration"), options);
195
+ if (choice === undefined || choice === doneOption) return "done";
196
+ if (choice === switchOption) return "switch";
197
+ if (choice === debugOption) {
198
+ await toggleDebug(ctx, session, config);
199
+ continue;
200
+ }
201
+ if (choice === dmOption) {
202
+ await decisionModelsMenu(ctx, session);
203
+ continue;
204
+ }
205
+ if (choice === addOption) {
206
+ await addVirtualModelWizard(ctx, session);
207
+ continue;
208
+ }
209
+ const model = config.models.find((m) => `${m.id} - ${m.name}` === choice);
210
+ if (model !== undefined) await virtualModelMenu(ctx, session, model);
211
+ }
212
+ }
213
+
214
+ async function toggleDebug(
215
+ ctx: DialogueContext,
216
+ session: DialogueSession,
217
+ config: SwitchbackFileConfig,
218
+ ): Promise<void> {
219
+ const next = !(config.debug === true);
220
+ setDebug(session.loaded, next);
221
+ if (await saveOrRevert(ctx, session)) {
222
+ ctx.ui.notify(`debug ${next ? "on" : "off"} - saved to ${session.loaded.path}`, "info");
223
+ }
224
+ }
225
+
226
+ async function virtualModelMenu(
227
+ ctx: DialogueContext,
228
+ session: DialogueSession,
229
+ model: SwitchbackConfig,
230
+ ): Promise<void> {
231
+ const renameIdOption = "Rename model id (pi restart required)";
232
+ const renameNameOption = "Change display name";
233
+ const editFallbacks = (n: number): string => `Edit fallbacks (${n})`;
234
+ const decisionOption = (config: SwitchbackConfig): string => `Decision model: ${describeJev(config.jev)}`;
235
+ const removeOption = "Remove model";
236
+ const backOption = "Back";
237
+ for (;;) {
238
+ const config = readConfigOrReport(ctx, session.loaded);
239
+ if (config === undefined) return;
240
+ const current = findModel(config, model.id);
241
+ if (current === undefined) return; // removed elsewhere in this session
242
+ const fallbacksOption = editFallbacks(current.fallbacks.length);
243
+ const decisionLabel = decisionOption(current);
244
+ const choice = await ctx.ui.select(screenTitle(session.loaded, `${current.id} - ${current.name}`), [
245
+ renameIdOption,
246
+ renameNameOption,
247
+ fallbacksOption,
248
+ decisionLabel,
249
+ removeOption,
250
+ backOption,
251
+ ]);
252
+ if (choice === undefined || choice === backOption) return;
253
+ if (choice === renameIdOption) {
254
+ const newId = await promptRequired(
255
+ ctx,
256
+ screenTitle(
257
+ session.loaded,
258
+ `rename ${current.id} - new model id (bare "auto" becomes "switchback/auto")`,
259
+ ),
260
+ current.id,
261
+ );
262
+ if (newId === undefined) continue;
263
+ renameVirtualModel(session.loaded, current.id, newId);
264
+ if (await saveOrRevert(ctx, session)) {
265
+ model.id = newId;
266
+ ctx.ui.notify(`renamed to ${newId} - the running pi keeps the old id until restart`, "info");
267
+ }
268
+ continue;
269
+ }
270
+ if (choice === renameNameOption) {
271
+ const newName = await promptRequired(
272
+ ctx,
273
+ screenTitle(session.loaded, `display name for ${current.id}`),
274
+ current.name,
275
+ );
276
+ if (newName === undefined) continue;
277
+ setVirtualModelName(session.loaded, current.id, newName);
278
+ if (await saveOrRevert(ctx, session)) model.name = newName;
279
+ continue;
280
+ }
281
+ if (choice === fallbacksOption) {
282
+ await fallbacksEditor(ctx, session, current.id);
283
+ continue;
284
+ }
285
+ if (choice === decisionLabel) {
286
+ await decisionModelPicker(ctx, session, current.id);
287
+ continue;
288
+ }
289
+ if (choice === removeOption) {
290
+ const configNow = readConfigOrReport(ctx, session.loaded);
291
+ if (configNow === undefined) return;
292
+ if (configNow.models.length <= 1) {
293
+ ctx.ui.notify("the last virtual model cannot be removed - add another one first", "warning");
294
+ continue;
295
+ }
296
+ const confirmed = await ctx.ui.confirm(
297
+ screenTitle(session.loaded, `remove ${current.id}`),
298
+ `Remove ${current.id} and its ${current.fallbacks.length} fallback(s) from ${session.loaded.path}?`,
299
+ );
300
+ if (!confirmed) continue;
301
+ removeVirtualModel(session.loaded, current.id);
302
+ if (await saveOrRevert(ctx, session)) {
303
+ ctx.ui.notify(`removed ${current.id}`, "info");
304
+ return;
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ async function fallbacksEditor(ctx: DialogueContext, session: DialogueSession, virtualId: string): Promise<void> {
311
+ const addOption = "Add fallback...";
312
+ const removeOption = "Remove fallback...";
313
+ const upOption = "Move up...";
314
+ const downOption = "Move down...";
315
+ const doneOption = "Done";
316
+ for (;;) {
317
+ const config = readConfigOrReport(ctx, session.loaded);
318
+ if (config === undefined) return;
319
+ const model = findModel(config, virtualId);
320
+ if (model === undefined) return;
321
+ const body = ["Fallbacks (first = preferred):", ...model.fallbacks.map((f, i) => ` ${i + 1}. ${f}`)].join(
322
+ "\n",
323
+ );
324
+ const choice = await ctx.ui.select(screenTitle(session.loaded, body), [
325
+ addOption,
326
+ removeOption,
327
+ upOption,
328
+ downOption,
329
+ doneOption,
330
+ ]);
331
+ if (choice === undefined || choice === doneOption) return;
332
+ const labels = model.fallbacks.map((f, i) => `${i + 1}. ${f}`);
333
+ if (choice === addOption) {
334
+ const fallback = await chooseFallback(
335
+ ctx,
336
+ session,
337
+ screenTitle(session.loaded, `add fallback to ${virtualId} ("provider/id")`),
338
+ model.fallbacks,
339
+ );
340
+ if (fallback === undefined) continue;
341
+ addFallback(session.loaded, virtualId, fallback);
342
+ await saveOrRevert(ctx, session);
343
+ continue;
344
+ }
345
+ if (choice === removeOption) {
346
+ if (model.fallbacks.length <= 1) {
347
+ ctx.ui.notify("at least one fallback is required - add one before removing the last", "warning");
348
+ continue;
349
+ }
350
+ const target = await ctx.ui.select(screenTitle(session.loaded, "remove which fallback?"), labels);
351
+ const index = target === undefined ? -1 : labels.indexOf(target);
352
+ if (index < 0) continue;
353
+ removeFallback(session.loaded, virtualId, index);
354
+ await saveOrRevert(ctx, session);
355
+ continue;
356
+ }
357
+ if (choice === upOption || choice === downOption) {
358
+ const up = choice === upOption;
359
+ const candidates = model.fallbacks
360
+ .map((f, i) => ({ label: `${i + 1}. ${f}`, index: i }))
361
+ .filter((e) => (up ? e.index > 0 : e.index < model.fallbacks.length - 1));
362
+ if (candidates.length === 0) {
363
+ ctx.ui.notify(`that fallback list has nothing to move ${up ? "up" : "down"}`, "warning");
364
+ continue;
365
+ }
366
+ const target = await ctx.ui.select(
367
+ screenTitle(session.loaded, `move which fallback ${up ? "up" : "down"}?`),
368
+ candidates.map((e) => e.label),
369
+ );
370
+ const picked = target === undefined ? undefined : candidates.find((e) => e.label === target);
371
+ if (picked === undefined) continue;
372
+ moveFallback(session.loaded, virtualId, picked.index, up ? picked.index - 1 : picked.index + 1);
373
+ await saveOrRevert(ctx, session);
374
+ }
375
+ }
376
+ }
377
+ /** Wizard: create a new virtual model entry (id, name, at least one fallback). */
378
+ async function addVirtualModelWizard(ctx: DialogueContext, session: DialogueSession): Promise<void> {
379
+ const id = await promptRequired(
380
+ ctx,
381
+ screenTitle(session.loaded, 'new virtual model - id (bare "auto" becomes "switchback/auto")'),
382
+ "auto",
383
+ );
384
+ if (id === undefined) return;
385
+ const name = await promptRequired(ctx, screenTitle(session.loaded, `display name for ${id}`), "e.g. Auto (fast)");
386
+ if (name === undefined) return;
387
+ const fallbacks: string[] = [];
388
+ const addOption = "Add fallback...";
389
+ const doneOption = "Done (needs at least one)";
390
+ for (;;) {
391
+ const body = [
392
+ "Fallbacks for the new model (first = preferred):",
393
+ ...(fallbacks.length === 0 ? [" (none yet)"] : fallbacks.map((f, i) => ` ${i + 1}. ${f}`)),
394
+ ].join("\n");
395
+ const choice = await ctx.ui.select(screenTitle(session.loaded, body), [addOption, doneOption]);
396
+ if (choice === undefined) return;
397
+ if (choice === addOption) {
398
+ const fallback = await chooseFallback(
399
+ ctx,
400
+ session,
401
+ screenTitle(session.loaded, `fallback ${fallbacks.length + 1} ("provider/id")`),
402
+ fallbacks,
403
+ );
404
+ if (fallback !== undefined) fallbacks.push(fallback);
405
+ continue;
406
+ }
407
+ if (fallbacks.length === 0) {
408
+ ctx.ui.notify("at least one fallback is required - add one first", "warning");
409
+ continue;
410
+ }
411
+ break;
412
+ }
413
+ try {
414
+ addVirtualModel(session.loaded, { id, name, fallbacks });
415
+ } catch (error) {
416
+ ctx.ui.notify(errorMessage(error), "warning");
417
+ return;
418
+ }
419
+ if (await saveOrRevert(ctx, session)) {
420
+ ctx.ui.notify(
421
+ `${id} added with ${fallbacks.length} fallback(s) - use its menu to attach a decision model`,
422
+ "info",
423
+ );
424
+ }
425
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Barrel re-export for tests and downstream consumers of the pi adapter. The pi
3
+ * extension entry (`../index.ts`) imports its modules directly to keep its surface
4
+ * small; this barrel exists so a consumer can import the whole pi-side surface
5
+ * from `pi-switchback` without enumerating modules. The host-agnostic half lives
6
+ * in `switchback-core` and is re-exported here so one import still reaches
7
+ * everything.
8
+ */
9
+
10
+ export * from "switchback-core";
11
+ export { buildRoute, ConfigInvalidError } from "./build-route.ts";
12
+ export { runDialogue, layerOptionLabel } from "./dialogue.ts";
13
+ export { describeJev } from "./dialogue-ui.ts";
14
+ export { createModelPicker, buildModelItems, MODEL_PICKER_MAX_VISIBLE } from "./model-picker.ts";
15
+ export type { ModelPickerSource, PickerColor, PickerKeybindings, PickerTheme, PickerTui } from "./model-picker.ts";
16
+ export type { DialogueContext, DialogueUi } from "./dialogue-ui.ts";
17
+ export { createSecretStore, secretsFilePath, powershellDpapi, SecretsError } from "./secrets.ts";
18
+ export type { SecretStore, Dpapi } from "./secrets.ts";