@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.
Files changed (4) hide show
  1. package/main.ts +796 -0
  2. package/options.ts +533 -0
  3. package/package.json +21 -0
  4. 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
@@ -0,0 +1,4 @@
1
+ name = "model"
2
+ version = "0.1.0"
3
+ entry = "main.ts"
4
+ description = "Model and reasoning-effort switchers, in the status line and behind a picker."