@gonrocca/nodd 0.1.2 → 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.
@@ -3,130 +3,513 @@ import assert from "node:assert/strict";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { CANONICAL_STEPS } from "../manifest.ts";
5
5
  import { MECHANISM_PLACEHOLDER } from "./slots.ts";
6
- import { createPickerState, enter, moveCursor, renderRows, type PickerState } from "./picker.ts";
6
+ import {
7
+ back,
8
+ createPickerState,
9
+ decodeKey,
10
+ enter,
11
+ navigate,
12
+ pickerTitle,
13
+ rebuildEntries,
14
+ submitText,
15
+ type PickerState,
16
+ } from "./picker.ts";
7
17
 
8
18
  const groups = new Map([
9
- ["anthropic", ["claude-sonnet-4-5", "claude-opus-4-1"]],
19
+ ["anthropic", ["claude-opus-4-1", "claude-sonnet-4-5"]],
10
20
  ["openai-codex", ["gpt-5-codex"]],
11
21
  ]);
12
22
 
13
- function open(models: Record<string, string> = {}): PickerState {
14
- return createPickerState({ models, groups });
23
+ function open(input: Partial<Parameters<typeof createPickerState>[0]> = {}): PickerState {
24
+ return createPickerState({ models: {}, thinking: {}, groups, ...input });
25
+ }
26
+
27
+ /** Move the cursor onto the row of the given kind, failing loudly if absent. */
28
+ function focus(state: PickerState, kind: string, value?: string): PickerState {
29
+ const index = state.entries.findIndex((e) => e.kind === kind && (value === undefined || e.value === value));
30
+ assert.ok(index >= 0, `no ${kind} row${value ? ` for ${value}` : ""} in: ${state.entries.map((e) => e.kind)}`);
31
+ return { ...state, cursor: index } as PickerState;
32
+ }
33
+
34
+ /** Enter on the focused row, asserting the picker stays open. */
35
+ function press(state: PickerState, kind: string, value?: string): PickerState {
36
+ const result = enter(focus(state, kind, value));
37
+ assert.equal(result.type, "state", `entering ${kind} should keep the picker open`);
38
+ if (result.type !== "state") throw new Error("unreachable");
39
+ return result.state;
40
+ }
41
+
42
+ /** Drill a slot all the way through provider → model → thinking. */
43
+ function assign(state: PickerState, slot: string, provider: string, model: string, level: string): PickerState {
44
+ let next = press(state, "slot", slot);
45
+ next = press(next, "provider", provider);
46
+ next = press(next, "model", model);
47
+ return press(next, "thinking-level", level);
15
48
  }
16
49
 
17
50
  // ---------------------------------------------------------------------------
18
- // Display: seven steps, four of them mechanisms.
51
+ // The main menu is profiles, not slots a profile is what the agents read.
19
52
  // ---------------------------------------------------------------------------
20
- test("all seven canonical steps are displayed in protocol order", () => {
21
- const rows = renderRows(open());
22
- const stepRows = rows.filter((row) => row.kind === "step" || row.kind === "mechanism");
23
- assert.deepEqual(stepRows.map((row) => row.id), [...CANONICAL_STEPS]);
24
- assert.equal(stepRows.length, 7);
53
+ test("the picker opens on the main screen with the cursor on the first row", () => {
54
+ const state = open();
55
+ assert.equal(state.screen, "main");
56
+ assert.equal(state.cursor, 0);
57
+ assert.ok(state.entries.length > 0);
25
58
  });
26
59
 
27
- test("the four mechanism rows say mecanismo · sin modelo and are not selectable", () => {
28
- const mechanisms = renderRows(open()).filter((row) => row.kind === "mechanism");
29
- assert.deepEqual(mechanisms.map((row) => row.id), ["authorize", "classify", "track", "close"]);
30
- for (const row of mechanisms) {
31
- assert.equal(row.value, MECHANISM_PLACEHOLDER);
32
- assert.equal(row.selectable, false, `${row.id} must not be selectable`);
33
- }
60
+ test("the main screen lists one row per profile, sorted, plus new-profile and save", () => {
61
+ const state = open({ profiles: { quick: { models: {} }, careful: { models: {} } } });
62
+ const kinds = state.entries.map((e) => e.kind);
63
+ assert.deepEqual(
64
+ state.entries.filter((e) => e.kind === "profile").map((e) => e.value),
65
+ ["careful", "quick"],
66
+ );
67
+ assert.ok(kinds.includes("new-profile"));
68
+ assert.equal(kinds.at(-1), "save", "save-and-exit is the last row");
34
69
  });
35
70
 
36
- test("an assigned slot displays its model, an unassigned one its placeholder", () => {
37
- const rows = renderRows(open({ implement: "anthropic/claude-opus-4-1" }));
38
- assert.equal(rows.find((row) => row.id === "implement")!.value, "anthropic/claude-opus-4-1");
39
- assert.equal(rows.find((row) => row.id === "explore")!.value, "sin asignar");
71
+ test("the active profile is marked and the others are not", () => {
72
+ const state = open({ profiles: { quick: { models: {} }, careful: { models: {} } }, activeProfile: "quick" });
73
+ const rows = state.entries.filter((e) => e.kind === "profile");
74
+ assert.match(rows.find((e) => e.value === "quick")!.label, /activo/);
75
+ assert.ok(!/activo/.test(rows.find((e) => e.value === "careful")!.label));
40
76
  });
41
77
 
42
- test("there is a save row and a quit row, and they are selectable", () => {
43
- const rows = renderRows(open());
44
- const actions = rows.filter((row) => row.kind === "action");
45
- assert.deepEqual(actions.map((row) => row.id), ["save", "quit"]);
46
- for (const row of actions) assert.equal(row.selectable, true);
78
+ test("a profile row summarises the providers it assigns", () => {
79
+ const state = open({
80
+ profiles: { mixed: { models: { implement: "anthropic/claude-opus-4-1", explore: "openai-codex/gpt-5-codex" } } },
81
+ });
82
+ const label = state.entries.find((e) => e.kind === "profile")!.label;
83
+ assert.match(label, /anthropic/);
84
+ assert.match(label, /openai-codex/);
85
+ });
86
+
87
+ test("with no profiles the menu offers editing the loose config; the row goes once one exists", () => {
88
+ assert.ok(open().entries.some((e) => e.kind === "edit-loose"));
89
+ assert.ok(!open({ profiles: { quick: { models: {} } } }).entries.some((e) => e.kind === "edit-loose"));
47
90
  });
48
91
 
49
92
  // ---------------------------------------------------------------------------
50
- // Navigation skips the non-selectable rows.
93
+ // The slot screen: NODD's slots, with the four mechanisms shown but inert.
51
94
  // ---------------------------------------------------------------------------
52
- test("the cursor starts on the first selectable row", () => {
53
- const rows = renderRows(open());
54
- assert.equal(rows[open().cursor].selectable, true);
55
- assert.equal(rows[open().cursor].id, "default");
95
+ test("the slot screen shows the two globals and all seven canonical steps in order", () => {
96
+ const slots = press(open(), "edit-loose");
97
+ assert.equal(slots.screen, "slots");
98
+ const shown = slots.entries.filter((e) => e.kind === "slot" || e.kind === "mechanism");
99
+ assert.deepEqual(shown.map((e) => e.value), ["default", "orchestrator", ...CANONICAL_STEPS]);
56
100
  });
57
101
 
58
- test("navigation skips mechanism rows entirely", () => {
59
- let state = open();
60
- const visited: string[] = [];
61
- for (let i = 0; i < 12; i++) {
62
- visited.push(renderRows(state)[state.cursor].id);
63
- state = moveCursor(state, 1);
102
+ test("the four mechanism rows say mecanismo · sin modelo and cannot be entered", () => {
103
+ const slots = press(open(), "edit-loose");
104
+ const mechanisms = slots.entries.filter((e) => e.kind === "mechanism");
105
+ assert.deepEqual(mechanisms.map((e) => e.value), ["authorize", "classify", "track", "close"]);
106
+ for (const row of mechanisms) {
107
+ assert.ok(row.label.includes(MECHANISM_PLACEHOLDER), `${row.value}: ${row.label}`);
64
108
  }
65
- for (const mechanism of ["authorize", "classify", "track", "close"]) {
66
- assert.ok(!visited.includes(mechanism), `the cursor must never land on ${mechanism}`);
109
+
110
+ // Entering one must not open a provider list: the slot does not exist.
111
+ for (const mechanism of mechanisms) {
112
+ const after = press(slots, "mechanism", mechanism.value);
113
+ assert.equal(after.screen, "slots", `${mechanism.value} must not drill anywhere`);
114
+ assert.equal(after.drillSlot, null);
115
+ assert.match(after.notice ?? "", /mecanismo/, "the refusal must say why, not just do nothing");
67
116
  }
68
- assert.deepEqual([...new Set(visited)], ["default", "orchestrator", "explore", "resolve-uncertainty", "implement", "save", "quit"]);
69
117
  });
70
118
 
71
- test("the cursor wraps at both ends and never leaves a selectable row", () => {
72
- let state = open();
73
- const up = moveCursor(state, -1);
74
- assert.equal(renderRows(up)[up.cursor].id, "quit", "moving up from the top wraps to the last row");
75
-
76
- for (let i = 0; i < 30; i++) {
77
- state = moveCursor(state, i % 2 === 0 ? 1 : -1);
78
- assert.equal(renderRows(state)[state.cursor].selectable, true);
119
+ test("navigation never lands the cursor on a mechanism row", () => {
120
+ let state = press(open(), "edit-loose");
121
+ const visited: string[] = [];
122
+ for (let i = 0; i < state.entries.length * 2; i++) {
123
+ visited.push(state.entries[state.cursor].kind);
124
+ state = navigate(state, 1);
79
125
  }
126
+ assert.ok(!visited.includes("mechanism"), "the cursor must skip every mechanism row");
127
+ assert.ok(visited.includes("slot"), "the scan must actually have walked the slot rows");
128
+ });
129
+
130
+ test("a slot row shows its model and thinking level, or a placeholder", () => {
131
+ const slots = press(open({ models: { implement: "anthropic/claude-opus-4-1" }, thinking: { implement: "high" } }), "edit-loose");
132
+ const implement = slots.entries.find((e) => e.value === "implement")!;
133
+ assert.match(implement.label, /anthropic\/claude-opus-4-1/);
134
+ assert.match(implement.label, /high/);
135
+ assert.match(slots.entries.find((e) => e.value === "explore")!.label, /sin asignar/);
80
136
  });
81
137
 
82
138
  // ---------------------------------------------------------------------------
83
- // Outcomes: quit writes nothing, save returns the assignment set.
139
+ // Editing: provider model → thinking, committed atomically at the level.
84
140
  // ---------------------------------------------------------------------------
85
- test("quit returns no changes", () => {
86
- let state = open({ implement: "anthropic/claude-opus-4-1" });
87
- state = { ...state, staged: { ...state.staged, explore: "openai-codex/gpt-5-codex" } };
141
+ test("entering a slot opens the providers of the live registry, sorted, plus a custom row", () => {
142
+ const providers = press(press(open(), "edit-loose"), "slot", "implement");
143
+ assert.equal(providers.screen, "provider");
144
+ assert.equal(providers.drillSlot, "implement");
145
+ assert.deepEqual(
146
+ providers.entries.filter((e) => e.kind === "provider").map((e) => e.value),
147
+ ["anthropic", "openai-codex"],
148
+ );
149
+ assert.ok(providers.entries.some((e) => e.kind === "custom-provider"));
150
+ });
88
151
 
89
- const rows = renderRows(state);
90
- state = { ...state, cursor: rows.findIndex((row) => row.id === "quit") };
152
+ test("entering a provider opens that provider's models plus a custom row", () => {
153
+ const models = press(press(press(open(), "edit-loose"), "slot", "implement"), "provider", "anthropic");
154
+ assert.equal(models.screen, "model");
155
+ assert.equal(models.drillProvider, "anthropic");
156
+ assert.deepEqual(
157
+ models.entries.filter((e) => e.kind === "model").map((e) => e.value),
158
+ ["claude-opus-4-1", "claude-sonnet-4-5"],
159
+ );
160
+ assert.ok(models.entries.some((e) => e.kind === "custom-model"));
161
+ });
91
162
 
92
- assert.deepEqual(enter(state), { type: "quit" });
163
+ test("with no registry the provider screen is skipped: there is nothing to list", () => {
164
+ const state = press(press(open({ groups: new Map() }), "edit-loose"), "slot", "implement");
165
+ assert.equal(state.screen, "model", "an empty provider list must not be shown as a screen");
166
+ assert.equal(state.drillProvider, null);
167
+ assert.ok(state.entries.some((e) => e.kind === "custom-model"), "the typed escape is the only way through");
168
+
169
+ // A bare model id with no provider is what the registry-free path produces,
170
+ // and it is exactly what `validateAssignment` accepts. The typed row has to be
171
+ // entered first: `submitText` only commits against an open prompt.
172
+ const typed = submitText(press(state, "custom-model"), "some-model");
173
+ const committed = press(press(typed, "thinking-level", "medium"), "slot", "implement");
174
+ assert.equal(committed.edits.models.implement, "some-model");
175
+ });
176
+
177
+ test("choosing a model stages nothing yet: it advances to the thinking screen", () => {
178
+ let state = press(press(open(), "edit-loose"), "slot", "implement");
179
+ state = press(state, "provider", "anthropic");
180
+ state = press(state, "model", "claude-opus-4-1");
181
+
182
+ assert.equal(state.screen, "thinking");
183
+ assert.equal(state.drillModel, "claude-opus-4-1");
184
+ assert.deepEqual(state.edits.models, {}, "nothing is committed before a level is chosen");
185
+ assert.equal(state.edits.changed, false);
93
186
  });
94
187
 
95
- test("save returns the staged assignment set", () => {
96
- let state = open({ implement: "anthropic/claude-opus-4-1" });
97
- state = { ...state, staged: { ...state.staged, explore: "openai-codex/gpt-5-codex" } };
188
+ test("the thinking screen offers exactly the six real levels", () => {
189
+ let state = press(press(open(), "edit-loose"), "slot", "implement");
190
+ state = press(press(state, "provider", "anthropic"), "model", "claude-opus-4-1");
191
+ assert.deepEqual(state.entries.map((e) => e.value), ["off", "minimal", "low", "medium", "high", "xhigh"]);
192
+ });
98
193
 
99
- const rows = renderRows(state);
100
- state = { ...state, cursor: rows.findIndex((row) => row.id === "save") };
194
+ test("choosing a level commits provider, model and level together, and returns to the slots", () => {
195
+ const state = assign(press(open(), "edit-loose"), "implement", "anthropic", "claude-opus-4-1", "xhigh");
101
196
 
102
- assert.deepEqual(enter(state), {
103
- type: "save",
104
- models: { implement: "anthropic/claude-opus-4-1", explore: "openai-codex/gpt-5-codex" },
197
+ assert.deepEqual(state.edits.models, { implement: "anthropic/claude-opus-4-1" });
198
+ assert.deepEqual(state.edits.thinking, { implement: "xhigh" });
199
+ assert.equal(state.edits.changed, true);
200
+ assert.equal(state.screen, "slots");
201
+ assert.equal(state.drillSlot, null, "the drill context is cleared after the commit");
202
+ assert.equal(state.drillModel, null);
203
+ });
204
+
205
+ test("escaping the thinking screen commits no partial edit", () => {
206
+ let state = press(press(open(), "edit-loose"), "slot", "implement");
207
+ state = press(press(state, "provider", "anthropic"), "model", "claude-opus-4-1");
208
+
209
+ const result = back(state);
210
+ assert.equal(result.type, "state");
211
+ if (result.type !== "state") return;
212
+ assert.deepEqual(result.state.edits.models, {}, "a model with no level must not be written");
213
+ assert.deepEqual(result.state.edits.thinking, {});
214
+ assert.equal(result.state.drillModel, null);
215
+ assert.equal(result.state.screen, "slots");
216
+ });
217
+
218
+ test("a typed provider and a typed model reach the same commit path", () => {
219
+ let state = press(press(open(), "edit-loose"), "slot", "implement");
220
+ state = press(state, "custom-provider");
221
+ assert.equal(state.textPrompt?.for, "provider");
222
+
223
+ state = submitText(state, " my-provider ");
224
+ assert.equal(state.screen, "model", "a typed provider advances to the model list");
225
+ assert.equal(state.drillProvider, "my-provider", "the typed value is trimmed");
226
+ assert.equal(state.textPrompt, null);
227
+
228
+ state = press(state, "custom-model");
229
+ state = submitText(state, "my-model");
230
+ assert.equal(state.screen, "thinking", "a typed model still goes through the level screen");
231
+ state = press(state, "thinking-level", "low");
232
+ assert.deepEqual(state.edits.models, { implement: "my-provider/my-model" });
233
+ assert.deepEqual(state.edits.thinking, { implement: "low" });
234
+ });
235
+
236
+ test("an empty typed value commits nothing and just closes the prompt", () => {
237
+ let state = press(press(open(), "edit-loose"), "slot", "implement");
238
+ state = press(state, "custom-provider");
239
+ const after = submitText(state, " ");
240
+ assert.equal(after.textPrompt, null);
241
+ assert.equal(after.screen, "provider", "the list comes back unchanged");
242
+ assert.equal(after.drillProvider, null);
243
+ });
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Profiles: create, edit, activate, duplicate, delete — all staged.
247
+ // ---------------------------------------------------------------------------
248
+ test("opening a profile offers activate, edit, duplicate and delete", () => {
249
+ const state = press(open({ profiles: { quick: { models: {} } } }), "profile", "quick");
250
+ assert.equal(state.screen, "profile-actions");
251
+ assert.equal(state.drillProfile, "quick");
252
+ assert.deepEqual(state.entries.map((e) => e.kind), [
253
+ "profile-use",
254
+ "profile-edit",
255
+ "profile-duplicate",
256
+ "profile-delete",
257
+ ]);
258
+ });
259
+
260
+ test("the already-active profile says so instead of offering activation", () => {
261
+ const state = press(open({ profiles: { quick: { models: {} } }, activeProfile: "quick" }), "profile", "quick");
262
+ assert.deepEqual(state.entries.map((e) => e.kind), [
263
+ "profile-active-noop",
264
+ "profile-edit",
265
+ "profile-duplicate",
266
+ "profile-delete",
267
+ ]);
268
+ // The informational row does nothing at all.
269
+ const after = press(state, "profile-active-noop");
270
+ assert.equal(after.screen, "profile-actions");
271
+ });
272
+
273
+ test("editing a profile loads its slots without activating it", () => {
274
+ let state = open({
275
+ profiles: { quick: { models: { implement: "openai-codex/gpt-5-codex" }, thinking: { implement: "low" } } },
276
+ activeProfile: null,
105
277
  });
278
+ state = press(press(state, "profile", "quick"), "profile-edit");
279
+
280
+ assert.equal(state.screen, "slots");
281
+ assert.equal(state.edits.editingProfile, "quick");
282
+ assert.equal(state.edits.activeProfile, null, "editing is not activating");
283
+ assert.deepEqual(state.edits.models, { implement: "openai-codex/gpt-5-codex" });
284
+ assert.deepEqual(state.edits.thinking, { implement: "low" });
106
285
  });
107
286
 
108
- test("entering a slot row opens that slot's model list", () => {
109
- const state = open();
110
- const result = enter(state);
111
- assert.equal(result.type, "choose");
112
- if (result.type !== "choose") return;
113
- assert.equal(result.slot, "default");
114
- assert.deepEqual(result.options, ["anthropic/claude-sonnet-4-5", "anthropic/claude-opus-4-1", "openai-codex/gpt-5-codex"]);
287
+ test("the title names what is being edited and whether it is active", () => {
288
+ const state = open({ profiles: { quick: { models: {} }, slow: { models: {} } }, activeProfile: "quick" });
289
+ assert.match(pickerTitle(state), /nodd/);
290
+
291
+ const editingActive = press(press(state, "profile", "quick"), "profile-edit");
292
+ assert.match(pickerTitle(editingActive), /quick/);
293
+ assert.match(pickerTitle(editingActive), /activo/);
294
+
295
+ const editingOther = press(press(state, "profile", "slow"), "profile-edit");
296
+ assert.match(pickerTitle(editingOther), /slow/);
297
+ assert.match(pickerTitle(editingOther), /no activo/);
298
+
299
+ assert.match(pickerTitle(press(open(), "edit-loose")), /sin perfil/);
300
+ });
301
+
302
+ test("creating the first profile names it in the title, not 'sin perfil'", () => {
303
+ // The first profile activates itself, and the slots opened right after are
304
+ // that profile's. A title reading "sin perfil" while editing «rapido» tells
305
+ // the user their choices are going somewhere other than where they are going.
306
+ const created = submitText(press(open(), "new-profile"), "rapido");
307
+
308
+ assert.equal(created.screen, "slots");
309
+ assert.match(pickerTitle(created), /rapido/);
310
+ assert.ok(!/sin perfil/.test(pickerTitle(created)), "the slots being edited belong to the new profile");
311
+ });
312
+
313
+ test("an edit lands in the profile being edited, not in the active one", () => {
314
+ let state = open({
315
+ profiles: { quick: { models: { implement: "openai-codex/gpt-5-codex" } }, slow: { models: {} } },
316
+ activeProfile: "quick",
317
+ });
318
+ state = press(press(state, "profile", "slow"), "profile-edit");
319
+ state = assign(state, "implement", "anthropic", "claude-opus-4-1", "high");
320
+
321
+ const result = back(state);
322
+ assert.equal(result.type, "state");
323
+ if (result.type !== "state") return;
324
+ assert.deepEqual(result.state.edits.profiles.slow, {
325
+ models: { implement: "anthropic/claude-opus-4-1" },
326
+ thinking: { implement: "high" },
327
+ });
328
+ assert.deepEqual(
329
+ result.state.edits.profiles.quick,
330
+ { models: { implement: "openai-codex/gpt-5-codex" } },
331
+ "the active profile must not be touched by editing another",
332
+ );
333
+ });
334
+
335
+ test("leaving the slot screen folds the edits into the profile they belong to", () => {
336
+ let state = open({ profiles: { quick: { models: {} } }, activeProfile: "quick" });
337
+ state = press(press(state, "profile", "quick"), "profile-edit");
338
+ state = assign(state, "explore", "anthropic", "claude-sonnet-4-5", "medium");
339
+ assert.deepEqual(state.edits.profiles.quick, { models: {} }, "not folded in until the screen is left");
340
+
341
+ const result = back(state);
342
+ assert.equal(result.type, "state");
343
+ if (result.type !== "state") return;
344
+ assert.deepEqual(result.state.edits.profiles.quick.models, { explore: "anthropic/claude-sonnet-4-5" });
345
+ });
346
+
347
+ test("activating a profile switches the live models and says a restart is needed", () => {
348
+ let state = open({
349
+ profiles: { quick: { models: { implement: "openai-codex/gpt-5-codex" } }, slow: { models: {} } },
350
+ activeProfile: "slow",
351
+ });
352
+ state = press(press(state, "profile", "quick"), "profile-use");
353
+
354
+ assert.equal(state.edits.activeProfile, "quick");
355
+ assert.deepEqual(state.edits.models, { implement: "openai-codex/gpt-5-codex" });
356
+ assert.equal(state.screen, "main");
357
+ assert.match(state.notice ?? "", /quick/);
115
358
  });
116
359
 
117
- test("staging a choice does not write and is visible in the rows", () => {
118
- let state = open();
119
- state = { ...state, staged: { ...state.staged, default: "anthropic/claude-opus-4-1" } };
120
- assert.equal(renderRows(state).find((row) => row.id === "default")!.value, "anthropic/claude-opus-4-1");
121
- assert.deepEqual(open().staged, {}, "the initial state stages nothing");
360
+ test("creating a profile asks for a name, then opens its slots", () => {
361
+ let state = press(open(), "new-profile");
362
+ assert.equal(state.textPrompt?.for, "new-profile");
363
+
364
+ state = submitText(state, "fresh");
365
+ assert.equal(state.screen, "slots");
366
+ assert.ok("fresh" in state.edits.profiles);
367
+ assert.equal(state.edits.activeProfile, "fresh", "the very first profile is activated on creation");
368
+ });
369
+
370
+ test("creating a second profile does not steal the activation", () => {
371
+ let state = open({ profiles: { quick: { models: {} } }, activeProfile: "quick" });
372
+ state = submitText(press(state, "new-profile"), "second");
373
+ assert.equal(state.edits.activeProfile, "quick");
374
+ assert.equal(state.edits.editingProfile, "second");
375
+ });
376
+
377
+ test("an invalid or duplicate name creates nothing and says why", () => {
378
+ for (const bad of ["Bad Name", "use", ""]) {
379
+ const state = submitText(press(open(), "new-profile"), bad);
380
+ assert.deepEqual(state.edits.profiles, {}, `${bad} must create nothing`);
381
+ if (bad !== "") assert.match(state.notice ?? "", /inválido|invalido/i, `${bad} must be explained`);
382
+ }
383
+
384
+ const dup = submitText(press(open({ profiles: { quick: { models: {} } } }), "new-profile"), "quick");
385
+ assert.match(dup.notice ?? "", /quick/);
386
+ assert.deepEqual(Object.keys(dup.edits.profiles), ["quick"], "an existing profile is never overwritten by a name");
387
+ });
388
+
389
+ test("duplicating clones the chosen profile under a new name", () => {
390
+ let state = open({ profiles: { quick: { models: { implement: "anthropic/claude-opus-4-1" } } } });
391
+ state = press(press(state, "profile", "quick"), "profile-duplicate");
392
+ assert.equal(state.textPrompt?.for, "duplicate-profile");
393
+
394
+ state = submitText(state, "copy");
395
+ assert.deepEqual(state.edits.profiles.copy, { models: { implement: "anthropic/claude-opus-4-1" } });
396
+ assert.equal(state.screen, "main");
397
+ });
398
+
399
+ test("deleting a profile drops it and clears the activation it held", () => {
400
+ let state = open({ profiles: { quick: { models: {} }, slow: { models: {} } }, activeProfile: "quick" });
401
+ state = press(press(state, "profile", "quick"), "profile-delete");
402
+
403
+ assert.deepEqual(Object.keys(state.edits.profiles), ["slow"]);
404
+ assert.equal(state.edits.activeProfile, null);
405
+ assert.equal(state.screen, "main");
406
+ assert.match(state.notice ?? "", /quick/);
407
+ });
408
+
409
+ // ---------------------------------------------------------------------------
410
+ // Going back, and leaving without writing.
411
+ // ---------------------------------------------------------------------------
412
+ test("esc walks back one screen at a time: model → provider is not skipped", () => {
413
+ let state = press(press(open(), "edit-loose"), "slot", "implement");
414
+ state = press(state, "provider", "anthropic");
415
+
416
+ const toProvider = back(state);
417
+ assert.equal(toProvider.type, "state");
418
+ if (toProvider.type !== "state") return;
419
+ assert.equal(toProvider.state.screen, "provider", "esc from the models returns to the providers");
420
+ assert.equal(toProvider.state.drillProvider, null);
421
+
422
+ const toSlots = back(toProvider.state);
423
+ assert.equal(toSlots.type === "state" && toSlots.state.screen, "slots");
424
+ });
425
+
426
+ test("esc from the slot screen returns to the menu, and from the menu it quits", () => {
427
+ const slots = press(open(), "edit-loose");
428
+ const toMain = back(slots);
429
+ assert.equal(toMain.type === "state" && toMain.state.screen, "main");
430
+
431
+ assert.deepEqual(back(toMain.type === "state" ? toMain.state : slots), { type: "quit" });
432
+ });
433
+
434
+ test("quit is the only way out that writes nothing, and save carries the edits", () => {
435
+ let state = assign(press(open(), "edit-loose"), "implement", "anthropic", "claude-opus-4-1", "high");
436
+ const toMain = back(state);
437
+ assert.equal(toMain.type, "state");
438
+ if (toMain.type !== "state") return;
439
+
440
+ assert.deepEqual(back(toMain.state), { type: "quit" }, "quit returns no edits at all");
441
+
442
+ const saved = enter(focus(toMain.state, "save"));
443
+ assert.equal(saved.type, "save");
444
+ if (saved.type !== "save") return;
445
+ assert.deepEqual(saved.state.edits.models, { implement: "anthropic/claude-opus-4-1" });
446
+ assert.deepEqual(saved.state.edits.thinking, { implement: "high" });
447
+ });
448
+
449
+ test("saving folds the open edits into their profile first", () => {
450
+ let state = open({ profiles: { quick: { models: {} } }, activeProfile: "quick" });
451
+ state = press(press(state, "profile", "quick"), "profile-edit");
452
+ state = assign(state, "implement", "anthropic", "claude-opus-4-1", "high");
453
+
454
+ // Save lives on the main menu, so this leaves the slot screen first — which is
455
+ // the point: the edits have to survive the trip and land in their profile.
456
+ const menu = back(state);
457
+ assert.equal(menu.type, "state");
458
+ if (menu.type !== "state") return;
459
+
460
+ const saved = enter(focus(menu.state, "save"));
461
+ assert.equal(saved.type, "save");
462
+ if (saved.type !== "save") return;
463
+ assert.deepEqual(saved.state.edits.profiles.quick.models, { implement: "anthropic/claude-opus-4-1" });
464
+ });
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // Rows, cursors and keys.
468
+ // ---------------------------------------------------------------------------
469
+ test("rebuildEntries is idempotent and clamps a cursor past the last row", () => {
470
+ const state = open({ profiles: { quick: { models: {} } } });
471
+ assert.deepEqual(rebuildEntries({ ...state }).entries, state.entries);
472
+
473
+ const clamped = rebuildEntries({ ...state, cursor: 99 });
474
+ assert.equal(clamped.cursor, clamped.entries.length - 1);
475
+ assert.equal(rebuildEntries({ ...state, cursor: -5 }).cursor, 0);
476
+ });
477
+
478
+ test("the cursor wraps at both ends", () => {
479
+ const state = open({ profiles: { quick: { models: {} } } });
480
+ assert.equal(navigate({ ...state, cursor: 0 }, -1).cursor, state.entries.length - 1);
481
+ assert.equal(navigate({ ...state, cursor: state.entries.length - 1 }, 1).cursor, 0);
482
+ assert.equal(navigate({ ...state, cursor: 0 }, 1).cursor, 1);
483
+ });
484
+
485
+ test("decodeKey accepts the legacy, SS3 and kitty forms of every picker key", () => {
486
+ assert.equal(decodeKey("\u001b[A"), "up");
487
+ assert.equal(decodeKey("\u001bOA"), "up");
488
+ assert.equal(decodeKey("\u001b[1;1:1A"), "up", "kitty press");
489
+ assert.equal(decodeKey("\u001b[1;1:2A"), "up", "kitty repeat: holding the key scrolls");
490
+ assert.equal(decodeKey("\u001b[B"), "down");
491
+ assert.equal(decodeKey("\u001b[1;1:1B"), "down");
492
+ assert.equal(decodeKey("\r"), "enter");
493
+ assert.equal(decodeKey("\n"), "enter");
494
+ assert.equal(decodeKey("\u001b[13u"), "enter");
495
+ assert.equal(decodeKey("\u001b"), "esc");
496
+ assert.equal(decodeKey("\u001b[27u"), "esc");
497
+ assert.equal(decodeKey("\u007f"), "backspace");
498
+ assert.equal(decodeKey("\u001b[127u"), "backspace");
499
+ });
500
+
501
+ test("decodeKey ignores releases, modified keys and printable text", () => {
502
+ assert.equal(decodeKey("\u001b[1;1:3A"), null, "a key release must not navigate");
503
+ assert.equal(decodeKey("\u001b[1;5A"), null, "ctrl+up is not a picker key");
504
+ assert.equal(decodeKey("q"), null);
505
+ assert.equal(decodeKey("A"), null);
506
+ assert.equal(decodeKey(""), null);
122
507
  });
123
508
 
124
509
  // ---------------------------------------------------------------------------
125
510
  // The module boundary is the point: no fs, no pi, no TUI.
126
511
  // ---------------------------------------------------------------------------
127
512
  test("the picker imports no node:fs, no pi and no TUI package", () => {
128
- // Comments are free to name what the module refuses to import; executable
129
- // lines are not.
130
513
  const source = readFileSync(new URL("./picker.ts", import.meta.url), "utf8")
131
514
  .split("\n")
132
515
  .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
@@ -139,3 +522,10 @@ test("the picker imports no node:fs, no pi and no TUI package", () => {
139
522
  assert.ok(specifier.startsWith("."), `unexpected external import: ${specifier}`);
140
523
  }
141
524
  });
525
+
526
+ test("no transition writes: every one returns state the caller may discard", () => {
527
+ // The guarantee behind "quit writes nothing" is structural, not a promise about
528
+ // a code path: the module cannot write, so only the command can.
529
+ const source = readFileSync(new URL("./picker.ts", import.meta.url), "utf8");
530
+ assert.ok(!/writeFileSync|readFileSync/.test(source), "the state machine performs no IO");
531
+ });