@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.
- 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
package/src/models/picker.ts
CHANGED
|
@@ -1,98 +1,677 @@
|
|
|
1
1
|
// The picker's decisions, as a pure state machine.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// no pi, and above all no TUI package
|
|
5
|
-
// `extensions/nodd-models.ts` owns rendering and keystrokes and
|
|
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
|
-
//
|
|
10
|
-
// refactor:
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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,
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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: "
|
|
35
|
-
| { type: "save";
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
351
|
+
return state;
|
|
79
352
|
}
|
|
80
353
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
}
|