@neosh/model 0.1.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/main.ts +796 -0
- package/options.ts +533 -0
- package/package.json +21 -0
- package/plugin.toml +4 -0
package/main.ts
ADDED
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model switcher, and the reasoning-effort switcher next to it.
|
|
3
|
+
*
|
|
4
|
+
* Both are the same thing twice: a picker over what the *driver* said it supports. The effort
|
|
5
|
+
* levels are not hard-coded here — they arrive as `ProviderOptionDescriptor`s attached to the
|
|
6
|
+
* chosen model, so a provider plugin that invents a new knob gets a working picker for it with no
|
|
7
|
+
* change to this file. That is the whole reason that descriptor type exists.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
AccountKind,
|
|
12
|
+
CredentialInfo,
|
|
13
|
+
Disposable,
|
|
14
|
+
ModelInfo,
|
|
15
|
+
CredentialSource,
|
|
16
|
+
ModelEntry,
|
|
17
|
+
ModelSelection,
|
|
18
|
+
ModelTier,
|
|
19
|
+
Neosh,
|
|
20
|
+
OptionSelection,
|
|
21
|
+
PluginContext,
|
|
22
|
+
ProviderOptionDescriptor,
|
|
23
|
+
} from "@neosh/api";
|
|
24
|
+
import type { PaneItem, RailItem } from "@neosh/api/ui";
|
|
25
|
+
import { confirmDestructive, defineHighlights, picker, prompt, railPicker } from "@neosh/api/ui";
|
|
26
|
+
import { installOptions, openOptions } from "./options.ts";
|
|
27
|
+
|
|
28
|
+
export async function activate({ neosh, subscriptions }: PluginContext) {
|
|
29
|
+
await defineHighlights(neosh);
|
|
30
|
+
|
|
31
|
+
await neosh.opt.declare({
|
|
32
|
+
name: "model.picker.show_pricing",
|
|
33
|
+
type: { type: "bool" },
|
|
34
|
+
default: true,
|
|
35
|
+
description: "Show cost per million tokens beside each model, where the provider reports it.",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
await neosh.cmd.register("model.pick", () => pickModel(neosh), {
|
|
39
|
+
desc: "Choose the model for this conversation",
|
|
40
|
+
});
|
|
41
|
+
await neosh.cmd.register("model.options", () => openOptions(neosh), {
|
|
42
|
+
desc: "Reasoning effort, and everything else this model can be told",
|
|
43
|
+
});
|
|
44
|
+
await neosh.cmd.register("provider.auth", () => pickProvider(neosh), {
|
|
45
|
+
desc: "Sign in to a provider, or forget a key",
|
|
46
|
+
});
|
|
47
|
+
await neosh.cmd.register("model.upgrade", () => step(neosh, 1), {
|
|
48
|
+
desc: "Move up one rung — to the more capable model this provider serves",
|
|
49
|
+
});
|
|
50
|
+
await neosh.cmd.register("model.downgrade", () => step(neosh, -1), {
|
|
51
|
+
desc: "Move down one rung — cheaper and quicker",
|
|
52
|
+
});
|
|
53
|
+
await neosh.cmd.register("model.line", (args) => useLine(neosh, args[0]), {
|
|
54
|
+
desc: "Switch to the current model in a line — `model.line opus`",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await neosh.keymap.set("chat", "<C-p>", "model.pick", { desc: "Pick a model" });
|
|
58
|
+
await neosh.keymap.set("chat", "<C-e>", "model.options", { desc: "Pick reasoning effort" });
|
|
59
|
+
// The rungs have no default key. They had `⌥↑` and `⌥↓`, which read well and are not keys a
|
|
60
|
+
// terminal can be relied on to send: a Mac terminal turns Option-arrow into a word motion or an
|
|
61
|
+
// escape sequence depending on a setting its owner has probably never opened, and a keyboard
|
|
62
|
+
// whose arrows live on a layer cannot hold Alt and reach them at all. Chat mode has no chord
|
|
63
|
+
// left to move them to, and inventing a two-key prefix for two rungs is a worse trade than the
|
|
64
|
+
// one row of `^P` they are already on.
|
|
65
|
+
//
|
|
66
|
+
// They stay ordinary commands, which is the part that matters: `^K` runs them by name, and one
|
|
67
|
+
// line in `init.ts` puts them on whichever key your keyboard actually has.
|
|
68
|
+
//
|
|
69
|
+
// await neosh.keymap.set("chat", "<A-Up>", "model.upgrade");
|
|
70
|
+
|
|
71
|
+
// Deliberately *not* on the shortcut row. The key lives beside the model name in the footer,
|
|
72
|
+
// where the thing it changes is already being read — saying it twice costs a row and teaches
|
|
73
|
+
// nothing the first place did not.
|
|
74
|
+
|
|
75
|
+
// The footer. The model and its options belong beside the composer rather than on a settings
|
|
76
|
+
// page: they are the two things you change mid-conversation — and each carries its own key,
|
|
77
|
+
// because a key is only memorable next to the thing it changes.
|
|
78
|
+
/**
|
|
79
|
+
* `flash` lights the options segment for a moment before settling.
|
|
80
|
+
*
|
|
81
|
+
* The "it moved there" flash the palette reserves reverse for, and it is here rather than in the
|
|
82
|
+
* panel that was just dismissed for two reasons: it is where the setting now lives, which is the
|
|
83
|
+
* thing worth teaching, and a panel held open to be looked at after the key that closed it is a
|
|
84
|
+
* panel still swallowing the next key.
|
|
85
|
+
*/
|
|
86
|
+
const footer = async (flash = false) => {
|
|
87
|
+
const selection = await neosh.agent.selection().catch(() => null);
|
|
88
|
+
if (!selection) {
|
|
89
|
+
await neosh.status.set("model", {
|
|
90
|
+
text: "no model",
|
|
91
|
+
keys: "^P",
|
|
92
|
+
hl: "Diagnostic.Warn",
|
|
93
|
+
align: "right",
|
|
94
|
+
priority: 10,
|
|
95
|
+
});
|
|
96
|
+
await neosh.status.clear("effort");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// Whether the next turn can authenticate belongs beside the model name, not in the error it
|
|
100
|
+
// would otherwise become. "opus · no key" is something you fix before you have typed the
|
|
101
|
+
// question; a failure on send is something you fix after losing your train of thought.
|
|
102
|
+
//
|
|
103
|
+
// Together with the catalogue, because both are wanted before the strip is written and
|
|
104
|
+
// neither depends on the other — asked one after the other, the model name appeared a round
|
|
105
|
+
// trip later than it had to for the sake of a shorter spelling of itself.
|
|
106
|
+
const [creds, entries] = await Promise.all([
|
|
107
|
+
neosh.agent.credentials().catch(() => []),
|
|
108
|
+
neosh.agent.listModels(selection.instance).catch(() => [] as ModelEntry[]),
|
|
109
|
+
]);
|
|
110
|
+
const cred = creds.find((c) => c.instance === selection.instance);
|
|
111
|
+
const unusable = cred?.source.kind === "missing";
|
|
112
|
+
const info = entries.find((e) => e.model.id === selection.model)?.model;
|
|
113
|
+
// Right-hand end, where pi puts it: the left of the strip is what the conversation is
|
|
114
|
+
// spending, which changes constantly, and the right is what it is spending it on, which does
|
|
115
|
+
// not. Two things that move at different rates should not be interleaved.
|
|
116
|
+
//
|
|
117
|
+
// `short` is the id with the vendor's half of it off — `claude-opus-4-5` is `opus-4-5`, and
|
|
118
|
+
// `anthropic/claude-opus-5` is `opus-5`. This is the widest thing on the strip and it used to
|
|
119
|
+
// cost its full width or nothing, which on a narrow terminal meant the segments after it went
|
|
120
|
+
// instead. Which vendor you are on is the part of that string you already knew.
|
|
121
|
+
const brief = withoutVendor(selection.model, info?.family ?? undefined);
|
|
122
|
+
await neosh.status.set("model", {
|
|
123
|
+
text: `${selection.model}${unusable ? " no key" : ""}`,
|
|
124
|
+
short: brief ? `${brief}${unusable ? " no key" : ""}` : undefined,
|
|
125
|
+
keys: "^P",
|
|
126
|
+
hl: unusable ? "Diagnostic.Warn" : "Accent",
|
|
127
|
+
align: "right",
|
|
128
|
+
priority: 10,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Its own segment, with its own key. Glued onto the model name they read as one word, and the
|
|
132
|
+
// one you want to change is whichever you are not looking at.
|
|
133
|
+
const descriptors = info?.capabilities?.option_descriptors ?? [];
|
|
134
|
+
const options = summarise(selection.options ?? [], descriptors);
|
|
135
|
+
if (options) {
|
|
136
|
+
await neosh.status.set("effort", {
|
|
137
|
+
text: options,
|
|
138
|
+
keys: "^E",
|
|
139
|
+
// A setting that is not a level — one bought by saying a word rather than by sending a
|
|
140
|
+
// parameter — wears the group that animates, so the strip itself says the next turn is not
|
|
141
|
+
// an ordinary one. It is the only place that *can* say it: the word never appears in the
|
|
142
|
+
// transcript, and the picker it was chosen in has been shut for an hour.
|
|
143
|
+
hl: flash
|
|
144
|
+
? "Option.Chosen"
|
|
145
|
+
: beyond(selection.options ?? [], descriptors)
|
|
146
|
+
? "Option.Beyond"
|
|
147
|
+
: undefined,
|
|
148
|
+
align: "right",
|
|
149
|
+
priority: 11,
|
|
150
|
+
});
|
|
151
|
+
// Long enough to be seen, short enough that nobody waits for it. Settling is an ordinary
|
|
152
|
+
// redraw, so whatever the strip should say — including a rainbow, if what you chose is a word
|
|
153
|
+
// rather than a level — is said by the same code that would have said it anyway.
|
|
154
|
+
if (flash) flashOff?.dispose();
|
|
155
|
+
if (flash) flashOff = neosh.timer.after(320, () => void footer());
|
|
156
|
+
} else {
|
|
157
|
+
await neosh.status.clear("effort");
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
await footer();
|
|
161
|
+
// The sheet's own keys, bound to this plugin's buffer kind rather than handled privately — so
|
|
162
|
+
// `^Z` lists them and `init.ts` can move any of them. See `options.ts`.
|
|
163
|
+
await installOptions({ neosh, subscriptions }, (flash) => footer(flash));
|
|
164
|
+
subscriptions.push(neosh.session.onChange(() => void footer()));
|
|
165
|
+
subscriptions.push(neosh.agent.onTurnEnd(() => void footer()));
|
|
166
|
+
// Whoever changed it — this plugin, a stored model that would not authenticate, or a provider
|
|
167
|
+
// registering late and finally serving the model somebody asked for in their config.
|
|
168
|
+
subscriptions.push(neosh.agent.onSelectionChange(() => void footer()));
|
|
169
|
+
refreshFooter = footer;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Set by `activate`, so the pickers can update the footer the moment a choice lands. */
|
|
173
|
+
let refreshFooter: () => Promise<void> = async () => {};
|
|
174
|
+
|
|
175
|
+
/** The pending "settle back to normal" after a flash, so two in a row do not fight. */
|
|
176
|
+
let flashOff: Disposable | null = null;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The option values, as a line short enough to sit in a footer.
|
|
180
|
+
*
|
|
181
|
+
* A boolean that is off is not shown at all — `fast_mode false` says nothing you did not already
|
|
182
|
+
* assume, and putting the word "false" in a status strip beside a model name reads as an error.
|
|
183
|
+
* One that is on is shown by name, because that *is* the news. Selects show their value, since a
|
|
184
|
+
* select always has one and which one it is always matters.
|
|
185
|
+
*/
|
|
186
|
+
function summarise(
|
|
187
|
+
chosen: readonly OptionSelection[],
|
|
188
|
+
descriptors: readonly ProviderOptionDescriptor[],
|
|
189
|
+
): string {
|
|
190
|
+
const out: string[] = [];
|
|
191
|
+
for (const d of descriptors) {
|
|
192
|
+
const value = chosen.find((o) => o.id === d.id)?.value;
|
|
193
|
+
if (d.type === "boolean") {
|
|
194
|
+
if (value === true) out.push(d.label.toLowerCase());
|
|
195
|
+
} else if (typeof value === "string") {
|
|
196
|
+
out.push(d.options.find((o) => o.id === value)?.label ?? value);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return out.join(" ");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Whether anything in effect is applied by *saying* it rather than by sending it.
|
|
204
|
+
*
|
|
205
|
+
* Asked of the descriptors rather than of the value's name, because which words a vendor reads is
|
|
206
|
+
* the vendor's business: this file has never heard of "ultrathink" and does not need to.
|
|
207
|
+
*/
|
|
208
|
+
function beyond(
|
|
209
|
+
chosen: readonly OptionSelection[],
|
|
210
|
+
descriptors: readonly ProviderOptionDescriptor[],
|
|
211
|
+
): boolean {
|
|
212
|
+
return descriptors.some((d) => {
|
|
213
|
+
const value = chosen.find((o) => o.id === d.id)?.value;
|
|
214
|
+
return d.type === "boolean"
|
|
215
|
+
? value === true && typeof d.prompt_injected_word === "string"
|
|
216
|
+
: typeof value === "string" && (d.prompt_injected_values ?? []).includes(value);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The model id with the vendor's half of it taken off — `claude-opus-4-5` becomes `opus-4-5`.
|
|
222
|
+
*
|
|
223
|
+
* The strip's short form for the model segment. Cut at the *family*, which the catalogue already
|
|
224
|
+
* says (`"opus"`, `"sonnet"`, `"gpt-5"`), rather than at a prefix this file guesses at: a list of
|
|
225
|
+
* vendor spellings to strip is a list that is wrong about the next provider somebody registers,
|
|
226
|
+
* and the whole point of the catalogue is that a plugin's models arrive described.
|
|
227
|
+
*
|
|
228
|
+
* Nothing when there is no shorter honest version of the name — no family, a family that is not in
|
|
229
|
+
* the id, or an id that is already only its family. The segment is then dropped whole if it comes
|
|
230
|
+
* to that, which is the right answer: half a model id names a different model.
|
|
231
|
+
*/
|
|
232
|
+
function withoutVendor(id: string, family?: string): string | undefined {
|
|
233
|
+
// A namespaced id is a vendor and a name, and the vendor is the part being dropped either way.
|
|
234
|
+
const bare = id.slice(id.lastIndexOf("/") + 1);
|
|
235
|
+
if (!family) return bare === id ? undefined : bare;
|
|
236
|
+
const at = bare.indexOf(family);
|
|
237
|
+
if (at <= 0) return bare === id ? undefined : bare;
|
|
238
|
+
const cut = bare.slice(at);
|
|
239
|
+
return cut === id ? undefined : cut;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* How an instance's key situation reads in a list.
|
|
244
|
+
*
|
|
245
|
+
* Said in words rather than shown as a state icon, because the useful distinction is not
|
|
246
|
+
* "authenticated or not" — it is *which* of four things is true, and each one implies a different
|
|
247
|
+
* next action.
|
|
248
|
+
*/
|
|
249
|
+
function describe(source: CredentialSource): string {
|
|
250
|
+
switch (source.kind) {
|
|
251
|
+
// A plan. There is no key, which is the answer to "why does it say I have no API key".
|
|
252
|
+
case "plan":
|
|
253
|
+
return `your ${source.via} login`;
|
|
254
|
+
case "plan_missing":
|
|
255
|
+
return source.hint
|
|
256
|
+
? `${source.program} is not installed — then \`${source.hint}\``
|
|
257
|
+
: `${source.program} is not installed`;
|
|
258
|
+
case "inherited":
|
|
259
|
+
return "signs in on its own";
|
|
260
|
+
// A key, and which of the four places it is coming from.
|
|
261
|
+
case "env":
|
|
262
|
+
return `$${source.var}`;
|
|
263
|
+
case "keychain":
|
|
264
|
+
return "keychain";
|
|
265
|
+
case "session":
|
|
266
|
+
return "this session only";
|
|
267
|
+
case "command":
|
|
268
|
+
return "credential helper";
|
|
269
|
+
case "not_needed":
|
|
270
|
+
return "no key needed";
|
|
271
|
+
case "missing":
|
|
272
|
+
return "needs a key";
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function pickModel(neosh: Neosh): Promise<void> {
|
|
277
|
+
const [creds, current, ascii, nerd] = await Promise.all([
|
|
278
|
+
neosh.agent.credentials().catch(() => [] as CredentialInfo[]),
|
|
279
|
+
neosh.agent.selection().catch(() => null),
|
|
280
|
+
// Defensively: a display setting that cannot be read must not stop the picker opening. It is
|
|
281
|
+
// decoration, and failing closed here would mean no way to change models at all.
|
|
282
|
+
neosh.opt.get<boolean>("ui.ascii_only").catch(() => false),
|
|
283
|
+
neosh.opt.get<boolean>("ui.nerd_font").catch(() => false),
|
|
284
|
+
]);
|
|
285
|
+
if (creds.length === 0) {
|
|
286
|
+
neosh.notify("no providers are configured", "warn");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const showPricing =
|
|
290
|
+
(await neosh.opt.get<boolean>("model.picker.show_pricing").catch(() => true)) ?? true;
|
|
291
|
+
const glyphs = { ascii: ascii ?? false, nerd: nerd ?? false };
|
|
292
|
+
|
|
293
|
+
// Grouped by what a turn spends. A plan you already pay for and a key billed per token are
|
|
294
|
+
// different decisions, and a flat list makes you read every row to tell which is which.
|
|
295
|
+
const order: AccountKind[] = ["plan", "api_key", "local"];
|
|
296
|
+
const rail: RailItem<CredentialInfo>[] = [];
|
|
297
|
+
for (const account of order) {
|
|
298
|
+
for (const c of creds.filter((x) => x.account === account)) {
|
|
299
|
+
rail.push({
|
|
300
|
+
mark: { text: markFor(c, glyphs), hl: c.brand?.hl ?? "Comment" },
|
|
301
|
+
label: c.display_name,
|
|
302
|
+
badge: badgeFor(c, glyphs),
|
|
303
|
+
group: headingFor(account),
|
|
304
|
+
// Selectable even when its driver is missing. Looking at what a provider offers is how you
|
|
305
|
+
// decide whether to install it, and a row you cannot land on is a question with nowhere to
|
|
306
|
+
// ask it. What cannot be chosen is a *model* on it, which is where the refusal belongs.
|
|
307
|
+
value: c,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const startAt = Math.max(0, rail.findIndex((r) => r.value.instance === current?.instance));
|
|
313
|
+
|
|
314
|
+
const chosen = await railPicker<CredentialInfo, ModelEntry>(neosh, {
|
|
315
|
+
title: "Model",
|
|
316
|
+
width: 88,
|
|
317
|
+
railWidth: 24,
|
|
318
|
+
height: 14,
|
|
319
|
+
rail,
|
|
320
|
+
railAt: startAt,
|
|
321
|
+
railLabel: "providers",
|
|
322
|
+
paneLabel: "models",
|
|
323
|
+
// Chords, not bare letters. Bare letters were nicer to read and made it impossible to type
|
|
324
|
+
// "sonnet" into the filter: the `s` signed you in instead. Anything the picker takes for
|
|
325
|
+
// itself is a letter the filter can never contain.
|
|
326
|
+
ownKeys: ["<C-s>", "<C-r>", "<C-a>", "<C-d>"],
|
|
327
|
+
hints: "↵ use ^S sign in ^R refresh ^A add ^D remove esc close",
|
|
328
|
+
placeholder: "nothing here yet — `s` to sign in, or `n` to name a model yourself",
|
|
329
|
+
async items(c) {
|
|
330
|
+
// Listed even when the driver is missing. What a provider offers is how you decide whether
|
|
331
|
+
// installing its CLI is worth the afternoon, and an empty pane answers that with silence.
|
|
332
|
+
const entries = await neosh.agent.listModels(c.instance).catch(() => [] as ModelEntry[]);
|
|
333
|
+
// Why nothing here can be chosen, at the top, where it will be read before the list it is
|
|
334
|
+
// about. `⨯` on the rail says *that* it is unavailable; this says what to do.
|
|
335
|
+
const blocked: PaneItem<ModelEntry>[] = c.driver_available
|
|
336
|
+
? []
|
|
337
|
+
: [{
|
|
338
|
+
label: "unavailable",
|
|
339
|
+
detail: describe(c.source),
|
|
340
|
+
disabled: true,
|
|
341
|
+
value: undefined as unknown as ModelEntry,
|
|
342
|
+
}];
|
|
343
|
+
// Anything the user named themselves goes next: they typed it because the list did not have
|
|
344
|
+
// it, and burying it under the list that did not have it would be a poor joke.
|
|
345
|
+
const named: PaneItem<ModelEntry>[] = (await custom(neosh, c.instance)).map((m) => ({
|
|
346
|
+
label: m.display_name,
|
|
347
|
+
badge: { text: "yours", hl: "Comment" },
|
|
348
|
+
disabled: !c.driver_available,
|
|
349
|
+
detail: m.id,
|
|
350
|
+
keywords: m.id,
|
|
351
|
+
value: { instance: c.instance, model: m } as ModelEntry,
|
|
352
|
+
}));
|
|
353
|
+
return blocked.concat(named).concat(entries.map((e) => ({
|
|
354
|
+
label: e.model.display_name,
|
|
355
|
+
badge: e.model.tier ? { text: tierLabel(e.model.tier), hl: tierHl(e.model.tier) } : undefined,
|
|
356
|
+
detail: describeModel(e, showPricing),
|
|
357
|
+
disabled: !c.driver_available,
|
|
358
|
+
keywords: `${e.model.id} ${e.model.family ?? ""}`,
|
|
359
|
+
// Last year's models are reachable and out of the way. You go looking for one to
|
|
360
|
+
// reproduce something, which is exactly when hiding it outright would be worst.
|
|
361
|
+
section: e.model.legacy ? "superseded" : undefined,
|
|
362
|
+
value: e,
|
|
363
|
+
})));
|
|
364
|
+
},
|
|
365
|
+
// Guarded, because not every row carries a model: the "why this is unavailable" row has no
|
|
366
|
+
// value at all, and reaching through it is how a picker crashes on open.
|
|
367
|
+
itemAt: (items) =>
|
|
368
|
+
Math.max(0, items.findIndex((i) => i.value?.model?.id === current?.model)),
|
|
369
|
+
async onKey(key, ctx) {
|
|
370
|
+
if (key.key.code.kind !== "char" || !key.key.mods.ctrl || key.key.mods.alt) return;
|
|
371
|
+
const c = ctx.rail;
|
|
372
|
+
const pressed = key.key.code.c.toLowerCase();
|
|
373
|
+
if (pressed === "s") {
|
|
374
|
+
if (!c) return "handled";
|
|
375
|
+
if (!c.accepts_key) {
|
|
376
|
+
neosh.notify(`${c.display_name}: ${describe(c.source)}`, "info");
|
|
377
|
+
return "handled";
|
|
378
|
+
}
|
|
379
|
+
return (await signIn(neosh, c.instance, c)) ? "reload" : "handled";
|
|
380
|
+
}
|
|
381
|
+
// Ask the endpoint again. Discovery is cached for the session, which is right — it is a
|
|
382
|
+
// network round trip per provider — and wrong the moment you add a key, get given access to
|
|
383
|
+
// a new model, or start the local server the list was empty because of.
|
|
384
|
+
if (pressed === "r") {
|
|
385
|
+
if (!c) return "handled";
|
|
386
|
+
neosh.notify(`${c.display_name}: asking again…`);
|
|
387
|
+
await neosh.agent.listModels(c.instance, { refresh: true }).catch(() => []);
|
|
388
|
+
return "reload";
|
|
389
|
+
}
|
|
390
|
+
// A model the catalogue has not heard of. Vendors ship faster than any list is updated, and
|
|
391
|
+
// "I know the id, let me type it" is the difference between waiting for a release and not.
|
|
392
|
+
if (pressed === "a") {
|
|
393
|
+
if (!c) return "handled";
|
|
394
|
+
return (await addModel(neosh, c)) ? "reload" : "handled";
|
|
395
|
+
}
|
|
396
|
+
// And out again. Anything you can add you must be able to take back — a typo in an id is the
|
|
397
|
+
// most likely thing to be in this list, and a wrong entry that cannot be removed is a row
|
|
398
|
+
// you have to read past forever.
|
|
399
|
+
if (pressed === "d") {
|
|
400
|
+
const id = ctx.item?.model?.id;
|
|
401
|
+
if (!c || !id) return "handled";
|
|
402
|
+
if (!(await custom(neosh, c.instance)).some((m) => m.id === id)) {
|
|
403
|
+
neosh.notify(
|
|
404
|
+
`${id} is in ${c.display_name}'s own catalogue — there is nothing of yours to remove`,
|
|
405
|
+
"info",
|
|
406
|
+
);
|
|
407
|
+
return "handled";
|
|
408
|
+
}
|
|
409
|
+
return (await removeModel(neosh, c.instance, id)) ? "reload" : "handled";
|
|
410
|
+
}
|
|
411
|
+
return undefined;
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
if (!chosen) return;
|
|
415
|
+
|
|
416
|
+
if (!creds.find((c) => c.instance === chosen.instance)?.source.kind.startsWith("plan")) {
|
|
417
|
+
const cred = creds.find((c) => c.instance === chosen.instance);
|
|
418
|
+
if (cred && cred.source.kind === "missing" && cred.accepts_key) {
|
|
419
|
+
if (!(await signIn(neosh, chosen.instance, cred))) return;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
await use(neosh, chosen, current);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Move one rung along the capability ladder, within the provider you are already using.
|
|
427
|
+
*
|
|
428
|
+
* The provider is held fixed on purpose. "Give me something cheaper" is a question about the model;
|
|
429
|
+
* answering it by also moving you to a different endpoint — with different billing, possibly a
|
|
430
|
+
* different key — would be answering a question nobody asked.
|
|
431
|
+
*
|
|
432
|
+
* No wrapping. At the top, `upgrade` says so rather than dropping you to the cheapest thing in the
|
|
433
|
+
* catalogue, which is the kind of surprise that stops people using a key at all.
|
|
434
|
+
*/
|
|
435
|
+
async function step(neosh: Neosh, delta: number): Promise<void> {
|
|
436
|
+
const current = await neosh.agent.selection().catch(() => null);
|
|
437
|
+
if (!current) {
|
|
438
|
+
neosh.notify("no model is selected", "warn");
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const entries = await neosh.agent.listModels(current.instance).catch(() => [] as ModelEntry[]);
|
|
442
|
+
const here = entries.find((e) => e.model.id === current.model);
|
|
443
|
+
const from = here?.model.tier;
|
|
444
|
+
if (!from) {
|
|
445
|
+
neosh.notify(`${current.model} is not on the ladder — nothing to step to`, "warn");
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
const rungs: ModelTier[] = ["fast", "balanced", "frontier"];
|
|
449
|
+
const at = rungs.indexOf(from) + delta;
|
|
450
|
+
const want = rungs[at];
|
|
451
|
+
if (!want) {
|
|
452
|
+
neosh.notify(delta > 0 ? "already the most capable one here" : "already the quickest one here");
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const next = best(entries.filter((e) => e.model.tier === want));
|
|
456
|
+
if (!next) {
|
|
457
|
+
neosh.notify(`${current.instance} has nothing on the ${tierLabel(want)} rung`, "warn");
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
await use(neosh, next, current);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Switch to the current model in a named line — `model.line opus`.
|
|
465
|
+
*
|
|
466
|
+
* A *line* is a product across versions; the best one in it is the newest that has not been
|
|
467
|
+
* superseded. That is the thing people mean by "use Opus": not a pinned id that goes stale, and not
|
|
468
|
+
* a picker they have to read.
|
|
469
|
+
*
|
|
470
|
+
* Looks in the provider you are on first, then anywhere reachable, so this works whether your Opus
|
|
471
|
+
* comes from a plan or a key.
|
|
472
|
+
*/
|
|
473
|
+
async function useLine(neosh: Neosh, line: string | undefined): Promise<void> {
|
|
474
|
+
if (!line) {
|
|
475
|
+
neosh.notify("model.line needs a line — try `model.line opus`", "warn");
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const current = await neosh.agent.selection().catch(() => null);
|
|
479
|
+
const wanted = line.trim().toLowerCase();
|
|
480
|
+
const matching = (entries: ModelEntry[]) =>
|
|
481
|
+
entries.filter((e) => (e.model.family ?? "").toLowerCase() === wanted);
|
|
482
|
+
|
|
483
|
+
let found = current
|
|
484
|
+
? best(matching(await neosh.agent.listModels(current.instance).catch(() => [])))
|
|
485
|
+
: undefined;
|
|
486
|
+
if (!found) found = best(matching(await neosh.agent.listModels().catch(() => [])));
|
|
487
|
+
if (!found) {
|
|
488
|
+
neosh.notify(`nothing reachable is in the "${line}" line`, "warn");
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
await use(neosh, found, current);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* The one to use out of a set: current before superseded, then the most capable.
|
|
496
|
+
*
|
|
497
|
+
* Order within a rung is the catalogue's, which lists newest first — so "current" needs no version
|
|
498
|
+
* parsing, which is the thing that goes wrong the moment a vendor renames something.
|
|
499
|
+
*/
|
|
500
|
+
function best(entries: ModelEntry[]): ModelEntry | undefined {
|
|
501
|
+
const live = entries.filter((e) => !e.model.legacy);
|
|
502
|
+
const pool = live.length > 0 ? live : entries;
|
|
503
|
+
const rank = (e: ModelEntry) =>
|
|
504
|
+
e.model.tier === "frontier" ? 2 : e.model.tier === "balanced" ? 1 : 0;
|
|
505
|
+
return pool.reduce<ModelEntry | undefined>(
|
|
506
|
+
(win, e) => (win === undefined || rank(e) > rank(win) ? e : win),
|
|
507
|
+
undefined,
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Put a selection into effect, carrying over the option values the new model also has. */
|
|
512
|
+
async function use(
|
|
513
|
+
neosh: Neosh,
|
|
514
|
+
chosen: ModelEntry,
|
|
515
|
+
current: ModelSelection | null,
|
|
516
|
+
): Promise<void> {
|
|
517
|
+
// Switching between two thinking models should keep your effort level; switching to one without
|
|
518
|
+
// the knob must drop it rather than send a value the driver will reject.
|
|
519
|
+
const descriptors = chosen.model.capabilities?.option_descriptors ?? [];
|
|
520
|
+
const keep = (current?.options ?? []).filter((o) => descriptors.some((d) => d.id === o.id));
|
|
521
|
+
await neosh.agent.setSelection({
|
|
522
|
+
instance: chosen.instance,
|
|
523
|
+
model: chosen.model.id,
|
|
524
|
+
options: keep,
|
|
525
|
+
});
|
|
526
|
+
await refreshFooter();
|
|
527
|
+
neosh.notify(`model: ${chosen.instance}/${chosen.model.display_name}`);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function headingFor(account: AccountKind): string {
|
|
531
|
+
if (account === "plan") return "PLANS";
|
|
532
|
+
return account === "api_key" ? "API KEYS" : "LOCAL";
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* The mark for a provider, at whatever fidelity this terminal has.
|
|
537
|
+
*
|
|
538
|
+
* Three levels rather than one, because a terminal is not one thing: a Nerd Font has a real brand
|
|
539
|
+
* glyph, a bare Unicode terminal has geometry, `ui.ascii_only` has a letter. Choosing badly is
|
|
540
|
+
* worse than choosing plainly — a box glyph where a logo should be reads as a broken program.
|
|
541
|
+
*/
|
|
542
|
+
function markFor(c: CredentialInfo, glyphs: { ascii: boolean; nerd: boolean }): string {
|
|
543
|
+
const b = c.brand;
|
|
544
|
+
if (!b) return glyphs.ascii ? "?" : "·";
|
|
545
|
+
if (glyphs.ascii) return b.ascii;
|
|
546
|
+
return (glyphs.nerd && b.nerd) || b.mark;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** The state marker at the right edge of the rail. One column, four meanings. */
|
|
550
|
+
function badgeFor(
|
|
551
|
+
c: CredentialInfo,
|
|
552
|
+
glyphs: { ascii: boolean },
|
|
553
|
+
): { text: string; hl: string } | undefined {
|
|
554
|
+
// A plan whose CLI is not installed is not broken — it is one `brew install` away, and the
|
|
555
|
+
// detail line says which. `⨯` is for a provider nothing can serve at all.
|
|
556
|
+
if (!c.driver_available) {
|
|
557
|
+
const fixable = c.source.kind === "plan_missing";
|
|
558
|
+
return { text: fixable ? "!" : glyphs.ascii ? "-" : "⨯", hl: fixable ? "Account.Missing" : "Comment" };
|
|
559
|
+
}
|
|
560
|
+
switch (c.source.kind) {
|
|
561
|
+
case "missing":
|
|
562
|
+
case "plan_missing":
|
|
563
|
+
return { text: "!", hl: "Account.Missing" };
|
|
564
|
+
case "not_needed":
|
|
565
|
+
return undefined;
|
|
566
|
+
case "plan":
|
|
567
|
+
return { text: glyphs.ascii ? "*" : "✓", hl: "Account.Plan" };
|
|
568
|
+
default:
|
|
569
|
+
return { text: glyphs.ascii ? "+" : "✓", hl: "Account.Key" };
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** The rung, as a word. Read down this column and you see the shape of what a provider offers. */
|
|
574
|
+
function tierLabel(tier: ModelTier): string {
|
|
575
|
+
if (tier === "frontier") return "Frontier";
|
|
576
|
+
return tier === "balanced" ? "Balanced" : "Fast";
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function tierHl(tier: ModelTier): string {
|
|
580
|
+
if (tier === "frontier") return "Accent";
|
|
581
|
+
return tier === "balanced" ? "Status.Working" : "Status.Done";
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** What a model row says about itself, beyond its name and rung. */
|
|
585
|
+
function describeModel(e: ModelEntry, showPricing: boolean): string {
|
|
586
|
+
const bits: string[] = [];
|
|
587
|
+
if (e.model.tagline) bits.push(e.model.tagline);
|
|
588
|
+
if (showPricing && e.model.pricing) {
|
|
589
|
+
bits.push(`$${e.model.pricing.input_per_mtok}/$${e.model.pricing.output_per_mtok}`);
|
|
590
|
+
}
|
|
591
|
+
if (bits.length === 0) bits.push(String(e.model.id));
|
|
592
|
+
return bits.join(" · ");
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Models the user named themselves, for one provider.
|
|
597
|
+
*
|
|
598
|
+
* Kept in plugin state rather than in configuration, because it is a note about *this machine's*
|
|
599
|
+
* access — "my org has early access to this id" — not a decision worth committing to a repository.
|
|
600
|
+
* Stored per instance so the same id can mean different things on two endpoints.
|
|
601
|
+
*/
|
|
602
|
+
async function custom(neosh: Neosh, instance: string): Promise<ModelInfo[]> {
|
|
603
|
+
const all = await neosh.state.get<Record<string, ModelInfo[]>>("custom").catch(() => null);
|
|
604
|
+
return all?.[instance] ?? [];
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Add a model the catalogue has never heard of.
|
|
609
|
+
*
|
|
610
|
+
* Two questions, because the id and the name are different things: the id is what goes on the wire
|
|
611
|
+
* and has to be exact, and the name is what you will read in a list forever afterwards. Defaulting
|
|
612
|
+
* the name to the id means answering the second one is optional.
|
|
613
|
+
*
|
|
614
|
+
* Nothing validates the id — nothing here *can*. Whether an endpoint serves it is a question only
|
|
615
|
+
* the endpoint can answer, and it answers it on the first turn.
|
|
616
|
+
*/
|
|
617
|
+
async function addModel(neosh: Neosh, c: CredentialInfo): Promise<boolean> {
|
|
618
|
+
const id = await prompt(neosh, `Model id on ${c.display_name}`, { width: 60 });
|
|
619
|
+
if (!id || !id.trim()) return false;
|
|
620
|
+
const name = await prompt(neosh, "Show it as", { initial: id.trim(), width: 60 });
|
|
621
|
+
|
|
622
|
+
const all =
|
|
623
|
+
(await neosh.state.get<Record<string, ModelInfo[]>>("custom").catch(() => null)) ?? {};
|
|
624
|
+
const mine = (all[c.instance] ?? []).filter((m) => m.id !== id.trim());
|
|
625
|
+
mine.unshift({
|
|
626
|
+
id: id.trim(),
|
|
627
|
+
display_name: (name ?? id).trim() || id.trim(),
|
|
628
|
+
capabilities: {
|
|
629
|
+
tools: true,
|
|
630
|
+
vision: false,
|
|
631
|
+
streaming: true,
|
|
632
|
+
thinking: false,
|
|
633
|
+
prompt_caching: false,
|
|
634
|
+
option_descriptors: [],
|
|
635
|
+
},
|
|
636
|
+
legacy: false,
|
|
637
|
+
} as ModelInfo);
|
|
638
|
+
all[c.instance] = mine;
|
|
639
|
+
await neosh.state.set("custom", all);
|
|
640
|
+
neosh.notify(`added ${c.instance}/${id.trim()}`);
|
|
641
|
+
return true;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Take back a model you named yourself.
|
|
646
|
+
*
|
|
647
|
+
* Only ever removes from your own list. A model the provider serves is not yours to delete, and
|
|
648
|
+
* the refusal says which of the two this row is rather than silently doing nothing.
|
|
649
|
+
*/
|
|
650
|
+
async function removeModel(neosh: Neosh, instance: string, id: string): Promise<boolean> {
|
|
651
|
+
const current = await neosh.agent.selection().catch(() => null);
|
|
652
|
+
const inUse = current?.instance === instance && current.model === id;
|
|
653
|
+
const ok = await confirmDestructive(neosh, `Remove ${id} from ${instance}?`, {
|
|
654
|
+
yes: "Remove",
|
|
655
|
+
no: "Keep",
|
|
656
|
+
detail: [
|
|
657
|
+
"You added this one by hand, so nothing else will offer it back.",
|
|
658
|
+
...(inUse ? ["It is the model in use — you will have to pick another before sending."] : []),
|
|
659
|
+
],
|
|
660
|
+
});
|
|
661
|
+
if (!ok) return false;
|
|
662
|
+
|
|
663
|
+
const all =
|
|
664
|
+
(await neosh.state.get<Record<string, ModelInfo[]>>("custom").catch(() => null)) ?? {};
|
|
665
|
+
const mine = (all[instance] ?? []).filter((m) => m.id !== id);
|
|
666
|
+
if (mine.length === 0) delete all[instance];
|
|
667
|
+
else all[instance] = mine;
|
|
668
|
+
await neosh.state.set("custom", all);
|
|
669
|
+
neosh.notify(`removed ${instance}/${id}`);
|
|
670
|
+
|
|
671
|
+
// If it was the one in use, the conversation is now pointed at a model nothing lists. Said again
|
|
672
|
+
// here rather than only in the dialog: the dialog is gone by the time it matters.
|
|
673
|
+
if (inUse) {
|
|
674
|
+
neosh.notify(`${id} was the model in use — pick another before sending`, "warn");
|
|
675
|
+
}
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Collect a key for one instance.
|
|
681
|
+
*
|
|
682
|
+
* The typing happens in the host — this call does not settle until the prompt closes, and what
|
|
683
|
+
* comes back is whether a key was stored, never the key. Afterwards the endpoint is re-queried,
|
|
684
|
+
* because the reason it listed nothing a moment ago was that it could not authenticate.
|
|
685
|
+
*/
|
|
686
|
+
async function signIn(
|
|
687
|
+
neosh: Neosh,
|
|
688
|
+
instance: string,
|
|
689
|
+
cred: CredentialInfo | undefined,
|
|
690
|
+
): Promise<boolean> {
|
|
691
|
+
if (cred && !cred.accepts_key) {
|
|
692
|
+
neosh.notify(`${cred.display_name} signs in on its own — there is nowhere to put a key`, "warn");
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
const stored = await neosh.agent
|
|
696
|
+
.setCredential(instance, { replace: true })
|
|
697
|
+
.catch((e: unknown) => {
|
|
698
|
+
neosh.notify(String(e), "warn");
|
|
699
|
+
return false;
|
|
700
|
+
});
|
|
701
|
+
if (!stored) return false;
|
|
702
|
+
await neosh.agent.listModels(instance, { refresh: true }).catch(() => []);
|
|
703
|
+
return true;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Every configured provider and the state of its account.
|
|
708
|
+
*
|
|
709
|
+
* The same information the model picker's rail carries, as a page you can go to directly — for
|
|
710
|
+
* replacing a key, forgetting one, or finding out why a provider you expected is not offering
|
|
711
|
+
* anything.
|
|
712
|
+
*/
|
|
713
|
+
async function pickProvider(neosh: Neosh): Promise<void> {
|
|
714
|
+
const rows = await neosh.agent.credentials();
|
|
715
|
+
if (rows.length === 0) {
|
|
716
|
+
neosh.notify("no providers are configured", "warn");
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
// Unavailable drivers are listed rather than hidden. The `claude` CLI not being installed is the
|
|
720
|
+
// single most likely reason a subscription does not show up, and a provider that has silently
|
|
721
|
+
// vanished from the list is a question with nowhere to ask it.
|
|
722
|
+
const order = ["plan", "api_key", "local"] as const;
|
|
723
|
+
const sorted = order.flatMap((account) =>
|
|
724
|
+
rows
|
|
725
|
+
.filter((c) => c.account === account)
|
|
726
|
+
.sort((a, b) => Number(b.driver_available) - Number(a.driver_available)),
|
|
727
|
+
);
|
|
728
|
+
const chosen = await picker(
|
|
729
|
+
neosh,
|
|
730
|
+
sorted.map((c) => ({
|
|
731
|
+
label: c.display_name,
|
|
732
|
+
detail: c.driver_available
|
|
733
|
+
? `${headingFor(c.account).toLowerCase()} · ${describe(c.source)}`
|
|
734
|
+
: `${c.instance} · its driver is not available here`,
|
|
735
|
+
keywords: c.instance,
|
|
736
|
+
value: c,
|
|
737
|
+
})),
|
|
738
|
+
{ title: "Providers", width: 76 },
|
|
739
|
+
);
|
|
740
|
+
if (!chosen) return;
|
|
741
|
+
|
|
742
|
+
if (!chosen.driver_available) {
|
|
743
|
+
neosh.notify(
|
|
744
|
+
chosen.driver === "claude-cli"
|
|
745
|
+
? "the `claude` CLI is not on your PATH — install it to use your Claude subscription"
|
|
746
|
+
: `${chosen.display_name}: no plugin provides the "${chosen.driver}" driver`,
|
|
747
|
+
"warn",
|
|
748
|
+
);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (chosen.source.kind === "plan_missing") {
|
|
752
|
+
const { program, hint } = chosen.source;
|
|
753
|
+
neosh.notify(
|
|
754
|
+
hint
|
|
755
|
+
? `${program} is not installed. Install it, then run \`${hint}\`.`
|
|
756
|
+
: `${program} is not installed.`,
|
|
757
|
+
"warn",
|
|
758
|
+
);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (!chosen.accepts_key) {
|
|
762
|
+
neosh.notify(`${chosen.display_name}: ${describe(chosen.source)}`, "info");
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
if (chosen.source.kind === "missing") {
|
|
766
|
+
await signIn(neosh, chosen.instance, chosen);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// It already has one, so the two things you could mean are "replace it" and "get rid of it".
|
|
771
|
+
const action = await picker<"replace" | "forget">(
|
|
772
|
+
neosh,
|
|
773
|
+
[
|
|
774
|
+
{ label: "Enter a new key", detail: `replaces ${describe(chosen.source)}`, value: "replace" },
|
|
775
|
+
{ label: "Forget the stored key", detail: "removes it from the keychain too", value: "forget" },
|
|
776
|
+
],
|
|
777
|
+
{ title: chosen.display_name, width: 64, height: 2 },
|
|
778
|
+
);
|
|
779
|
+
if (action === null) return;
|
|
780
|
+
if (action === "replace") {
|
|
781
|
+
await signIn(neosh, chosen.instance, chosen);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
const ok = await confirmDestructive(neosh, `Forget the key for ${chosen.display_name}?`, {
|
|
785
|
+
yes: "Forget",
|
|
786
|
+
no: "Keep",
|
|
787
|
+
detail: [
|
|
788
|
+
"It goes from the keychain too, so this machine will not have it again until you paste it.",
|
|
789
|
+
`${chosen.display_name} will stop offering models until you sign in.`,
|
|
790
|
+
],
|
|
791
|
+
});
|
|
792
|
+
if (!ok) return;
|
|
793
|
+
await neosh.agent.forgetCredential(chosen.instance);
|
|
794
|
+
await refreshFooter();
|
|
795
|
+
}
|
|
796
|
+
|
package/options.ts
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything the chosen model can be told, on one surface.
|
|
3
|
+
*
|
|
4
|
+
* # Why this is not a picker
|
|
5
|
+
*
|
|
6
|
+
* It used to be two: a list of knobs, and then a list of that knob's values. Which meant the answer
|
|
7
|
+
* to "what can I change about this model" was three keystrokes and a modal you had to back out of
|
|
8
|
+
* to see the other knob — and, on the common path, no answer at all: with exactly one knob the
|
|
9
|
+
* outer list was skipped, so `^E` opened a list of effort levels and nothing anywhere said that
|
|
10
|
+
* fast mode or the context window existed. A setting you cannot see is a setting you do not have.
|
|
11
|
+
*
|
|
12
|
+
* So: every knob at once, one row each, values laid out along the row with the current one lit.
|
|
13
|
+
* `h`/`l` moves along a row and `j`/`k` between them — the arrows do the same — and what you are
|
|
14
|
+
* looking at *is* the state: there is no "current value" to go and read somewhere else.
|
|
15
|
+
*
|
|
16
|
+
* # Why it is a panel and not a private key handler
|
|
17
|
+
*
|
|
18
|
+
* Because the surface rule says so, and the sidebar proved why: every key here is an ordinary binding
|
|
19
|
+
* pointed at a named command, scoped to this buffer's **kind**. `^Z` lists them, `^K` runs them,
|
|
20
|
+
* and `init.ts` can move any of them. A `switch` on the key inside this file is exactly the thing
|
|
21
|
+
* that rule replaced.
|
|
22
|
+
*
|
|
23
|
+
* # What is drawn, and why it moves
|
|
24
|
+
*
|
|
25
|
+
* A value's prominence is its position on its own ladder — `Option.Step1`…`Step5`, which is weight
|
|
26
|
+
* rather than hue, because a reasoning level is a *scale* and the palette keeps its three hues for
|
|
27
|
+
* states. The exception is a level that is not on the ladder at all: one bought by putting a word
|
|
28
|
+
* in the message rather than by sending a parameter. That gets `Option.Beyond`, the one group in
|
|
29
|
+
* the theme that animates a rainbow — because what it has to say is "this is not one of the normal
|
|
30
|
+
* settings", and no amount of amber says that.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type {
|
|
34
|
+
Disposable,
|
|
35
|
+
FloatOptions,
|
|
36
|
+
ModelEntry,
|
|
37
|
+
ModelSelection,
|
|
38
|
+
Neosh,
|
|
39
|
+
OptionSelection,
|
|
40
|
+
ProviderOptionDescriptor,
|
|
41
|
+
} from "@neosh/api";
|
|
42
|
+
import { byteLength, clipToWidth, padToWidth, width } from "@neosh/api";
|
|
43
|
+
|
|
44
|
+
const KIND = "neosh.model.options";
|
|
45
|
+
const NS = "neosh.model.options";
|
|
46
|
+
|
|
47
|
+
/** One value a knob can take. */
|
|
48
|
+
interface Choice {
|
|
49
|
+
id: string;
|
|
50
|
+
label: string;
|
|
51
|
+
detail: string;
|
|
52
|
+
/** Applied by saying it in the message rather than by sending it. */
|
|
53
|
+
spoken: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One knob, flattened so a boolean and a select draw identically. */
|
|
57
|
+
interface Row {
|
|
58
|
+
id: string;
|
|
59
|
+
label: string;
|
|
60
|
+
description: string;
|
|
61
|
+
choices: Choice[];
|
|
62
|
+
/** Which choice is in effect. */
|
|
63
|
+
at: number;
|
|
64
|
+
/** Booleans come back as flags, selects as strings. */
|
|
65
|
+
boolean: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface Sheet {
|
|
69
|
+
win: number;
|
|
70
|
+
buf: number;
|
|
71
|
+
ns: number;
|
|
72
|
+
rows: Row[];
|
|
73
|
+
/** Which row the keys act on. */
|
|
74
|
+
cursor: number;
|
|
75
|
+
selection: ModelSelection;
|
|
76
|
+
title: string;
|
|
77
|
+
/** Inside the border. What the description line has to fit in. */
|
|
78
|
+
inner: number;
|
|
79
|
+
/** What the float was opened with, so `configure` can change one field without losing the rest. */
|
|
80
|
+
config: FloatOptions;
|
|
81
|
+
/** Whether anything spoken is in effect, so the border is only redone when the answer changes. */
|
|
82
|
+
beyond: boolean;
|
|
83
|
+
close(): Promise<void>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let sheet: Sheet | null = null;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The keys, in the order they were pressed.
|
|
90
|
+
*
|
|
91
|
+
* Every verb here is several round trips — set the lines, clear the marks, set a dozen more, hand
|
|
92
|
+
* the selection back to the agent — and the host dispatches each key as its own command *without
|
|
93
|
+
* waiting for the last one to finish*. Three keys in flight at once therefore finish in length
|
|
94
|
+
* order rather than press order, and the shortest verb is `done`: press `→ → ⏎` quickly and the
|
|
95
|
+
* panel shuts first, leaving two redraws to fail against a window that is already gone. Chaining
|
|
96
|
+
* them costs nothing — they were serial in effect anyway, because they all touch one buffer — and
|
|
97
|
+
* it is the same reason the sidebar serialises its own redraws.
|
|
98
|
+
*/
|
|
99
|
+
let queue: Promise<void> = Promise.resolve();
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Turn what a driver said into rows.
|
|
103
|
+
*
|
|
104
|
+
* A boolean becomes a two-value row, because "off / on" laid out like every other ladder is one
|
|
105
|
+
* fewer shape to learn than a checkbox that behaves differently from the row above it — and it
|
|
106
|
+
* gives the spoken switches somewhere to put their word.
|
|
107
|
+
*/
|
|
108
|
+
function rowsFrom(
|
|
109
|
+
descriptors: readonly ProviderOptionDescriptor[],
|
|
110
|
+
chosen: readonly OptionSelection[],
|
|
111
|
+
): Row[] {
|
|
112
|
+
return descriptors.map((d) => {
|
|
113
|
+
const value = chosen.find((o) => o.id === d.id)?.value;
|
|
114
|
+
if (d.type === "boolean") {
|
|
115
|
+
const on = typeof value === "boolean" ? value : d.current_value;
|
|
116
|
+
// The word, if this switch is one. A switch that is a word is not a parameter at all — see
|
|
117
|
+
// `prompt_injected_word` — so it wears the same mark as a spoken level.
|
|
118
|
+
const spoken = typeof d.prompt_injected_word === "string";
|
|
119
|
+
return {
|
|
120
|
+
id: d.id,
|
|
121
|
+
label: d.label,
|
|
122
|
+
description: d.description ?? "",
|
|
123
|
+
choices: [
|
|
124
|
+
{ id: "off", label: "off", detail: "", spoken: false },
|
|
125
|
+
{ id: "on", label: "on", detail: "", spoken },
|
|
126
|
+
],
|
|
127
|
+
at: on ? 1 : 0,
|
|
128
|
+
boolean: true,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const injected = d.prompt_injected_values ?? [];
|
|
132
|
+
const current =
|
|
133
|
+
(typeof value === "string" ? value : undefined) ??
|
|
134
|
+
d.current_value ??
|
|
135
|
+
d.options.find((o) => o.is_default)?.id;
|
|
136
|
+
const choices = d.options.map((o) => ({
|
|
137
|
+
id: o.id,
|
|
138
|
+
label: o.label,
|
|
139
|
+
// What the *driver* said about this level, and nothing invented. A value with nothing to
|
|
140
|
+
// say falls through to what the knob says, which is better than a row that reads "the
|
|
141
|
+
// default" where the sentence explaining the control used to be.
|
|
142
|
+
detail: o.description ?? "",
|
|
143
|
+
spoken: injected.includes(o.id),
|
|
144
|
+
}));
|
|
145
|
+
return {
|
|
146
|
+
id: d.id,
|
|
147
|
+
label: d.label,
|
|
148
|
+
description: d.description ?? "",
|
|
149
|
+
choices,
|
|
150
|
+
at: Math.max(0, choices.findIndex((c) => c.id === current)),
|
|
151
|
+
boolean: false,
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Which step of the five-rung ramp a value sits on.
|
|
158
|
+
*
|
|
159
|
+
* Computed from where it is in *its own* ladder rather than from its name, because the ladders are
|
|
160
|
+
* different lengths — five levels on Claude, three on an aggregator, two on Gemini — and a `high`
|
|
161
|
+
* that is the top rung of one and the middle of another should not be drawn the same weight.
|
|
162
|
+
*/
|
|
163
|
+
function step(at: number, of: number): string {
|
|
164
|
+
if (of <= 1) return "Option.Step3";
|
|
165
|
+
const rung = Math.round((at / (of - 1)) * 4) + 1;
|
|
166
|
+
return `Option.Step${Math.min(5, Math.max(1, rung))}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The group a value is drawn in, given whether it is the one in effect.
|
|
171
|
+
*
|
|
172
|
+
* The ramp is measured over the levels that are *sendable*, so a ladder with a spoken level bolted
|
|
173
|
+
* past the top of it does not compress: five rungs stay five rungs, and the word on the end is
|
|
174
|
+
* drawn as the thing off the scale that it is.
|
|
175
|
+
*/
|
|
176
|
+
function groupFor(row: Row, at: number, live: boolean, focused: boolean): string {
|
|
177
|
+
if (!live) return "Option.Unset";
|
|
178
|
+
const choice = row.choices[at]!;
|
|
179
|
+
if (choice.spoken) return "Option.Beyond";
|
|
180
|
+
// The row the keys are on says so, and says it where you are looking: at the value they would
|
|
181
|
+
// move. A ladder with one rung lit reads identically whether or not `←→` do anything to it —
|
|
182
|
+
// which is how a live control came to look like a summary, and why `↵` on it felt like a key
|
|
183
|
+
// that does nothing. The ramp is still on every other row, where it is a statement about the
|
|
184
|
+
// setting rather than about the cursor.
|
|
185
|
+
if (focused) return "Option.Cursor";
|
|
186
|
+
// A switch is the ends of the scale and nothing in between, said explicitly rather than left to
|
|
187
|
+
// the ramp: a spoken switch has one ordinary value and one word, and a ladder of length one puts
|
|
188
|
+
// its only rung in the middle — so `off` on the switch beside it came out brighter than `off` on
|
|
189
|
+
// an ordinary one, for no reason a reader could ever recover.
|
|
190
|
+
if (row.boolean) return at === 0 ? "Option.Step1" : "Option.Step5";
|
|
191
|
+
const ladder = row.choices.filter((c) => !c.spoken);
|
|
192
|
+
return step(ladder.findIndex((c) => c.id === choice.id), ladder.length);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** One row's worth of text, and where every value landed in it. */
|
|
196
|
+
interface Painted {
|
|
197
|
+
text: string;
|
|
198
|
+
/** `[start, end]` byte offsets, one per choice. */
|
|
199
|
+
spans: [number, number][];
|
|
200
|
+
/** The two chevrons, same shape, so the caller can colour them by whether they can move. */
|
|
201
|
+
rails: [[number, number], [number, number]];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A row, with the rail that says it is one.
|
|
206
|
+
*
|
|
207
|
+
* The chevrons are the whole reason this function changed. Every row here is a horizontal control
|
|
208
|
+
* and nothing on screen said so: `↵` was the only key that visibly did anything, it means "done",
|
|
209
|
+
* and a panel whose one working key closes it is a panel that does nothing. `‹` and `›` are on
|
|
210
|
+
* every row — the shape is learnt once — and they light on the row the keys act on, dimming at
|
|
211
|
+
* whichever end you have reached, so where you are on the ladder is readable without counting.
|
|
212
|
+
*/
|
|
213
|
+
function paint(row: Row, labelWidth: number, mark: string, glyph: string): Painted {
|
|
214
|
+
let text = `${mark}${padToWidth(row.label, labelWidth)} `;
|
|
215
|
+
const leftStart = byteLength(text);
|
|
216
|
+
text += "‹";
|
|
217
|
+
const left: [number, number] = [leftStart, byteLength(text)];
|
|
218
|
+
const spans: [number, number][] = [];
|
|
219
|
+
for (const choice of row.choices) {
|
|
220
|
+
const label = choice.spoken ? `${glyph}${choice.label}` : choice.label;
|
|
221
|
+
const start = byteLength(text);
|
|
222
|
+
text += ` ${label} `;
|
|
223
|
+
spans.push([start, byteLength(text)]);
|
|
224
|
+
}
|
|
225
|
+
const rightStart = byteLength(text);
|
|
226
|
+
text += "›";
|
|
227
|
+
return { text, spans, rails: [left, [rightStart, byteLength(text)]] };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function draw(neosh: Neosh, s: Sheet, hints: string): Promise<void> {
|
|
231
|
+
// The panel this frame is about may have been dismissed while the frame was being computed —
|
|
232
|
+
// every line of it is a round trip. Drawing into a window that is gone is this widget racing
|
|
233
|
+
// itself, not something the user did wrong.
|
|
234
|
+
if (sheet !== s) return;
|
|
235
|
+
const glyph = (await neosh.opt.get<boolean>("ui.ascii_only").catch(() => false)) ? "*" : "✦ ";
|
|
236
|
+
const labelWidth = Math.max(...s.rows.map((r) => width(r.label)));
|
|
237
|
+
const painted = s.rows.map((r, i) => paint(r, labelWidth, i === s.cursor ? "❯ " : " ", glyph));
|
|
238
|
+
|
|
239
|
+
const here = s.rows[s.cursor];
|
|
240
|
+
// A description line that appeared and disappeared with the cursor would move every row below it
|
|
241
|
+
// on every keystroke, so it has a permanent home at the foot. What it says is whatever the driver
|
|
242
|
+
// said about the value under the cursor, falling back to what it said about the knob.
|
|
243
|
+
// Clipped rather than allowed to size the float: this line changes with every keystroke, and a
|
|
244
|
+
// panel that got wider when the cursor moved onto the row with the long sentence would be a panel
|
|
245
|
+
// that never stops moving.
|
|
246
|
+
const said = clipToWidth(here?.choices[here.at]?.detail || here?.description || "", s.inner - 3);
|
|
247
|
+
const lines = [...painted.map((p) => p.text), "", ` ${said}`, ` ${hints}`];
|
|
248
|
+
await neosh.buf.setLines(s.buf, 0, -1, lines);
|
|
249
|
+
|
|
250
|
+
// The whole surface says when the next turn is not an ordinary one. A word in the message is
|
|
251
|
+
// invisible everywhere else — it never reaches the transcript, and the panel it was chosen in
|
|
252
|
+
// will have been shut for an hour by the time it matters — so while one is in effect the border
|
|
253
|
+
// carries the same rainbow the value does. Reconfigured only when the answer *changes*: this is
|
|
254
|
+
// a round trip, and one per keystroke on a control that already makes several is how a panel
|
|
255
|
+
// starts lagging behind the keys being pressed. Clock-driven rather than a one-shot, so it is
|
|
256
|
+
// safe against a redraw that mints every mark again.
|
|
257
|
+
const beyond = s.rows.some((r) => r.choices[r.at]?.spoken);
|
|
258
|
+
if (beyond !== s.beyond) {
|
|
259
|
+
s.beyond = beyond;
|
|
260
|
+
await neosh.float
|
|
261
|
+
.configure(s.win, { ...s.config, borderHl: beyond ? "Option.Beyond" : undefined })
|
|
262
|
+
.catch(() => {});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Cleared before anything is drawn, every frame. A mark clamps rather than dies when the line
|
|
266
|
+
// under it is replaced, so a row rewritten on every keypress otherwise accumulates them and ends
|
|
267
|
+
// up painted by the narrowest one from three keystrokes ago.
|
|
268
|
+
await neosh.ns.clear(s.ns, s.buf);
|
|
269
|
+
for (let i = 0; i < s.rows.length; i++) {
|
|
270
|
+
const row = s.rows[i]!;
|
|
271
|
+
const p = painted[i]!;
|
|
272
|
+
if (i === s.cursor) {
|
|
273
|
+
await neosh.ns.mark(s.ns, s.buf, i, 0, { hlGroup: "Comment", endCol: byteLength("❯ ") });
|
|
274
|
+
}
|
|
275
|
+
await neosh.ns.mark(s.ns, s.buf, i, byteLength(i === s.cursor ? "❯ " : " "), {
|
|
276
|
+
hlGroup: i === s.cursor ? "Title" : "Comment",
|
|
277
|
+
endCol: byteLength(p.text.slice(0, p.spans[0]?.[0] ?? 0)),
|
|
278
|
+
});
|
|
279
|
+
for (let c = 0; c < row.choices.length; c++) {
|
|
280
|
+
const [start, end] = p.spans[c]!;
|
|
281
|
+
const live = c === row.at;
|
|
282
|
+
// One mark, not a band under a colour: the renderer takes the highest-priority ranged group
|
|
283
|
+
// covering a character and stops, so two of them here would draw one and silently drop the
|
|
284
|
+
// other. `Option.Step*` carries the band as well as the step for exactly that reason.
|
|
285
|
+
await neosh.ns.mark(s.ns, s.buf, i, start, {
|
|
286
|
+
hlGroup: groupFor(row, c, live, i === s.cursor),
|
|
287
|
+
endCol: end,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
// Lit on the row the keys act on, and only at the end there is somewhere to go. A chevron that
|
|
291
|
+
// stayed bright at `max` would promise a level that is not there.
|
|
292
|
+
const ends = [row.at > 0, row.at < row.choices.length - 1];
|
|
293
|
+
for (let r = 0; r < 2; r++) {
|
|
294
|
+
const [start, end] = p.rails[r]!;
|
|
295
|
+
await neosh.ns.mark(s.ns, s.buf, i, start, {
|
|
296
|
+
hlGroup: i === s.cursor && ends[r] ? "Option.RailLive" : "Option.Rail",
|
|
297
|
+
endCol: end,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const detail = s.rows.length + 1;
|
|
302
|
+
await neosh.ns.mark(s.ns, s.buf, detail, 0, {
|
|
303
|
+
// What a spoken level says about itself is said in the register it belongs to. The sentence
|
|
304
|
+
// under a rainbow value is the one explaining why it is not a level, and drawing it in the
|
|
305
|
+
// same grey as "how much reasoning the model spends" buries the difference.
|
|
306
|
+
hlGroup: here?.choices[here.at]?.spoken ? "Option.Beyond" : "Picker.Detail",
|
|
307
|
+
endCol: byteLength(lines[detail] ?? ""),
|
|
308
|
+
});
|
|
309
|
+
await neosh.ns.mark(s.ns, s.buf, detail + 1, 0, {
|
|
310
|
+
hlGroup: "Sidebar.Dim",
|
|
311
|
+
endCol: byteLength(lines[detail + 1] ?? ""),
|
|
312
|
+
});
|
|
313
|
+
await neosh.win.setCursor(s.win, s.cursor, 0);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Put the whole sheet into effect.
|
|
318
|
+
*
|
|
319
|
+
* Every keystroke, rather than on the way out. Changing a reasoning level is reversible — you can
|
|
320
|
+
* see what it is and put it back — and "everything irreversible asks, and nothing reversible does"
|
|
321
|
+
* cuts both ways: a control that only takes effect when you press the right key to leave is a
|
|
322
|
+
* control you have to be taught. The footer moves as you move, which is the whole feedback loop.
|
|
323
|
+
*/
|
|
324
|
+
async function apply(
|
|
325
|
+
neosh: Neosh,
|
|
326
|
+
s: Sheet,
|
|
327
|
+
refresh: (flash?: boolean) => Promise<void>,
|
|
328
|
+
): Promise<void> {
|
|
329
|
+
const options: OptionSelection[] = s.rows.map((row) => {
|
|
330
|
+
const choice = row.choices[row.at]!;
|
|
331
|
+
return {
|
|
332
|
+
id: row.id,
|
|
333
|
+
value: row.boolean ? row.at === 1 : choice.id,
|
|
334
|
+
};
|
|
335
|
+
});
|
|
336
|
+
s.selection = { ...s.selection, options };
|
|
337
|
+
await neosh.agent.setSelection(s.selection);
|
|
338
|
+
await refresh();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The keys, as words.
|
|
343
|
+
*
|
|
344
|
+
* The key that *changes something* first: it used to be third, behind `↵`, which closes. Read in
|
|
345
|
+
* order, the old line taught you that the way to use this panel was to dismiss it.
|
|
346
|
+
*/
|
|
347
|
+
function hintsFor(): string {
|
|
348
|
+
// Letters rather than arrows, and not because the arrows do not work — they are bound too. The
|
|
349
|
+
// row is a promise about a keyboard, and `h j k l` is a promise every keyboard keeps.
|
|
350
|
+
return "h l change j k knob ⇥ cycle ↵ done esc close";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export async function installOptions(
|
|
354
|
+
{ neosh, subscriptions }: { neosh: Neosh; subscriptions: Disposable[] },
|
|
355
|
+
refresh: (flash?: boolean) => Promise<void>,
|
|
356
|
+
): Promise<void> {
|
|
357
|
+
const scope = { kind: "buf_kind", name: KIND } as const;
|
|
358
|
+
|
|
359
|
+
/** Register a verb and bind it inside the sheet. See the sidebar's `verb` — same reasoning. */
|
|
360
|
+
const verb = async (
|
|
361
|
+
name: string,
|
|
362
|
+
keys: string[],
|
|
363
|
+
desc: string,
|
|
364
|
+
fn: (s: Sheet) => Promise<void> | void,
|
|
365
|
+
): Promise<void> => {
|
|
366
|
+
subscriptions.push(
|
|
367
|
+
await neosh.cmd.register(name, () => {
|
|
368
|
+
const mine = queue.then(async () => {
|
|
369
|
+
if (!sheet) return;
|
|
370
|
+
await fn(sheet);
|
|
371
|
+
});
|
|
372
|
+
// One verb that fails must not stop the ones behind it: the panel would look alive and
|
|
373
|
+
// ignore every key from then on, which is worse than the failure it is hiding.
|
|
374
|
+
queue = mine.catch((e: unknown) => neosh.notify(String(e), "warn"));
|
|
375
|
+
return mine;
|
|
376
|
+
}, { desc }),
|
|
377
|
+
);
|
|
378
|
+
for (const key of keys) {
|
|
379
|
+
await neosh.keymap.set("chat", key, name, { scope, desc });
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const redraw = async (s: Sheet) => draw(neosh, s, hintsFor());
|
|
384
|
+
|
|
385
|
+
const move = async (s: Sheet, delta: number) => {
|
|
386
|
+
s.cursor = Math.min(s.rows.length - 1, Math.max(0, s.cursor + delta));
|
|
387
|
+
await redraw(s);
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Along the current row.
|
|
392
|
+
*
|
|
393
|
+
* `wrap` is the difference between the arrows and the cycle key, and it is deliberate: an arrow
|
|
394
|
+
* that teleported from `max` back to `low` would be the same surprise as a downgrade key that
|
|
395
|
+
* lands on the most expensive model in the catalogue, which `ModelTier::step` refuses to be. A
|
|
396
|
+
* key whose whole job is "the next one" may wrap, because that is what it says it does.
|
|
397
|
+
*/
|
|
398
|
+
const shift = async (s: Sheet, delta: number, wrap: boolean) => {
|
|
399
|
+
const row = s.rows[s.cursor];
|
|
400
|
+
if (!row) return;
|
|
401
|
+
const n = row.choices.length;
|
|
402
|
+
row.at = wrap
|
|
403
|
+
? (row.at + delta + n) % n
|
|
404
|
+
: Math.min(n - 1, Math.max(0, row.at + delta));
|
|
405
|
+
// Drawn before it is applied. Both are round trips and the panel is where you are looking, so
|
|
406
|
+
// putting the several calls that update the *strip* first makes every keypress wait on work
|
|
407
|
+
// whose result is behind the window you are reading — and keys arriving meanwhile queue up
|
|
408
|
+
// against a panel that has not caught up with the last one.
|
|
409
|
+
await redraw(s);
|
|
410
|
+
await apply(neosh, s, refresh);
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
// No `^N`/`^P` here, and none needed: the float is **modal**, so nothing global resolves while
|
|
414
|
+
// it is up. This used to be a list of keys deliberately left unbound so that `^N` and `^P` kept
|
|
415
|
+
// working — which was the wrong half of the problem. Shadowing a key is how a panel keeps the
|
|
416
|
+
// one key it needs; it does nothing about the dozen it does not, and `^T`, `^G`, `^L` and the
|
|
417
|
+
// rest all fell through and opened something behind this. A control you are in the middle of
|
|
418
|
+
// using owns the keyboard until you leave it.
|
|
419
|
+
await verb(`${NS}.next`, ["j", "<Down>"], "Next knob", (s) => move(s, 1));
|
|
420
|
+
await verb(`${NS}.prev`, ["k", "<Up>"], "Previous knob", (s) => move(s, -1));
|
|
421
|
+
await verb(`${NS}.higher`, ["l", "<Right>"], "More", (s) => shift(s, 1, false));
|
|
422
|
+
await verb(`${NS}.lower`, ["h", "<Left>"], "Less", (s) => shift(s, -1, false));
|
|
423
|
+
await verb(`${NS}.cycle`, ["<Tab>", "<Space>"], "The next value along", (s) => shift(s, 1, true));
|
|
424
|
+
// `^E` closes it too, the way `^S` leaves reading the transcript. A key that opens a surface and
|
|
425
|
+
// cannot shut it is one you have to remember a second key for — and with the panel modal, the
|
|
426
|
+
// global `^E` no longer arrives to do it.
|
|
427
|
+
await verb(
|
|
428
|
+
`${NS}.close`,
|
|
429
|
+
["<Esc>", "q", "<C-c>", "<C-e>"],
|
|
430
|
+
"Back to the composer",
|
|
431
|
+
(s) => s.close(),
|
|
432
|
+
);
|
|
433
|
+
// Nothing left to apply — every change is already in effect — so what this adds is where the
|
|
434
|
+
// acknowledgement *goes*: the panel shuts and the strip it moved to flashes, which is the answer
|
|
435
|
+
// to "fine, but where does that live now". Deliberately not a flash inside the panel: anything
|
|
436
|
+
// that holds the window open after the key that dismissed it is a window still eating keys, and
|
|
437
|
+
// a swallowed `^P` costs more than a lit row is worth.
|
|
438
|
+
await verb(`${NS}.done`, ["<CR>"], "Done", async (s) => {
|
|
439
|
+
await s.close();
|
|
440
|
+
await refresh(true);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
subscriptions.push({ dispose: () => void sheet?.close() });
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Open the sheet for whatever model this conversation is on.
|
|
448
|
+
*
|
|
449
|
+
* Reads the descriptors afresh every time rather than caching them: they arrive from the *driver*,
|
|
450
|
+
* and a driver that learned what it accepts by asking — `codex` does — reports a different set
|
|
451
|
+
* after a refresh than it did at startup.
|
|
452
|
+
*/
|
|
453
|
+
export async function openOptions(neosh: Neosh): Promise<void> {
|
|
454
|
+
if (sheet) {
|
|
455
|
+
await sheet.close();
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const current = await neosh.agent.selection().catch(() => null);
|
|
459
|
+
if (!current) {
|
|
460
|
+
neosh.notify("no model is selected", "warn");
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const entries = await neosh.agent.listModels(current.instance).catch(() => [] as ModelEntry[]);
|
|
464
|
+
const model = entries.find((e) => e.model.id === current.model)?.model;
|
|
465
|
+
const descriptors = model?.capabilities?.option_descriptors ?? [];
|
|
466
|
+
if (descriptors.length === 0) {
|
|
467
|
+
// Which model, because the answer to "why not" is almost always "not that one" — a small model
|
|
468
|
+
// in a lineup whose big one has five levels, or an endpoint that told us only an id.
|
|
469
|
+
neosh.notify(`${model?.display_name ?? current.model} has nothing to set`, "info");
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const rows = rowsFrom(descriptors, current.options ?? []);
|
|
474
|
+
const buf = await neosh.buf.create({
|
|
475
|
+
name: "[model options]",
|
|
476
|
+
scratch: true,
|
|
477
|
+
kind: KIND,
|
|
478
|
+
});
|
|
479
|
+
const ns = await neosh.ns.create(NS);
|
|
480
|
+
// Wide enough for the longest ladder plus its labels, and no wider: this is a control panel, not
|
|
481
|
+
// a page, and a float that spans the terminal reads as something you have to finish reading.
|
|
482
|
+
const labelWidth = Math.max(...rows.map((r) => width(r.label)));
|
|
483
|
+
const widest = Math.max(
|
|
484
|
+
// `+ 2` for the rail: `‹` and `›` are part of the row, and a ladder measured without them is
|
|
485
|
+
// one whose last chevron falls off the right-hand edge on the widest row.
|
|
486
|
+
...rows.map((r) =>
|
|
487
|
+
r.choices.reduce((n, c) => n + width(c.label) + (c.spoken ? 5 : 2) + 1, 0) + 2
|
|
488
|
+
),
|
|
489
|
+
width(hintsFor()),
|
|
490
|
+
);
|
|
491
|
+
const inner = Math.min(96, labelWidth + widest + 8);
|
|
492
|
+
const config: FloatOptions = {
|
|
493
|
+
anchor: { kind: "screen" },
|
|
494
|
+
width: { kind: "max", n: inner },
|
|
495
|
+
height: { kind: "fixed", n: rows.length + 3 },
|
|
496
|
+
border: "rounded",
|
|
497
|
+
title: ` ${model?.display_name ?? current.model} `,
|
|
498
|
+
focusable: true,
|
|
499
|
+
closeOnBlur: true,
|
|
500
|
+
// Nothing else reaches the keyboard while this is up. It is a control sheet you are in the
|
|
501
|
+
// middle of using: `^N` over it opened a new conversation *behind* it, `^T` opened the project
|
|
502
|
+
// panel and left focus somewhere neither of them expected, and the panel stayed on screen
|
|
503
|
+
// through both. `^Q` and `^R` still work — see `ui.modal_escape_keys` — so this can never be
|
|
504
|
+
// a terminal somebody has to kill.
|
|
505
|
+
modal: true,
|
|
506
|
+
z: 200,
|
|
507
|
+
};
|
|
508
|
+
const win = await neosh.float.open(buf, config);
|
|
509
|
+
await neosh.focus.push(win);
|
|
510
|
+
|
|
511
|
+
const s: Sheet = {
|
|
512
|
+
win,
|
|
513
|
+
buf,
|
|
514
|
+
ns,
|
|
515
|
+
rows,
|
|
516
|
+
cursor: 0,
|
|
517
|
+
selection: current,
|
|
518
|
+
title: model?.display_name ?? String(current.model),
|
|
519
|
+
inner,
|
|
520
|
+
config,
|
|
521
|
+
// What the border currently says, not what the rows currently are — so the first `draw` finds
|
|
522
|
+
// them different and lights it, which is what opening straight onto `ultracode` needs.
|
|
523
|
+
beyond: false,
|
|
524
|
+
close: async () => {
|
|
525
|
+
if (sheet !== s) return;
|
|
526
|
+
sheet = null;
|
|
527
|
+
await neosh.focus.pop().catch(() => {});
|
|
528
|
+
await neosh.win.close(win).catch(() => {});
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
sheet = s;
|
|
532
|
+
await draw(neosh, s, hintsFor());
|
|
533
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@neosh/model",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Model and reasoning-effort switchers, in the status line and behind a picker.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"neosh",
|
|
9
|
+
"neosh-plugin"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/neoswarm/neosh.git",
|
|
14
|
+
"directory": "plugins/builtin/model"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"*.ts",
|
|
18
|
+
"plugin.toml",
|
|
19
|
+
"!._*"
|
|
20
|
+
]
|
|
21
|
+
}
|
package/plugin.toml
ADDED