@gonrocca/nodd 0.3.1 → 0.4.1
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-models.test.ts +36 -0
- package/extensions/nodd-models.ts +36 -12
- package/package.json +1 -1
- package/src/models/assign.test.ts +46 -0
- package/src/models/assign.ts +52 -5
- package/src/models/layout.test.ts +45 -0
- package/src/models/layout.ts +69 -0
- package/src/models/picker.test.ts +56 -0
- package/src/models/picker.ts +73 -3
|
@@ -279,3 +279,39 @@ test("nothing in this extension names forge's config file", () => {
|
|
|
279
279
|
assert.ok(!source.includes(`zero${"."}json`), "NODD never opens forge's config");
|
|
280
280
|
assert.ok(source.includes("nodd.json") || source.includes("noddConfigPath"), "it uses its own");
|
|
281
281
|
});
|
|
282
|
+
|
|
283
|
+
test("the picker offers the providers you can actually use, not the whole catalogue", () => {
|
|
284
|
+
// pi's registry carries every provider it knows how to speak to — bedrock,
|
|
285
|
+
// baseten, huggingface, cloudflare — whether or not you have credentials for
|
|
286
|
+
// any of them. Browsing all of it meant scrolling past dozens of providers
|
|
287
|
+
// that cannot answer. `getAvailable()` is pi's own answer to "which of these
|
|
288
|
+
// has configured auth", so that is what the picker lists.
|
|
289
|
+
const registry = {
|
|
290
|
+
getAll: () => [
|
|
291
|
+
{ provider: "cliproxy", id: "ds/deepseek-flash" },
|
|
292
|
+
{ provider: "amazon-bedrock", id: "claude-3" },
|
|
293
|
+
{ provider: "huggingface", id: "meta-llama/x" },
|
|
294
|
+
],
|
|
295
|
+
getAvailable: () => [{ provider: "cliproxy", id: "ds/deepseek-flash" }],
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const groups = register.groupsFrom(registry);
|
|
299
|
+
assert.deepEqual([...groups.keys()], ["cliproxy/ds"], "only the configured provider is browsable");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("with no availability information the full registry is still offered", () => {
|
|
303
|
+
// An older host, or one that cannot answer, must not leave the picker empty:
|
|
304
|
+
// showing everything beats showing nothing.
|
|
305
|
+
const groups = register.groupsFrom({ getAll: () => [{ provider: "anthropic", id: "claude-opus-4-1" }] });
|
|
306
|
+
assert.deepEqual([...groups.keys()], ["anthropic"]);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("an empty availability list falls back rather than offering nothing", () => {
|
|
310
|
+
// Availability is refreshed asynchronously, so early in a session it can be
|
|
311
|
+
// legitimately empty. Trusting it blindly would show an empty provider list.
|
|
312
|
+
const groups = register.groupsFrom({
|
|
313
|
+
getAll: () => [{ provider: "anthropic", id: "claude-opus-4-1" }],
|
|
314
|
+
getAvailable: () => [],
|
|
315
|
+
});
|
|
316
|
+
assert.deepEqual([...groups.keys()], ["anthropic"], "an empty answer is not an answer");
|
|
317
|
+
});
|
|
@@ -24,8 +24,8 @@ import { mergeConfig, noddConfigPath } from "../src/config.ts";
|
|
|
24
24
|
import { SLOT_ROWS } from "../src/models/slots.ts";
|
|
25
25
|
import { assignmentPatch, groupByProvider, parseAssignment, validateAssignment, type RegistryModel } from "../src/models/assign.ts";
|
|
26
26
|
import { applyProfileCommand, isValidProfileName, mirrorToActiveProfile, readActiveProfile, readProfiles, type Profile } from "../src/models/profiles.ts";
|
|
27
|
-
import { back, createPickerState, decodeKey, enter, navigate, pickerTitle, submitText, type EnterResult, type PickerState } from "../src/models/picker.ts";
|
|
28
|
-
import { fitRows, truncateToWidth, usableRows, windowRows } from "../src/models/layout.ts";
|
|
27
|
+
import { back, createPickerState, decodeKey, enter, navigate, pickerTitle, previewRows, submitText, type EnterResult, type PickerState } from "../src/models/picker.ts";
|
|
28
|
+
import { fitRows, sideBySide, truncateToWidth, usableRows, windowRows } from "../src/models/layout.ts";
|
|
29
29
|
import { isThinkingLevel, type SlotThinking } from "../src/models/thinking.ts";
|
|
30
30
|
|
|
31
31
|
export type ConfigIo = {
|
|
@@ -48,13 +48,31 @@ export function fileConfigIo(path: string = noddConfigPath()): ConfigIo {
|
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
type ModelRegistry = {
|
|
51
|
+
type ModelRegistry = {
|
|
52
|
+
getAll(): RegistryModel[];
|
|
53
|
+
/** pi's own answer to "which providers have configured auth". */
|
|
54
|
+
getAvailable?(): RegistryModel[];
|
|
55
|
+
};
|
|
52
56
|
|
|
53
57
|
const USAGE =
|
|
54
58
|
"uso: /nodd-models · /nodd-models <slot>=<provider>/<modelo> · /nodd-models profile [list|new <n>|save [n]|use <n>|delete <n>]";
|
|
55
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The models worth browsing.
|
|
62
|
+
*
|
|
63
|
+
* pi's registry lists every provider it can speak to — bedrock, baseten,
|
|
64
|
+
* huggingface — whether or not credentials exist for any of them, which buried
|
|
65
|
+
* the few usable ones under dozens that cannot answer. `getAvailable()` narrows
|
|
66
|
+
* that to the configured providers.
|
|
67
|
+
*
|
|
68
|
+
* An empty or missing answer falls back to the full registry: availability is
|
|
69
|
+
* refreshed asynchronously, so early in a session it can be legitimately empty,
|
|
70
|
+
* and an empty picker is worse than a long one.
|
|
71
|
+
*/
|
|
56
72
|
function groupsFrom(registry: ModelRegistry | undefined): Map<string, string[]> {
|
|
57
73
|
try {
|
|
74
|
+
const available = registry?.getAvailable?.();
|
|
75
|
+
if (available && available.length > 0) return groupByProvider(available);
|
|
58
76
|
const all = registry?.getAll?.();
|
|
59
77
|
return all && all.length > 0 ? groupByProvider(all) : new Map();
|
|
60
78
|
} catch {
|
|
@@ -211,7 +229,7 @@ function createComponent(
|
|
|
211
229
|
let buffer: string | null = null;
|
|
212
230
|
|
|
213
231
|
function render(width: number): string[] {
|
|
214
|
-
const inner = Math.max(20, width -
|
|
232
|
+
const inner = Math.max(20, width - 4);
|
|
215
233
|
// pi hands the component its width but not its height, so the height comes
|
|
216
234
|
// from the terminal itself, minus what pi's own chrome takes.
|
|
217
235
|
const maxRows = usableRows(process.stdout?.rows);
|
|
@@ -226,19 +244,24 @@ function createComponent(
|
|
|
226
244
|
);
|
|
227
245
|
}
|
|
228
246
|
|
|
229
|
-
// Reserve the header and
|
|
230
|
-
// a long list scrolls instead of overflowing
|
|
231
|
-
const capacity = Math.max(1, maxRows - head.length -
|
|
247
|
+
// Reserve the header, footer and the two frame lines, then window the list
|
|
248
|
+
// around the cursor so a long list scrolls instead of overflowing.
|
|
249
|
+
const capacity = Math.max(1, maxRows - head.length - 4);
|
|
232
250
|
const win = windowRows(state.entries.length, state.cursor, capacity);
|
|
233
251
|
const rows = state.entries.slice(win.start, win.end).map((entry, index) => {
|
|
234
252
|
const selected = win.start + index === state.cursor;
|
|
235
|
-
return
|
|
253
|
+
return { text: `${selected ? "❯ " : " "}${entry.label}` };
|
|
236
254
|
});
|
|
237
255
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
256
|
+
const menu = [
|
|
257
|
+
...head.map((text) => ({ text })),
|
|
258
|
+
...rows,
|
|
259
|
+
{ text: "" },
|
|
260
|
+
{ text: "↑↓ mover · enter elegir · esc volver · q salir" },
|
|
261
|
+
];
|
|
262
|
+
// The preview is empty until a profile exists, and `sideBySide` drops the
|
|
263
|
+
// second panel for an empty preview or a narrow terminal.
|
|
264
|
+
return fitRows(sideBySide(menu, previewRows(state), width), maxRows);
|
|
242
265
|
}
|
|
243
266
|
|
|
244
267
|
/** Apply an `EnterResult` — re-render on `state`, close on `save`/`quit`. */
|
|
@@ -357,5 +380,6 @@ function register(pi?: PiApi): void {
|
|
|
357
380
|
register.runPicker = runPicker;
|
|
358
381
|
register.createComponent = createComponent;
|
|
359
382
|
register.pickerInput = pickerInput;
|
|
383
|
+
register.groupsFrom = groupsFrom;
|
|
360
384
|
|
|
361
385
|
export default register;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonrocca/nodd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Non-negotiable Organic Driven Development — the ODD protocol as runtime mechanism for pi: blocking gates, observed evidence, and promotion to /forge.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -123,3 +123,49 @@ test("a bare model resolved to one provider is written qualified", () => {
|
|
|
123
123
|
const patch = assignmentPatch({}, { slot: "implement", provider: "openai-codex", model: "gpt-5-codex" });
|
|
124
124
|
assert.deepEqual(patch, { models: { implement: "openai-codex/gpt-5-codex" } });
|
|
125
125
|
});
|
|
126
|
+
|
|
127
|
+
test("a provider whose ids carry a prefix is browsable by prefix", () => {
|
|
128
|
+
// cliproxy fronts several subscription pools and encodes the pool in the id:
|
|
129
|
+
// `personal/claude-opus-5`, `ds/deepseek-flash`. Grouping by provider alone
|
|
130
|
+
// put 166 models behind one row, which is not a menu anyone can use.
|
|
131
|
+
const groups = groupByProvider([
|
|
132
|
+
{ provider: "cliproxy", id: "personal/claude-opus-5" },
|
|
133
|
+
{ provider: "cliproxy", id: "personal/claude-sonnet-5" },
|
|
134
|
+
{ provider: "cliproxy", id: "ds/deepseek-flash" },
|
|
135
|
+
{ provider: "cliproxy", id: "glm-5.2" },
|
|
136
|
+
{ provider: "anthropic", id: "claude-opus-4-1" },
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
assert.deepEqual(
|
|
140
|
+
[...groups.keys()].sort(),
|
|
141
|
+
["anthropic", "cliproxy/ds", "cliproxy/personal", "cliproxy"].sort(),
|
|
142
|
+
"each prefix is its own browsable group; unprefixed ids stay under the provider",
|
|
143
|
+
);
|
|
144
|
+
assert.deepEqual(groups.get("cliproxy/personal"), ["personal/claude-opus-5", "personal/claude-sonnet-5"]);
|
|
145
|
+
assert.deepEqual(groups.get("cliproxy"), ["glm-5.2"], "only the unprefixed ids remain directly under it");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("assigning through a prefixed group writes the provider, not the prefix", () => {
|
|
149
|
+
// The group is a browsing device. What reaches the agent frontmatter must be
|
|
150
|
+
// the real `provider/id`, or pi cannot resolve it.
|
|
151
|
+
const groups = groupByProvider([{ provider: "cliproxy", id: "personal/claude-opus-5" }]);
|
|
152
|
+
const assignment = parseAssignment("implement=cliproxy/personal/claude-opus-5");
|
|
153
|
+
|
|
154
|
+
assert.deepEqual(assignment, { slot: "implement", provider: "cliproxy", model: "personal/claude-opus-5" });
|
|
155
|
+
assert.deepEqual(validateAssignment(assignment!, groups), { ok: true, provider: "cliproxy" });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("the unknown-provider message names providers, not browsing keys", () => {
|
|
159
|
+
// `cliproxy/ds` is a menu grouping, not something you can type as a provider:
|
|
160
|
+
// offering it as a valid value would send the user to an assignment that
|
|
161
|
+
// cannot resolve.
|
|
162
|
+
const groups = groupByProvider([
|
|
163
|
+
{ provider: "cliproxy", id: "personal/claude-opus-5" },
|
|
164
|
+
{ provider: "cliproxy", id: "ds/deepseek-flash" },
|
|
165
|
+
{ provider: "anthropic", id: "claude-opus-4-1" },
|
|
166
|
+
]);
|
|
167
|
+
const result = validateAssignment(parseAssignment("implement=nope/x")!, groups);
|
|
168
|
+
|
|
169
|
+
assert.equal(result.ok, false);
|
|
170
|
+
assert.match(result.message, /Usá uno de: anthropic, cliproxy$/, "the real providers, deduped");
|
|
171
|
+
});
|
package/src/models/assign.ts
CHANGED
|
@@ -31,12 +31,31 @@ export type Validation =
|
|
|
31
31
|
| { ok: true; provider: string | null }
|
|
32
32
|
| { ok: false; message: string };
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Group the registry into browsable menus.
|
|
36
|
+
*
|
|
37
|
+
* Normally one group per provider. But a provider that fronts several pools
|
|
38
|
+
* encodes the pool in the id — cliproxy serves `personal/claude-opus-5`,
|
|
39
|
+
* `ds/deepseek-flash`, `wibond/...` — and grouping by provider alone puts every
|
|
40
|
+
* one of them behind a single row: measured against a live cliproxy, 166 models
|
|
41
|
+
* in one flat list. Those ids are split one level deeper into `provider/prefix`
|
|
42
|
+
* groups, so the pools are what you actually browse.
|
|
43
|
+
*
|
|
44
|
+
* The group key is a browsing device only. The id inside each group stays whole
|
|
45
|
+
* (`personal/claude-opus-5`), because that is what pi has to resolve.
|
|
46
|
+
*/
|
|
34
47
|
export function groupByProvider(models: RegistryModel[]): Map<string, string[]> {
|
|
35
48
|
const groups = new Map<string, string[]>();
|
|
36
49
|
for (const model of models) {
|
|
37
|
-
const
|
|
50
|
+
const slash = model.id.indexOf("/");
|
|
51
|
+
// An id that begins or ends with the slash has no usable prefix; it is kept
|
|
52
|
+
// whole under its provider rather than producing an empty group.
|
|
53
|
+
const key = slash > 0 && slash < model.id.length - 1
|
|
54
|
+
? `${model.provider}/${model.id.slice(0, slash)}`
|
|
55
|
+
: model.provider;
|
|
56
|
+
const existing = groups.get(key);
|
|
38
57
|
if (existing) existing.push(model.id);
|
|
39
|
-
else groups.set(
|
|
58
|
+
else groups.set(key, [model.id]);
|
|
40
59
|
}
|
|
41
60
|
return groups;
|
|
42
61
|
}
|
|
@@ -56,8 +75,34 @@ export function parseAssignment(text: string): Assignment | null {
|
|
|
56
75
|
return { slot, provider, model };
|
|
57
76
|
}
|
|
58
77
|
|
|
78
|
+
/** The real provider names, with the browsing prefixes folded back off. */
|
|
79
|
+
function providerNames(groups: Map<string, string[]>): string[] {
|
|
80
|
+
const names = new Set<string>();
|
|
81
|
+
for (const key of groups.keys()) {
|
|
82
|
+
const slash = key.indexOf("/");
|
|
83
|
+
names.add(slash > 0 ? key.slice(0, slash) : key);
|
|
84
|
+
}
|
|
85
|
+
return [...names].sort();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Every id reachable under a provider, across all of its prefixed groups. */
|
|
89
|
+
function idsForProvider(groups: Map<string, string[]>, provider: string): string[] | undefined {
|
|
90
|
+
const ids: string[] = [];
|
|
91
|
+
for (const [key, group] of groups) {
|
|
92
|
+
if (key === provider || key.startsWith(`${provider}/`)) ids.push(...group);
|
|
93
|
+
}
|
|
94
|
+
return ids.length > 0 ? ids : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The providers owning a model id, with the browsing prefix stripped back off. */
|
|
59
98
|
function providersOwning(groups: Map<string, string[]>, model: string): string[] {
|
|
60
|
-
|
|
99
|
+
const owners = new Set<string>();
|
|
100
|
+
for (const [key, ids] of groups) {
|
|
101
|
+
if (!ids.includes(model)) continue;
|
|
102
|
+
const slash = key.indexOf("/");
|
|
103
|
+
owners.add(slash > 0 ? key.slice(0, slash) : key);
|
|
104
|
+
}
|
|
105
|
+
return [...owners];
|
|
61
106
|
}
|
|
62
107
|
|
|
63
108
|
function suggest(groups: Map<string, string[]>, model: string): string[] {
|
|
@@ -89,11 +134,13 @@ export function validateAssignment(assignment: Assignment, groups: Map<string, s
|
|
|
89
134
|
if (groups.size === 0) return { ok: true, provider: assignment.provider };
|
|
90
135
|
|
|
91
136
|
if (assignment.provider) {
|
|
92
|
-
|
|
137
|
+
// The group keys are browsing devices (`cliproxy/personal`), so a provider
|
|
138
|
+
// owns every group it prefixes, not just the one bearing its exact name.
|
|
139
|
+
const ids = idsForProvider(groups, assignment.provider);
|
|
93
140
|
if (!ids) {
|
|
94
141
|
return {
|
|
95
142
|
ok: false,
|
|
96
|
-
message: `provider desconocido: ${assignment.provider}. Usá uno de: ${
|
|
143
|
+
message: `provider desconocido: ${assignment.provider}. Usá uno de: ${providerNames(groups).join(", ")}`,
|
|
97
144
|
};
|
|
98
145
|
}
|
|
99
146
|
if (!ids.includes(assignment.model)) {
|
|
@@ -3,7 +3,9 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import {
|
|
5
5
|
fitRows,
|
|
6
|
+
frameBox,
|
|
6
7
|
padToWidth,
|
|
8
|
+
sideBySide,
|
|
7
9
|
stripAnsi,
|
|
8
10
|
truncateToWidth,
|
|
9
11
|
usableRows,
|
|
@@ -120,3 +122,46 @@ test("the layout helpers import nothing at all", () => {
|
|
|
120
122
|
.join("\n");
|
|
121
123
|
assert.ok(!/^import /m.test(source), "the layout must stay dependency-free: no node:*, no pi, no TUI");
|
|
122
124
|
});
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// The boxed two-panel layout
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
test("frameBox draws four sides and pads every row to the same width", () => {
|
|
131
|
+
const out = frameBox([{ text: "uno" }, { text: "" }, { text: "dos" }], 20);
|
|
132
|
+
|
|
133
|
+
assert.equal(out.length, 5, "top, three rows, bottom");
|
|
134
|
+
assert.ok(out[0].startsWith("┌") && out[0].endsWith("┐"));
|
|
135
|
+
assert.ok(out[4].startsWith("└") && out[4].endsWith("┘"));
|
|
136
|
+
for (const line of out) {
|
|
137
|
+
assert.equal(visibleWidth(line), 20, `every line is the box width: ${JSON.stringify(line)}`);
|
|
138
|
+
}
|
|
139
|
+
assert.ok(out[1].startsWith("│ uno") && out[1].endsWith("│"));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("frameBox truncates on display cells, so the closing edge never moves", () => {
|
|
143
|
+
const out = frameBox([{ text: "una fila larguísima que no entra de ningún modo" }], 20);
|
|
144
|
+
assert.equal(visibleWidth(out[1]), 20);
|
|
145
|
+
assert.ok(out[1].endsWith("│"), "the right edge survives the cut");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("below the split width only the menu is rendered", () => {
|
|
149
|
+
const menu = [{ text: "menu" }];
|
|
150
|
+
const preview = [{ text: "preview" }];
|
|
151
|
+
const narrow = sideBySide(menu, preview, 40);
|
|
152
|
+
|
|
153
|
+
assert.ok(narrow.every((line) => !line.includes("preview")), "no room: the preview is dropped, not squeezed");
|
|
154
|
+
assert.ok(narrow.some((line) => line.includes("menu")));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("side by side, both panels are framed and equally tall", () => {
|
|
158
|
+
const out = sideBySide([{ text: "a" }, { text: "b" }, { text: "c" }], [{ text: "p" }], 100);
|
|
159
|
+
|
|
160
|
+
assert.ok(out.every((line) => visibleWidth(line) <= 100), "the pair never exceeds the terminal");
|
|
161
|
+
const joined = out.join("\n");
|
|
162
|
+
assert.ok(joined.includes("a") && joined.includes("p"), "both panels are present");
|
|
163
|
+
// Five rows: the taller panel is three rows plus its two frame lines, and the
|
|
164
|
+
// shorter one is padded to match rather than leaving a ragged edge.
|
|
165
|
+
assert.equal(out.length, 5);
|
|
166
|
+
for (const line of out) assert.equal(visibleWidth(line), visibleWidth(out[0]), "rows are flush");
|
|
167
|
+
});
|
package/src/models/layout.ts
CHANGED
|
@@ -185,3 +185,72 @@ export function fitRows(lines: readonly string[], maxRows: number): string[] {
|
|
|
185
185
|
if (maxRows <= 0) return [];
|
|
186
186
|
return lines.length <= maxRows ? [...lines] : lines.slice(0, maxRows);
|
|
187
187
|
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// The boxed two-panel layout
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
/** One content row of a panel: plain text, measured before any colour runs. */
|
|
194
|
+
export type BoxRow = { text: string };
|
|
195
|
+
|
|
196
|
+
/** Below this width a frame cannot be drawn; the rows are returned bare. */
|
|
197
|
+
export const MIN_BOX_WIDTH = 10;
|
|
198
|
+
/** Below this terminal width the preview panel is dropped, never squeezed. */
|
|
199
|
+
export const MIN_SPLIT_WIDTH = 76;
|
|
200
|
+
/** The menu never shrinks past this, so profile names stay readable. */
|
|
201
|
+
const MIN_MENU_WIDTH = 30;
|
|
202
|
+
/** Nor does it take more than this share when both panels compete. */
|
|
203
|
+
const MAX_MENU_SHARE = 0.62;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Wrap rows in a four-sided box of the given outer width.
|
|
207
|
+
*
|
|
208
|
+
* Every row is sized on its plain text to `width - 4` (two `│` columns plus a
|
|
209
|
+
* space of padding each side) and measured in display cells, so a wide glyph
|
|
210
|
+
* can never push the closing edge off the line.
|
|
211
|
+
*/
|
|
212
|
+
export function frameBox(rows: readonly BoxRow[], width: number): string[] {
|
|
213
|
+
if (width < MIN_BOX_WIDTH) return rows.map((row) => truncateToWidth(row.text, Math.max(0, width)));
|
|
214
|
+
const horizontal = "─".repeat(width - 2);
|
|
215
|
+
return [
|
|
216
|
+
`┌${horizontal}┐`,
|
|
217
|
+
...rows.map((row) => `│ ${padToWidth(row.text, width - 4)} │`),
|
|
218
|
+
`└${horizontal}┘`,
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The widest row of a block, in display cells. */
|
|
223
|
+
function contentWidth(rows: readonly BoxRow[]): number {
|
|
224
|
+
let max = 0;
|
|
225
|
+
for (const row of rows) max = Math.max(max, visibleWidth(row.text));
|
|
226
|
+
return max;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Two framed panels side by side, padded to equal height.
|
|
231
|
+
*
|
|
232
|
+
* Under {@link MIN_SPLIT_WIDTH}, or with nothing to preview, only the menu is
|
|
233
|
+
* returned: a preview crushed into a handful of columns is worse than none.
|
|
234
|
+
*/
|
|
235
|
+
export function sideBySide(menu: readonly BoxRow[], preview: readonly BoxRow[], width: number): string[] {
|
|
236
|
+
if (width < MIN_SPLIT_WIDTH || preview.length === 0) {
|
|
237
|
+
return frameBox(menu, Math.min(width, Math.max(MIN_BOX_WIDTH, contentWidth(menu) + 4)));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const gap = 1;
|
|
241
|
+
const menuWidth = Math.max(
|
|
242
|
+
MIN_MENU_WIDTH,
|
|
243
|
+
Math.min(contentWidth(menu) + 4, Math.floor(width * MAX_MENU_SHARE)),
|
|
244
|
+
);
|
|
245
|
+
const previewWidth = Math.max(MIN_BOX_WIDTH, width - menuWidth - gap);
|
|
246
|
+
|
|
247
|
+
// Pad to a common height first: framing a short panel and padding afterwards
|
|
248
|
+
// would leave its bottom border floating mid-block.
|
|
249
|
+
const height = Math.max(menu.length, preview.length);
|
|
250
|
+
const pad = (rows: readonly BoxRow[]): BoxRow[] =>
|
|
251
|
+
rows.length >= height ? [...rows] : [...rows, ...Array.from({ length: height - rows.length }, () => ({ text: "" }))];
|
|
252
|
+
|
|
253
|
+
const left = frameBox(pad(menu), menuWidth);
|
|
254
|
+
const right = frameBox(pad(preview), previewWidth);
|
|
255
|
+
return left.map((line, index) => `${line}${" ".repeat(gap)}${right[index] ?? ""}`);
|
|
256
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
enter,
|
|
11
11
|
navigate,
|
|
12
12
|
pickerTitle,
|
|
13
|
+
previewRows,
|
|
13
14
|
rebuildEntries,
|
|
14
15
|
submitText,
|
|
15
16
|
type PickerState,
|
|
@@ -529,3 +530,58 @@ test("no transition writes: every one returns state the caller may discard", ()
|
|
|
529
530
|
const source = readFileSync(new URL("./picker.ts", import.meta.url), "utf8");
|
|
530
531
|
assert.ok(!/writeFileSync|readFileSync/.test(source), "the state machine performs no IO");
|
|
531
532
|
});
|
|
533
|
+
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
// The preview panel
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
test("the preview shows the profile under the cursor, model and level per slot", () => {
|
|
539
|
+
const state = createPickerState({
|
|
540
|
+
models: {},
|
|
541
|
+
thinking: {},
|
|
542
|
+
groups: new Map(),
|
|
543
|
+
profiles: {
|
|
544
|
+
rapido: {
|
|
545
|
+
models: { implement: "cliproxy/ds/deepseek-v4-pro", explore: "cliproxy/ds/deepseek-flash" },
|
|
546
|
+
thinking: { implement: "high", explore: "low" },
|
|
547
|
+
},
|
|
548
|
+
lento: { models: { implement: "cliproxy/personal/claude-opus-5" }, thinking: { implement: "xhigh" } },
|
|
549
|
+
},
|
|
550
|
+
activeProfile: "rapido",
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// The menu is alphabetical, so the cursor opens on `lento`; one row down is
|
|
554
|
+
// `rapido`, and the preview follows the cursor rather than the active profile.
|
|
555
|
+
const rows = previewRows(navigate(state, 1)).map((row) => row.text);
|
|
556
|
+
assert.match(rows[0], /vista previa · rapido \(activo\)/, "the profile under the cursor is named");
|
|
557
|
+
assert.ok(rows.some((r) => /implement\s+→ cliproxy\/ds\/deepseek-v4-pro · high/.test(r)), rows.join("\n"));
|
|
558
|
+
assert.ok(rows.some((r) => /explore\s+→ cliproxy\/ds\/deepseek-flash · low/.test(r)), rows.join("\n"));
|
|
559
|
+
assert.ok(rows.some((r) => r.includes("proveedores: cliproxy")), "the providers are summarised");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("the preview marks mechanism steps rather than pretending they take a model", () => {
|
|
563
|
+
const state = createPickerState({
|
|
564
|
+
models: {}, thinking: {}, groups: new Map(),
|
|
565
|
+
profiles: { p: { models: {}, thinking: {} } }, activeProfile: "p",
|
|
566
|
+
});
|
|
567
|
+
const rows = previewRows(state).map((row) => row.text);
|
|
568
|
+
assert.ok(rows.some((r) => r.includes("authorize") && r.includes(MECHANISM_PLACEHOLDER)), rows.join("\n"));
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test("with no profile at all there is nothing to preview", () => {
|
|
572
|
+
const state = createPickerState({ models: {}, thinking: {}, groups: new Map(), profiles: {}, activeProfile: null });
|
|
573
|
+
assert.deepEqual(previewRows(state), [], "an empty preview is what suppresses the second panel");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("choosing through a prefixed group does not double the prefix", () => {
|
|
577
|
+
// The group key is `cliproxy/ds` and the id inside it is `ds/deepseek-v4-pro`:
|
|
578
|
+
// joining them naively produced `cliproxy/ds/ds/deepseek-v4-pro`, a model id
|
|
579
|
+
// that resolves to nothing. The key's prefix belongs to the id, not to the
|
|
580
|
+
// provider, so only the provider part may be prepended.
|
|
581
|
+
let state = open({ groups: new Map([["cliproxy/ds", ["ds/deepseek-v4-pro"]]]) });
|
|
582
|
+
state = press(state, "edit-loose");
|
|
583
|
+
state = assign(state, "implement", "cliproxy/ds", "ds/deepseek-v4-pro", "high");
|
|
584
|
+
|
|
585
|
+
assert.equal(state.edits.models.implement, "cliproxy/ds/deepseek-v4-pro");
|
|
586
|
+
assert.equal(state.edits.thinking.implement, "high");
|
|
587
|
+
});
|
package/src/models/picker.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// Forge's autotune screen is deliberately absent: autotune is a forge feature and
|
|
23
23
|
// NODD has no learning loop to configure.
|
|
24
24
|
|
|
25
|
-
import { SLOT_ROWS, isMechanismSlot } from "./slots.ts";
|
|
25
|
+
import { MECHANISM_PLACEHOLDER, SLOT_ROWS, isMechanismSlot } from "./slots.ts";
|
|
26
26
|
import { THINKING_LEVELS, type SlotThinking, type ThinkingLevel } from "./thinking.ts";
|
|
27
27
|
import { isValidProfileName, type Profile } from "./profiles.ts";
|
|
28
28
|
|
|
@@ -528,9 +528,15 @@ function commitDrill(state: PickerState, level: ThinkingLevel): PickerState {
|
|
|
528
528
|
const { drillSlot, drillProvider, drillModel } = state;
|
|
529
529
|
if (drillSlot === null || drillModel === null) return state;
|
|
530
530
|
|
|
531
|
-
//
|
|
531
|
+
// `drillProvider` is a group key, and a pooled provider's key carries the
|
|
532
|
+
// pool prefix (`cliproxy/ds`) which the id already repeats
|
|
533
|
+
// (`ds/deepseek-v4-pro`). Only the provider part is prepended, or the commit
|
|
534
|
+
// doubles the prefix into an id that resolves to nothing.
|
|
535
|
+
//
|
|
536
|
+
// No provider at all is a bare model id — what the empty-registry path
|
|
532
537
|
// produces, and what the command's validation accepts.
|
|
533
|
-
const
|
|
538
|
+
const provider = drillProvider?.split("/")[0] ?? null;
|
|
539
|
+
const qualified = provider ? `${provider}/${drillModel}` : drillModel;
|
|
534
540
|
return {
|
|
535
541
|
...state,
|
|
536
542
|
edits: {
|
|
@@ -675,3 +681,67 @@ function submitProfileName(
|
|
|
675
681
|
: `perfil «${name}» creado — elegí los modelos (activar es aparte)`,
|
|
676
682
|
});
|
|
677
683
|
}
|
|
684
|
+
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
// The preview panel
|
|
687
|
+
// ---------------------------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
/** One row of the preview panel. */
|
|
690
|
+
export type PreviewRow = { text: string };
|
|
691
|
+
|
|
692
|
+
/** The profile the preview should show, or null when there is nothing to show. */
|
|
693
|
+
function previewTarget(state: PickerState): { name: string; profile: Profile } | null {
|
|
694
|
+
if (state.screen === "profile-actions" && state.drillProfile) {
|
|
695
|
+
const profile = state.edits.profiles[state.drillProfile];
|
|
696
|
+
return profile ? { name: state.drillProfile, profile } : null;
|
|
697
|
+
}
|
|
698
|
+
if (state.screen !== "main") return null;
|
|
699
|
+
|
|
700
|
+
// On the menu the cursor drives the preview, so arrowing down the list shows
|
|
701
|
+
// each profile in turn. Off a profile row it falls back to the active one.
|
|
702
|
+
const entry = state.entries[state.cursor];
|
|
703
|
+
const name = entry?.kind === "profile"
|
|
704
|
+
? entry.value
|
|
705
|
+
: state.edits.activeProfile ?? Object.keys(state.edits.profiles).sort()[0];
|
|
706
|
+
if (!name) return null;
|
|
707
|
+
const profile = state.edits.profiles[name];
|
|
708
|
+
return profile ? { name, profile } : null;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* The preview rows: every canonical step with its model and thinking level.
|
|
713
|
+
*
|
|
714
|
+
* Empty when there is no profile to show, which is what suppresses the second
|
|
715
|
+
* panel rather than drawing an empty box.
|
|
716
|
+
*/
|
|
717
|
+
export function previewRows(state: PickerState): PreviewRow[] {
|
|
718
|
+
const target = previewTarget(state);
|
|
719
|
+
if (!target) return [];
|
|
720
|
+
|
|
721
|
+
const { name, profile } = target;
|
|
722
|
+
const active = name === state.edits.activeProfile ? " (activo)" : "";
|
|
723
|
+
const rows: PreviewRow[] = [{ text: `vista previa · ${name}${active}` }, { text: "" }];
|
|
724
|
+
|
|
725
|
+
const width = Math.max(...SLOT_ROWS.map((row) => row.id.length));
|
|
726
|
+
const providers = new Set<string>();
|
|
727
|
+
for (const row of SLOT_ROWS) {
|
|
728
|
+
const label = row.id.padEnd(width);
|
|
729
|
+
if (isMechanismSlot(row.id)) {
|
|
730
|
+
rows.push({ text: `${label} ${MECHANISM_PLACEHOLDER}` });
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
const model = profile.models?.[row.id];
|
|
734
|
+
if (!model) {
|
|
735
|
+
rows.push({ text: `${label} → sin asignar` });
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
const slash = model.indexOf("/");
|
|
739
|
+
if (slash > 0) providers.add(model.slice(0, slash));
|
|
740
|
+
const level = profile.thinking?.[row.id];
|
|
741
|
+
rows.push({ text: `${label} → ${model}${level ? ` · ${level}` : ""}` });
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
rows.push({ text: "" });
|
|
745
|
+
rows.push({ text: `proveedores: ${providers.size > 0 ? [...providers].sort().join(", ") : "por defecto"}` });
|
|
746
|
+
return rows;
|
|
747
|
+
}
|