@gonrocca/nodd 0.1.1 → 0.2.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.
@@ -1,98 +1,677 @@
1
1
  // The picker's decisions, as a pure state machine.
2
2
  //
3
- // Dependency-free by design, after `zero-models-picker.ts:5-11`: no `node:fs`,
4
- // no pi, and above all no TUI package not even as a type. The host in
5
- // `extensions/nodd-models.ts` owns rendering and keystrokes and holds one
6
- // `PickerState`; every navigation and staging decision is here, where
3
+ // Adapted from `zero-models-picker.ts` adapted, never imported. Dependency-free
4
+ // by design: no `node:fs`, no pi, and above all no TUI package, not even as a
5
+ // type. The host in `extensions/nodd-models.ts` owns rendering and keystrokes and
6
+ // holds one `PickerState`; every navigation and staging decision is here, where
7
7
  // `node --test` can reach it without a terminal.
8
8
  //
9
- // Two properties the tests pin down, because both are easy to lose in a
10
- // refactor: the four mechanism rows are displayed but never selectable (a cursor
11
- // that can land on `classify` implies a slot that does not exist), and this
12
- // module writes nothing. It returns outcomes; the command decides.
9
+ // Three properties the tests pin down, because all three are easy to lose in a
10
+ // refactor:
11
+ //
12
+ // 1. The four mechanism rows are displayed but never enterable, and entering one
13
+ // explains why rather than doing nothing. A cursor that can land on
14
+ // `classify` implies a slot that does not exist.
15
+ // 2. This module writes nothing. It returns outcomes; the command decides. That
16
+ // is what makes "quit writes nothing" structural instead of a promise about a
17
+ // code path.
18
+ // 3. Model, provider and level commit together, at the level screen. Escaping
19
+ // before it leaves the slot exactly as it was — a model saved without a level
20
+ // would run at an effort nobody chose.
21
+ //
22
+ // Forge's autotune screen is deliberately absent: autotune is a forge feature and
23
+ // NODD has no learning loop to configure.
13
24
 
14
- import { SLOT_ROWS, type SlotRow } from "./slots.ts";
25
+ import { SLOT_ROWS, isMechanismSlot } from "./slots.ts";
26
+ import { THINKING_LEVELS, type SlotThinking, type ThinkingLevel } from "./thinking.ts";
27
+ import { isValidProfileName, type Profile } from "./profiles.ts";
15
28
 
