@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.
- package/README.md +59 -0
- package/extensions/nodd-agents.test.ts +10 -0
- package/extensions/nodd-agents.ts +24 -2
- package/extensions/nodd-enforcement.test.ts +2 -1
- package/extensions/nodd-kernel.ts +8 -3
- package/extensions/nodd-models.test.ts +42 -5
- package/extensions/nodd-models.ts +145 -42
- package/extensions/nodd-tools.test.ts +2 -1
- package/package.json +1 -1
- package/src/config.test.ts +11 -0
- package/src/models/layout.test.ts +122 -0
- package/src/models/layout.ts +187 -0
- package/src/models/picker.test.ts +467 -77
- package/src/models/picker.ts +644 -65
- package/src/models/profiles.test.ts +86 -0
- package/src/models/profiles.ts +65 -4
- package/src/models/slots.ts +4 -3
- package/src/models/thinking.test.ts +14 -0
- package/src/models/thinking.ts +22 -0
|
@@ -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 {
|
|
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-
|
|
19
|
+
["anthropic", ["claude-opus-4-1", "claude-sonnet-4-5"]],
|
|
10
20
|
["openai-codex", ["gpt-5-codex"]],
|
|
11
21
|
]);
|
|
12
22
|
|
|
13
|
-
function open(
|
|
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
|
-
//
|
|
51
|
+
// The main menu is profiles, not slots — a profile is what the agents read.
|
|
19
52
|
// ---------------------------------------------------------------------------
|
|
20
|
-
test("
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
assert.
|
|
24
|
-
assert.
|
|
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
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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("
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
assert.
|
|
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("
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
//
|
|
93
|
+
// The slot screen: NODD's slots, with the four mechanisms shown but inert.
|
|
51
94
|
// ---------------------------------------------------------------------------
|
|
52
|
-
test("the
|
|
53
|
-
const
|
|
54
|
-
assert.equal(
|
|
55
|
-
|
|
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("
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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("
|
|
72
|
-
let state = open();
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
//
|
|
139
|
+
// Editing: provider → model → thinking, committed atomically at the level.
|
|
84
140
|
// ---------------------------------------------------------------------------
|
|
85
|
-
test("
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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("
|
|
96
|
-
let state = open(
|
|
97
|
-
state =
|
|
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
|
-
|
|
100
|
-
state =
|
|
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(
|
|
103
|
-
|
|
104
|
-
|
|
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("
|
|
109
|
-
const state = open();
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
assert.
|
|
114
|
-
assert.
|
|
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("
|
|
118
|
-
let state = open();
|
|
119
|
-
state
|
|
120
|
-
|
|
121
|
-
|
|
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
|
+
});
|