@gonrocca/nodd 0.3.1 → 0.4.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-models.ts +16 -11
- 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 +43 -0
- package/src/models/picker.ts +65 -1
|
@@ -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 = {
|
|
@@ -211,7 +211,7 @@ function createComponent(
|
|
|
211
211
|
let buffer: string | null = null;
|
|
212
212
|
|
|
213
213
|
function render(width: number): string[] {
|
|
214
|
-
const inner = Math.max(20, width -
|
|
214
|
+
const inner = Math.max(20, width - 4);
|
|
215
215
|
// pi hands the component its width but not its height, so the height comes
|
|
216
216
|
// from the terminal itself, minus what pi's own chrome takes.
|
|
217
217
|
const maxRows = usableRows(process.stdout?.rows);
|
|
@@ -226,19 +226,24 @@ function createComponent(
|
|
|
226
226
|
);
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
// Reserve the header and
|
|
230
|
-
// a long list scrolls instead of overflowing
|
|
231
|
-
const capacity = Math.max(1, maxRows - head.length -
|
|
229
|
+
// Reserve the header, footer and the two frame lines, then window the list
|
|
230
|
+
// around the cursor so a long list scrolls instead of overflowing.
|
|
231
|
+
const capacity = Math.max(1, maxRows - head.length - 4);
|
|
232
232
|
const win = windowRows(state.entries.length, state.cursor, capacity);
|
|
233
233
|
const rows = state.entries.slice(win.start, win.end).map((entry, index) => {
|
|
234
234
|
const selected = win.start + index === state.cursor;
|
|
235
|
-
return
|
|
235
|
+
return { text: `${selected ? "❯ " : " "}${entry.label}` };
|
|
236
236
|
});
|
|
237
237
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
238
|
+
const menu = [
|
|
239
|
+
...head.map((text) => ({ text })),
|
|
240
|
+
...rows,
|
|
241
|
+
{ text: "" },
|
|
242
|
+
{ text: "↑↓ mover · enter elegir · esc volver · q salir" },
|
|
243
|
+
];
|
|
244
|
+
// The preview is empty until a profile exists, and `sideBySide` drops the
|
|
245
|
+
// second panel for an empty preview or a narrow terminal.
|
|
246
|
+
return fitRows(sideBySide(menu, previewRows(state), width), maxRows);
|
|
242
247
|
}
|
|
243
248
|
|
|
244
249
|
/** Apply an `EnterResult` — re-render on `state`, close on `save`/`quit`. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonrocca/nodd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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,45 @@ 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
|
+
});
|
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
|
|
|
@@ -675,3 +675,67 @@ function submitProfileName(
|
|
|
675
675
|
: `perfil «${name}» creado — elegí los modelos (activar es aparte)`,
|
|
676
676
|
});
|
|
677
677
|
}
|
|
678
|
+
|
|
679
|
+
// ---------------------------------------------------------------------------
|
|
680
|
+
// The preview panel
|
|
681
|
+
// ---------------------------------------------------------------------------
|
|
682
|
+
|
|
683
|
+
/** One row of the preview panel. */
|
|
684
|
+
export type PreviewRow = { text: string };
|
|
685
|
+
|
|
686
|
+
/** The profile the preview should show, or null when there is nothing to show. */
|
|
687
|
+
function previewTarget(state: PickerState): { name: string; profile: Profile } | null {
|
|
688
|
+
if (state.screen === "profile-actions" && state.drillProfile) {
|
|
689
|
+
const profile = state.edits.profiles[state.drillProfile];
|
|
690
|
+
return profile ? { name: state.drillProfile, profile } : null;
|
|
691
|
+
}
|
|
692
|
+
if (state.screen !== "main") return null;
|
|
693
|
+
|
|
694
|
+
// On the menu the cursor drives the preview, so arrowing down the list shows
|
|
695
|
+
// each profile in turn. Off a profile row it falls back to the active one.
|
|
696
|
+
const entry = state.entries[state.cursor];
|
|
697
|
+
const name = entry?.kind === "profile"
|
|
698
|
+
? entry.value
|
|
699
|
+
: state.edits.activeProfile ?? Object.keys(state.edits.profiles).sort()[0];
|
|
700
|
+
if (!name) return null;
|
|
701
|
+
const profile = state.edits.profiles[name];
|
|
702
|
+
return profile ? { name, profile } : null;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* The preview rows: every canonical step with its model and thinking level.
|
|
707
|
+
*
|
|
708
|
+
* Empty when there is no profile to show, which is what suppresses the second
|
|
709
|
+
* panel rather than drawing an empty box.
|
|
710
|
+
*/
|
|
711
|
+
export function previewRows(state: PickerState): PreviewRow[] {
|
|
712
|
+
const target = previewTarget(state);
|
|
713
|
+
if (!target) return [];
|
|
714
|
+
|
|
715
|
+
const { name, profile } = target;
|
|
716
|
+
const active = name === state.edits.activeProfile ? " (activo)" : "";
|
|
717
|
+
const rows: PreviewRow[] = [{ text: `vista previa · ${name}${active}` }, { text: "" }];
|
|
718
|
+
|
|
719
|
+
const width = Math.max(...SLOT_ROWS.map((row) => row.id.length));
|
|
720
|
+
const providers = new Set<string>();
|
|
721
|
+
for (const row of SLOT_ROWS) {
|
|
722
|
+
const label = row.id.padEnd(width);
|
|
723
|
+
if (isMechanismSlot(row.id)) {
|
|
724
|
+
rows.push({ text: `${label} ${MECHANISM_PLACEHOLDER}` });
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
const model = profile.models?.[row.id];
|
|
728
|
+
if (!model) {
|
|
729
|
+
rows.push({ text: `${label} → sin asignar` });
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
const slash = model.indexOf("/");
|
|
733
|
+
if (slash > 0) providers.add(model.slice(0, slash));
|
|
734
|
+
const level = profile.thinking?.[row.id];
|
|
735
|
+
rows.push({ text: `${label} → ${model}${level ? ` · ${level}` : ""}` });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
rows.push({ text: "" });
|
|
739
|
+
rows.push({ text: `proveedores: ${providers.size > 0 ? [...providers].sort().join(", ") : "por defecto"}` });
|
|
740
|
+
return rows;
|
|
741
|
+
}
|