16
- export type Row = SlotRow & {
17
- /** `action` rows are save/quit; the rest come from the slot table. */
18
- kind: SlotRow["kind"] | "action";
19
- /** The model to show, or the row's placeholder. */
29
+ /** Which sub-screen the picker is showing. */
30
+ export type Screen = "main" | "slots" | "provider" | "model" | "thinking" | "profile-actions";
31
+
32
+ /** One row of the current screen. */
33
+ export type MenuEntry = {
34
+ kind:
35
+ | "profile" // a concrete profile (main)
36
+ | "new-profile" // — nuevo perfil — (main)
37
+ | "edit-loose" // configurar sin perfil (main, only with none)
38
+ | "save" // — guardar y salir — (main)
39
+ | "profile-use" // activar (profile-actions)
40
+ | "profile-active-noop" // ya está activo (profile-actions)
41
+ | "profile-edit" // editar modelos por slot (profile-actions)
42
+ | "profile-duplicate" // duplicar (profile-actions)
43
+ | "profile-delete" // borrar (profile-actions)
44
+ | "slot" // an assignable slot (slots)
45
+ | "mechanism" // a displayed, inert canonical step (slots)
46
+ | "provider" // a concrete provider id (provider)
47
+ | "custom-provider" // — otro provider (escribir) —
48
+ | "model" // a concrete model id (model)
49
+ | "custom-model" // — otro modelo (escribir) —
50
+ | "thinking-level"; // off | minimal | … | xhigh (thinking)
51
+ label: string;
52
+ /** Payload: profile name, slot id, provider id, model id or level. */
20
53
  value: string;
21
- selectable: boolean;
54
+ };
55
+
56
+ /** Edits accumulated in memory. Written to `nodd.json` once, on save. */
57
+ export type StagedEdits = {
58
+ /** slot -> `provider/model`, for the slot set currently being edited. */
59
+ models: Record<string, string>;
60
+ /** slot -> level, parallel to `models`. Partial: an absent slot has no level. */
61
+ thinking: SlotThinking;
62
+ /** Any slot assignment changed. */
63
+ changed: boolean;
64
+ profiles: Record<string, Profile>;
65
+ activeProfile: string | null;
66
+ /**
67
+ * The profile the maps above are editing. `null` means the loose config, which
68
+ * is the active profile's content when there is one. Distinct from
69
+ * `activeProfile`: a profile can be edited without being activated.
70
+ */
71
+ editingProfile: string | null;
72
+ /** A profile was created, deleted, duplicated or activated. */
73
+ profilesChanged: boolean;
22
74
  };
23
75
 
24
76
  export type PickerState = {
77
+ screen: Screen;
25
78
  cursor: number;
26
- /** slot -> `provider/model`, as read from config. */
27
- models: Record<string, string>;
28
- /** Edits not yet saved. Empty until the user chooses something. */
29
- staged: Record<string, string>;
79
+ /** Rows of the current screen derived, never hand-mutated. */
80
+ entries: MenuEntry[];
81
+ edits: StagedEdits;
82
+ /** provider -> model ids, captured from pi's registry when the picker opened. */
30
83
  groups: Map<string, string[]>;
84
+ /** Drill context: the slot being edited. */
85
+ drillSlot: string | null;
86
+ /** Drill context: the provider chosen so far. */
87
+ drillProvider: string | null;
88
+ /** Drill context: the model chosen, awaiting a level. Held out of `edits`. */
89
+ drillModel: string | null;
90
+ /** Drill context: the profile chosen from the list. */
91
+ drillProfile: string | null;
92
+ /**
93
+ * Has a slot been edited since these maps were loaded? Decides whether to fold
94
+ * them into the target profile. `edits.changed` will not do: it is sticky for
95
+ * the whole picker, so after editing one profile, opening a second would fold
96
+ * untouched maps over it.
97
+ */
98
+ dirtySinceLoad: boolean;
99
+ /** When set, the host shows an inline text input for this. */
100
+ textPrompt: { for: "provider" | "model" | "new-profile" | "duplicate-profile"; label: string } | null;
101
+ /** A one-line notice under the title. Cleared by the next transition. */
102
+ notice: string | null;
31
103
  };
32
104
 
33
105
  export type EnterResult =
34
- | { type: "choose"; slot: string; options: string[] }
35
- | { type: "save"; models: Record<string, string> }
36
- | { type: "quit" };
106
+ | { type: "state"; state: PickerState } // stay open, re-render
107
+ | { type: "save"; state: PickerState } // close, persist the edits
108
+ | { type: "quit" }; // close, write nothing
37
109
 
38
- const ACTION_ROWS: Row[] = [
39
- { id: "save", kind: "action", placeholder: "", value: "guardar y salir —", selectable: true },
40
- { id: "quit", kind: "action", placeholder: "", value: "— salir sin guardar —", selectable: true },
41
- ];
110
+ const CUSTOM_PROVIDER_LABEL = "— otro provider (escribir) —";
111
+ const CUSTOM_MODEL_LABEL = "— otro modelo (escribir) —";
112
+ const SAVE_LABEL = " guardar y salir —";
113
+ const NEW_PROFILE_LABEL = "— nuevo perfil —";
114
+ const LOOSE_LABEL = "— configurar modelos sin perfil —";
42
115
 
43
116
  export function createPickerState(input: {
44
117
  models: Record<string, string>;
118
+ thinking: SlotThinking;
45
119
  groups: Map<string, string[]>;
120
+ profiles?: Record<string, Profile>;
121
+ activeProfile?: string | null;
46
122
  }): PickerState {
47
- const state: PickerState = { cursor: 0, models: { ...input.models }, staged: {}, groups: input.groups };
48
- // Start on a row the user can actually act on, whatever the table's shape.
49
- return { ...state, cursor: nextSelectable(state, 0, 1) };
50
- }
51
-
52
- /** The full display, in order: globals, the seven steps, then the actions. */
53
- export function renderRows(state: PickerState): Row[] {
54
- const assigned = { ...state.models, ...state.staged };
55
- const slots = SLOT_ROWS.map((row): Row => ({
56
- ...row,
57
- value: row.kind === "mechanism" ? row.placeholder : assigned[row.id] ?? row.placeholder,
58
- selectable: row.kind !== "mechanism",
59
- }));
60
- return [...slots, ...ACTION_ROWS];
61
- }
62
-
63
- /** Every `provider/model` pi can resolve, qualified, in registry order. */
64
- export function modelOptions(groups: Map<string, string[]>): string[] {
65
- const out: string[] = [];
66
- for (const [provider, ids] of groups) {
67
- for (const id of ids) out.push(`${provider}/${id}`);
68
- }
123
+ return rebuildEntries({
124
+ screen: "main",
125
+ cursor: 0,
126
+ entries: [],
127
+ edits: {
128
+ models: { ...input.models },
129
+ thinking: { ...input.thinking },
130
+ changed: false,
131
+ profiles: cloneProfiles(input.profiles ?? {}),
132
+ activeProfile: input.activeProfile ?? null,
133
+ // Editing starts on the live config, which is the active profile's content
134
+ // when there is one: so opening the picker and touching a slot edits it.
135
+ editingProfile: null,
136
+ profilesChanged: false,
137
+ },
138
+ groups: input.groups,
139
+ drillSlot: null,
140
+ drillProvider: null,
141
+ drillModel: null,
142
+ drillProfile: null,
143
+ dirtySinceLoad: false,
144
+ textPrompt: null,
145
+ notice: null,
146
+ });
147
+ }
148
+
149
+ /** Deep copy: staged edits must never mutate what the caller read from disk. */
150
+ function cloneProfiles(profiles: Record<string, Profile>): Record<string, Profile> {
151
+ const out: Record<string, Profile> = {};
152
+ for (const [name, profile] of Object.entries(profiles)) out[name] = cloneProfile(profile);
69
153
  return out;
70
154
  }
71
155
 
72
- function nextSelectable(state: PickerState, from: number, step: number): number {
73
- const rows = renderRows(state);
74
- for (let i = 0; i < rows.length; i++) {
75
- const index = (((from + i * step) % rows.length) + rows.length) % rows.length;
76
- if (rows[index].selectable) return index;
156
+ function cloneProfile(profile: Profile): Profile {
157
+ return profile.thinking
158
+ ? { models: { ...profile.models }, thinking: { ...profile.thinking } }
159
+ : { models: { ...profile.models } };
160
+ }
161
+
162
+ /** The maps being edited, as a profile. `thinking` is omitted when empty. */
163
+ function snapshotEdits(edits: StagedEdits): Profile {
164
+ return Object.keys(edits.thinking).length > 0
165
+ ? { models: { ...edits.models }, thinking: { ...edits.thinking } }
166
+ : { models: { ...edits.models } };
167
+ }
168
+
169
+ /**
170
+ * Fold the maps being edited into the profile they belong to.
171
+ *
172
+ * The target is the profile being edited, or the active one when editing the
173
+ * loose config. With neither there is nowhere to fold and the maps stay as the
174
+ * flat config.
175
+ *
176
+ * Only folds when a slot was actually edited since these maps were loaded.
177
+ * Without that guard, merely opening another profile would overwrite the target
178
+ * with maps nobody touched.
179
+ */
180
+ function flushEdits(state: PickerState): PickerState {
181
+ if (!state.dirtySinceLoad) return state;
182
+ const target = state.edits.editingProfile ?? state.edits.activeProfile;
183
+ if (target === null) return state;
184
+ return {
185
+ ...state,
186
+ edits: { ...state.edits, profiles: { ...state.edits.profiles, [target]: snapshotEdits(state.edits) } },
187
+ dirtySinceLoad: false,
188
+ };
189
+ }
190
+
191
+ /** Load a profile into the editing maps, folding the previous one first. */
192
+ function loadProfile(state: PickerState, name: string): PickerState {
193
+ const profile = state.edits.profiles[name];
194
+ if (profile === undefined) return state;
195
+ const flushed = flushEdits(state);
196
+ return {
197
+ ...flushed,
198
+ edits: {
199
+ ...flushed.edits,
200
+ models: { ...profile.models },
201
+ thinking: { ...(profile.thinking ?? {}) },
202
+ editingProfile: name,
203
+ },
204
+ dirtySinceLoad: false,
205
+ };
206
+ }
207
+
208
+ /** The frame title: always say what is being touched. */
209
+ export function pickerTitle(state: PickerState): string {
210
+ if (state.screen === "main") return "nodd · perfiles de modelos";
211
+ if (state.screen === "profile-actions") return `nodd · perfil «${state.drillProfile ?? ""}»`;
212
+
213
+ const editing = state.edits.editingProfile;
214
+ if (editing === null) return "nodd · modelos sin perfil";
215
+ return `nodd · perfil «${editing}»${editing === state.edits.activeProfile ? " · activo" : " · no activo"}`;
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Rows per screen
220
+ // ---------------------------------------------------------------------------
221
+
222
+ /** The providers a profile assigns — a short summary for its menu row. */
223
+ function profileSummary(profile: Profile): string {
224
+ const providers = new Set<string>();
225
+ for (const value of Object.values(profile.models)) {
226
+ const slash = value.indexOf("/");
227
+ if (slash > 0) providers.add(value.slice(0, slash));
228
+ }
229
+ return [...providers].sort().join(", ");
230
+ }
231
+
232
+ function profileRowLabel(state: PickerState, name: string): string {
233
+ const active = name === state.edits.activeProfile;
234
+ const bits = [active ? "activo" : "", profileSummary(state.edits.profiles[name])].filter((s) => s !== "");
235
+ return `${active ? "●" : "○"} ${name}${bits.length > 0 ? ` (${bits.join(" · ")})` : ""}`;
236
+ }
237
+
238
+ function mainEntries(state: PickerState): MenuEntry[] {
239
+ const entries: MenuEntry[] = [];
240
+
241
+ // Profiles first: a profile is what the generated agents actually read.
242
+ for (const name of Object.keys(state.edits.profiles).sort()) {
243
+ entries.push({ kind: "profile", label: profileRowLabel(state, name), value: name });
244
+ }
245
+ entries.push({ kind: "new-profile", label: NEW_PROFILE_LABEL, value: "" });
246
+
247
+ // The loose config is only offered while no profile exists — it is the state of
248
+ // someone who has not migrated, and the row goes when the first one is created.
249
+ if (Object.keys(state.edits.profiles).length === 0) {
250
+ entries.push({ kind: "edit-loose", label: LOOSE_LABEL, value: "loose" });
251
+ }
252
+
253
+ entries.push({ kind: "save", label: SAVE_LABEL, value: "save" });
254
+ return entries;
255
+ }
256
+
257
+ function profileActionEntries(state: PickerState): MenuEntry[] {
258
+ const name = state.drillProfile ?? "";
259
+ const isActive = name === state.edits.activeProfile;
260
+ return [
261
+ isActive
262
+ ? { kind: "profile-active-noop" as const, label: "● ya es el perfil activo", value: name }
263
+ : { kind: "profile-use" as const, label: "activar — los agentes de NODD usan este", value: name },
264
+ { kind: "profile-edit" as const, label: "editar modelos por slot…", value: name },
265
+ { kind: "profile-duplicate" as const, label: "duplicar en un perfil nuevo…", value: name },
266
+ {
267
+ kind: "profile-delete" as const,
268
+ label: isActive ? "borrar (queda sin perfil activo)" : "borrar",
269
+ value: name,
270
+ },
271
+ ];
272
+ }
273
+
274
+ /**
275
+ * The slot screen: the two globals, then the seven canonical steps in protocol
276
+ * order. The four mechanisms are rows like any other — visible, so the mechanized
277
+ * half of ODD stays legible — but their kind makes them inert.
278
+ */
279
+ function slotEntries(state: PickerState): MenuEntry[] {
280
+ const width = Math.max(...SLOT_ROWS.map((row) => row.id.length));
281
+ return SLOT_ROWS.map((row): MenuEntry => {
282
+ if (isMechanismSlot(row.id)) {
283
+ return { kind: "mechanism", label: `${row.id.padEnd(width)} · ${row.placeholder}`, value: row.id };
284
+ }
285
+ const model = state.edits.models[row.id] ?? row.placeholder;
286
+ const level = state.edits.thinking[row.id];
287
+ return {
288
+ kind: "slot",
289
+ label: `${row.id.padEnd(width)} → ${model}${level ? ` · thinking ${level}` : ""}`,
290
+ value: row.id,
291
+ };
292
+ });
293
+ }
294
+
295
+ function providerEntries(state: PickerState): MenuEntry[] {
296
+ const entries: MenuEntry[] = [...state.groups.keys()]
297
+ .sort()
298
+ .map((provider) => ({ kind: "provider" as const, label: provider, value: provider }));
299
+ entries.push({ kind: "custom-provider", label: CUSTOM_PROVIDER_LABEL, value: "" });
300
+ return entries;
301
+ }
302
+
303
+ function modelEntries(state: PickerState): MenuEntry[] {
304
+ const models = state.drillProvider !== null ? (state.groups.get(state.drillProvider) ?? []) : [];
305
+ const entries: MenuEntry[] = models.map((model) => ({ kind: "model" as const, label: model, value: model }));
306
+ entries.push({ kind: "custom-model", label: CUSTOM_MODEL_LABEL, value: "" });
307
+ return entries;
308
+ }
309
+
310
+ function thinkingEntries(): MenuEntry[] {
311
+ return THINKING_LEVELS.map((level) => ({ kind: "thinking-level" as const, label: level, value: level }));
312
+ }
313
+
314
+ /**
315
+ * Recompute `state.entries` for the current screen and drill context.
316
+ *
317
+ * Idempotent. Afterwards the cursor is clamped into range, so a transition that
318
+ * shrinks the row list never leaves the highlight out of bounds, and it is nudged
319
+ * off a mechanism row, which cannot be selected.
320
+ */
321
+ export function rebuildEntries(state: PickerState): PickerState {
322
+ const entries =
323
+ state.screen === "main" ? mainEntries(state)
324
+ : state.screen === "slots" ? slotEntries(state)
325
+ : state.screen === "provider" ? providerEntries(state)
326
+ : state.screen === "model" ? modelEntries(state)
327
+ : state.screen === "thinking" ? thinkingEntries()
328
+ : profileActionEntries(state);
329
+
330
+ const clamped = entries.length === 0 ? 0 : Math.min(Math.max(0, state.cursor), entries.length - 1);
331
+ const next = { ...state, entries, cursor: clamped };
332
+ return entries[clamped]?.kind === "mechanism" ? navigate(next, 1) : next;
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Cursor
337
+ // ---------------------------------------------------------------------------
338
+
339
+ /**
340
+ * Move the highlight by `dir`, wrapping at both ends and skipping the mechanism
341
+ * rows: they are displayed, never selected.
342
+ */
343
+ export function navigate(state: PickerState, dir: -1 | 1): PickerState {
344
+ const n = state.entries.length;
345
+ if (n === 0) return { ...state, cursor: 0 };
346
+
347
+ for (let step = 1; step <= n; step++) {
348
+ const index = (state.cursor + dir * step + n * step) % n;
349
+ if (state.entries[index].kind !== "mechanism") return { ...state, cursor: index };
77
350
  }
78
- return from;
351
+ return state;
79
352
  }
80
353
 
81
- /** Move the cursor, wrapping at both ends and skipping mechanism rows. */
82
- export function moveCursor(state: PickerState, step: number): PickerState {
83
- const rows = renderRows(state);
84
- const start = (((state.cursor + step) % rows.length) + rows.length) % rows.length;
85
- return { ...state, cursor: nextSelectable(state, start, step >= 0 ? 1 : -1) };
354
+ // ---------------------------------------------------------------------------
355
+ // Keys
356
+ // ---------------------------------------------------------------------------
357
+
358
+ export type PickerKey = "up" | "down" | "enter" | "esc" | "backspace";
359
+
360
+ /**
361
+ * One functional key under the kitty keyboard protocol: `CSI <code>`, an optional
362
+ * `;1` (no modifiers), an optional `:1` (press) or `:2` (repeat), then the final
363
+ * byte. A `:3` release or any real modifier does not match — releases must be
364
+ * ignored, and a modified key is not a picker key.
365
+ */
366
+ function kittyPressOrRepeat(code: string, final: string): RegExp {
367
+ return new RegExp(`^\u001b\\[${code}(?:;1(?::[12])?)?${final}$`);
368
+ }
369
+
370
+ const KITTY_UP = kittyPressOrRepeat("1", "A");
371
+ const KITTY_DOWN = kittyPressOrRepeat("1", "B");
372
+ const KITTY_ENTER = kittyPressOrRepeat("13", "u");
373
+ const KITTY_ESC = kittyPressOrRepeat("27", "u");
374
+ const KITTY_BACKSPACE = kittyPressOrRepeat("127", "u");
375
+
376
+ /**
377
+ * Decode one raw stdin sequence into a picker key, or `null` when it is not one.
378
+ *
379
+ * pi-tui negotiates kitty keyboard flags with the terminal, and a terminal that
380
+ * grants them encodes arrows as `CSI 1;1:1 A/B` and Esc as `CSI 27 u` — never the
381
+ * legacy forms. A terminal that does not keeps the legacy or SS3 forms. Both
382
+ * worlds are accepted, and kitty repeats navigate too so holding an arrow scrolls.
383
+ */
384
+ export function decodeKey(data: string): PickerKey | null {
385
+ if (data === "\u001b[A" || data === "\u001bOA" || KITTY_UP.test(data)) return "up";
386
+ if (data === "\u001b[B" || data === "\u001bOB" || KITTY_DOWN.test(data)) return "down";
387
+ if (data === "\r" || data === "\n" || data === "\r\n" || KITTY_ENTER.test(data)) return "enter";
388
+ if (data === "\u001b" || KITTY_ESC.test(data)) return "esc";
389
+ if (data === "\u007f" || data === "\b" || KITTY_BACKSPACE.test(data)) return "backspace";
390
+ return null;
391
+ }
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // Enter
395
+ // ---------------------------------------------------------------------------
396
+
397
+ /** Go to a screen with the cursor at the top and the rows rebuilt. */
398
+ function goto(state: PickerState, screen: Screen, patch: Partial<PickerState> = {}): EnterResult {
399
+ return { type: "state", state: rebuildEntries({ ...state, ...patch, screen, cursor: 0 }) };
86
400
  }
87
401
 
88
402
  export function enter(state: PickerState): EnterResult {
89
- const row = renderRows(state)[state.cursor];
90
- if (row.id === "quit") return { type: "quit" };
91
- if (row.id === "save") return { type: "save", models: { ...state.models, ...state.staged } };
92
- return { type: "choose", slot: row.id, options: modelOptions(state.groups) };
403
+ const entry = state.entries[state.cursor];
404
+ if (!entry) return { type: "state", state };
405
+
406
+ // Any key that advances clears the previous transition's notice.
407
+ const current: PickerState = { ...state, notice: null };
408
+
409
+ switch (entry.kind) {
410
+ case "mechanism":
411
+ // Displayed, never assignable. Saying why beats a dead keypress: the model
412
+ // is not the problem, the slot does not exist.
413
+ return {
414
+ type: "state",
415
+ state: {
416
+ ...current,
417
+ notice: `${entry.value} es un mecanismo: es código determinista y no corre ningún modelo`,
418
+ },
419
+ };
420
+
421
+ case "slot":
422
+ // With no registry there is no provider list worth showing, so the typed
423
+ // escape on the model screen is the way through.
424
+ return goto(current, current.groups.size === 0 ? "model" : "provider", {
425
+ drillSlot: entry.value,
426
+ drillProvider: null,
427
+ });
428
+
429
+ case "provider":
430
+ return goto(current, "model", { drillProvider: entry.value });
431
+
432
+ case "custom-provider":
433
+ return { type: "state", state: { ...current, textPrompt: { for: "provider", label: entry.label } } };
434
+
435
+ case "custom-model":
436
+ return { type: "state", state: { ...current, textPrompt: { for: "model", label: entry.label } } };
437
+
438
+ case "model":
439
+ // Choosing a model does not commit: it stages the model and advances to the
440
+ // level screen, so model, provider and level are written together. An esc
441
+ // before that leaves `edits` untouched.
442
+ return goto(current, "thinking", { drillModel: entry.value });
443
+
444
+ case "thinking-level":
445
+ return goto(commitDrill(current, entry.value as ThinkingLevel), "slots", {
446
+ drillSlot: null,
447
+ drillProvider: null,
448
+ drillModel: null,
449
+ });
450
+
451
+ case "edit-loose":
452
+ return goto(current, "slots", { ...{ edits: { ...current.edits, editingProfile: null } } });
453
+
454
+ case "profile":
455
+ return goto(current, "profile-actions", { drillProfile: entry.value });
456
+
457
+ case "profile-active-noop":
458
+ // Informational row: there is nothing to activate.
459
+ return { type: "state", state: current };
460
+
461
+ case "new-profile":
462
+ return {
463
+ type: "state",
464
+ state: { ...current, textPrompt: { for: "new-profile", label: "nombre del perfil nuevo:" } },
465
+ };
466
+
467
+ case "profile-duplicate":
468
+ return {
469
+ type: "state",
470
+ state: {
471
+ ...current,
472
+ textPrompt: { for: "duplicate-profile", label: `nombre del duplicado de «${entry.value}»:` },
473
+ },
474
+ };
475
+
476
+ case "profile-edit":
477
+ // Load that profile's slots. This does not activate it: an inactive profile
478
+ // is editable.
479
+ return goto(loadProfile(current, entry.value), "slots");
480
+
481
+ case "profile-use": {
482
+ // Activating means that profile becomes the live config. What was being
483
+ // edited is folded in first, so no unsaved change is lost.
484
+ const loaded = loadProfile(current, entry.value);
485
+ return goto(
486
+ {
487
+ ...loaded,
488
+ edits: { ...loaded.edits, activeProfile: entry.value, profilesChanged: true, changed: true },
489
+ },
490
+ "main",
491
+ {
492
+ drillProfile: null,
493
+ notice: `perfil «${entry.value}» activo — guardá y reiniciá pi para que los agentes lo tomen`,
494
+ },
495
+ );
496
+ }
497
+
498
+ case "profile-delete": {
499
+ const profiles = { ...current.edits.profiles };
500
+ delete profiles[entry.value];
501
+ // Deleting does not change the models in use: it only removes the profile
502
+ // the edits would have been folded into.
503
+ return goto(
504
+ {
505
+ ...current,
506
+ edits: {
507
+ ...current.edits,
508
+ profiles,
509
+ profilesChanged: true,
510
+ activeProfile: current.edits.activeProfile === entry.value ? null : current.edits.activeProfile,
511
+ editingProfile: current.edits.editingProfile === entry.value ? null : current.edits.editingProfile,
512
+ },
513
+ },
514
+ "main",
515
+ { drillProfile: null, notice: `perfil «${entry.value}» borrado` },
516
+ );
517
+ }
518
+
519
+ case "save":
520
+ // Fold what is open into its profile before closing, or the slots just
521
+ // edited would never reach it.
522
+ return { type: "save", state: flushEdits(current) };
523
+ }
524
+ }
525
+
526
+ /** The single commit point: slot, provider, model and level, together. */
527
+ function commitDrill(state: PickerState, level: ThinkingLevel): PickerState {
528
+ const { drillSlot, drillProvider, drillModel } = state;
529
+ if (drillSlot === null || drillModel === null) return state;
530
+
531
+ // No provider is a bare model id — which is what the empty-registry path
532
+ // produces, and what the command's validation accepts.
533
+ const qualified = drillProvider ? `${drillProvider}/${drillModel}` : drillModel;
534
+ return {
535
+ ...state,
536
+ edits: {
537
+ ...state.edits,
538
+ models: { ...state.edits.models, [drillSlot]: qualified },
539
+ thinking: { ...state.edits.thinking, [drillSlot]: level },
540
+ changed: true,
541
+ },
542
+ dirtySinceLoad: true,
543
+ };
93
544
  }
94
545
 
95
- /** Stage a choice. Nothing is written until the user picks the save row. */
96
- export function stage(state: PickerState, slot: string, model: string): PickerState {
97
- return { ...state, staged: { ...state.staged, [slot]: model } };
546
+ // ---------------------------------------------------------------------------
547
+ // Esc
548
+ // ---------------------------------------------------------------------------
549
+
550
+ /**
551
+ * Go back one screen. From the main screen it quits, so the host closes the
552
+ * picker without writing anything.
553
+ *
554
+ * One screen at a time, not straight to the menu: the way out of a wrong model is
555
+ * the provider list, and the way out of a wrong provider is the slot list.
556
+ */
557
+ export function back(state: PickerState): EnterResult {
558
+ const current: PickerState = { ...state, notice: null, textPrompt: null };
559
+
560
+ if (current.screen === "main") return { type: "quit" };
561
+
562
+ if (current.screen === "model") {
563
+ // One step back, to the providers. With no registry that screen was skipped,
564
+ // so returning to it would land on a screen the user never saw.
565
+ return current.groups.size === 0
566
+ ? goto(current, "slots", { drillSlot: null, drillProvider: null, drillModel: null })
567
+ : goto(current, "provider", { drillProvider: null, drillModel: null });
568
+ }
569
+ if (current.screen === "thinking") {
570
+ // The level is the last step of one atomic write, so esc aborts the whole
571
+ // commit rather than stepping back: clearing `drillModel` drops the model
572
+ // chosen without a level, and no partial edit is ever written.
573
+ return goto(current, "slots", { drillSlot: null, drillProvider: null, drillModel: null });
574
+ }
575
+ if (current.screen === "provider") {
576
+ return goto(current, "slots", { drillSlot: null, drillProvider: null, drillModel: null });
577
+ }
578
+
579
+ // Leaving the slot screen folds what was edited into its profile.
580
+ return goto(current.screen === "slots" ? flushEdits(current) : current, "main", {
581
+ drillSlot: null,
582
+ drillProvider: null,
583
+ drillModel: null,
584
+ drillProfile: null,
585
+ });
586
+ }
587
+
588
+ // ---------------------------------------------------------------------------
589
+ // Typed values
590
+ // ---------------------------------------------------------------------------
591
+
592
+ /**
593
+ * Commit a value typed into the inline input opened by a `custom-*` row or a
594
+ * profile-name prompt. An empty or whitespace value is a no-op: the prompt closes
595
+ * and the list comes back unchanged, with nothing committed.
596
+ */
597
+ export function submitText(state: PickerState, typed: string): PickerState {
598
+ const prompt = state.textPrompt;
599
+ const value = typed.trim();
600
+ const current: PickerState = { ...state, notice: null };
601
+
602
+ if (prompt === null || value === "") return rebuildEntries({ ...current, textPrompt: null });
603
+
604
+ if (prompt.for === "provider") {
605
+ return rebuildEntries({ ...current, textPrompt: null, screen: "model", cursor: 0, drillProvider: value });
606
+ }
607
+ if (prompt.for === "model") {
608
+ // Like the `model` row: stage it and go to the level screen. The commit is
609
+ // there, never here.
610
+ return rebuildEntries({ ...current, textPrompt: null, screen: "thinking", cursor: 0, drillModel: value });
611
+ }
612
+
613
+ return submitProfileName(current, prompt.for, value);
614
+ }
615
+
616
+ /** Create or duplicate a profile under a typed name. */
617
+ function submitProfileName(
618
+ state: PickerState,
619
+ kind: "new-profile" | "duplicate-profile",
620
+ typed: string,
621
+ ): PickerState {
622
+ const name = typed.toLowerCase();
623
+ const cleared: PickerState = { ...state, textPrompt: null };
624
+
625
+ // An invalid or taken name says so and creates nothing: overwriting a profile
626
+ // by typing its name would be destructive and silent.
627
+ if (!isValidProfileName(name)) {
628
+ return rebuildEntries({ ...cleared, notice: `nombre inválido: «${typed}» (minúsculas, números, - y _)` });
629
+ }
630
+ if (name in cleared.edits.profiles) {
631
+ return rebuildEntries({ ...cleared, notice: `ya existe un perfil «${name}»` });
632
+ }
633
+
634
+ // A duplicate clones the chosen profile; a new one starts from whatever is
635
+ // being edited, which is what the user has in front of them.
636
+ const source =
637
+ kind === "duplicate-profile" && cleared.drillProfile !== null
638
+ ? cleared.edits.profiles[cleared.drillProfile]
639
+ : snapshotEdits(cleared.edits);
640
+
641
+ const created: PickerState = {
642
+ ...cleared,
643
+ edits: {
644
+ ...cleared.edits,
645
+ profiles: { ...cleared.edits.profiles, [name]: cloneProfile(source) },
646
+ profilesChanged: true,
647
+ },
648
+ };
649
+
650
+ if (kind === "duplicate-profile") {
651
+ return rebuildEntries({
652
+ ...created,
653
+ screen: "main",
654
+ cursor: 0,
655
+ drillProfile: null,
656
+ notice: `perfil «${name}» duplicado`,
657
+ });
658
+ }
659
+
660
+ // Creating opens it for editing: choosing the models is what you came to do.
661
+ const loaded = loadProfile(created, name);
662
+ // The first profile activates itself. Otherwise it would sit saved while the
663
+ // agents kept reading the loose config — a profile that does nothing.
664
+ const first = loaded.edits.activeProfile === null;
665
+ return rebuildEntries({
666
+ ...loaded,
667
+ // `loadProfile` already set `editingProfile`, and it stays set: the slots
668
+ // being opened are this profile's, so the title has to say so. Blanking it
669
+ // made the first profile read "sin perfil" while editing it.
670
+ edits: first ? { ...loaded.edits, activeProfile: name } : loaded.edits,
671
+ screen: "slots",
672
+ cursor: 0,
673
+ notice: first
674
+ ? `perfil «${name}» creado y activo — elegí los modelos`
675
+ : `perfil «${name}» creado — elegí los modelos (activar es aparte)`,
676
+ });
98
677
  }