model-compare-opencode-plugin 0.1.0 → 0.1.3

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/dist/tui.js ADDED
@@ -0,0 +1,976 @@
1
+ // GENERATED by scripts/build-tui.mjs from src/tui.tsx — do not edit.
2
+ // Run `npm run build` after changing the source. See the build script's
3
+ // header for why the published package ships precompiled JSX.
4
+ import { createComponent as _$createComponent } from "@opentui/solid";
5
+ import { memo as _$memo } from "@opentui/solid";
6
+ import { use as _$use } from "@opentui/solid";
7
+ import { createTextNode as _$createTextNode } from "@opentui/solid";
8
+ import { insertNode as _$insertNode } from "@opentui/solid";
9
+ import { setProp as _$setProp } from "@opentui/solid";
10
+ import { effect as _$effect } from "@opentui/solid";
11
+ import { insert as _$insert } from "@opentui/solid";
12
+ import { createElement as _$createElement } from "@opentui/solid";
13
+ // src/tui.tsx
14
+ // The model-compare CLI plugin (design §10, "Notices and status"): the
15
+ // terminal-UI half of the plugin, loaded from the `./tui` export. It owns
16
+ // everything the headless server cannot show:
17
+ //
18
+ // * a live status line in the prompt and home footers, derived from the
19
+ // model registry (the runtime plugin keeps the alias entries' display
20
+ // names current, so the registry IS the state; no messaging involved);
21
+ // * the picks dialog (alt+m): a select over the three priorities —
22
+ // choosing one switches the session to it, with the same decision logic
23
+ // as the server command; with no session open (the opening screen)
24
+ // choosing one CREATES a session already bound to that alias and opens
25
+ // it, so the shortcuts work from the first keystroke (merely OPENING a
26
+ // dialog never creates a session);
27
+ // * the report dialog (alt+shift+m): the /model-compare command emits its
28
+ // structured report as an RPC event, and this half opens it as a
29
+ // selectable dialog (selecting a ranked model switches straight to it) —
30
+ // one command serves both worlds, so no TUI duplicate of the server
31
+ // command exists; with no session open the report is fetched directly
32
+ // over the plugin's RPC (no session command to post it to) and a pick
33
+ // creates a session bound to that model;
34
+ // * a toast when a pick flips (at most one per changed priority per
35
+ // reload, and never for the first observation after load).
36
+ //
37
+ // The headless path (servers, `opencode run`, API clients) has no TUI: its
38
+ // notices stay inbox items delivered at the session's next turn (the
39
+ // commands' `resume: false`), which is correct there — there is no UI to
40
+ // toast in.
41
+ //
42
+ // Owner discipline: everything the host's bridges read through Solid context
43
+ // (the keymap layer registration) runs in setup's own scope, under the owner
44
+ // the TUI host captured for this activation. The createRoot below detaches
45
+ // from that chain on purpose — it owns the solid-js lifetime of the memos
46
+ // and effects — so nothing inside it may call context APIs that resolve
47
+ // Solid context: `context.keymap.layer(...)` inside the root threw
48
+ // "Keymap.Provider is missing" and failed the whole TUI half (observed live,
49
+ // 2026-10-11). The memos and helper functions therefore live at setup scope,
50
+ // where the root's effects and the keymap layers can both close over them.
51
+
52
+ import { Plugin } from "@opencode/plugin/tui";
53
+ import { TextAttributes, RGBA } from "@opentui/core";
54
+ // The OpenTUI JSX element types (<text fg>, <box>): a type-only import loads
55
+ // the package's solid-js JSX augmentation without a runtime import — the TUI
56
+ // host already provides the runtime side.
57
+
58
+ import { createEffect, createMemo, createRoot, createSignal } from "solid-js";
59
+ import { ALIAS_PROVIDER, PRIORITIES, alignedPriority, aliasId, pickOf, pickFromName, readAliasViews, statusLine, useBestDecision, useBestNotice, useBestRefusal } from "../src/opencode/usebest";
60
+ import { sanitizeCatalogText } from "../src/opencode/commands";
61
+ import { ModelCompare } from "../src/rpc";
62
+ const TOAST_MS = 6_000;
63
+ const RESYNC_MS = 30_000;
64
+
65
+ // The plugin package name fragment this entry shares state with: the config
66
+ // lookup matches `plugins[].package` entries against it to read the runtime
67
+ // plugin's options (defaultPriority) through the client's config API.
68
+ const PACKAGE_FRAGMENT = "model-compare-opencode-plugin";
69
+ // The RPC event the /model-compare command emits (see src/rpc.ts; the wire
70
+ // name is prefixed with the RPC's id).
71
+ const REPORT_EVENT = "rpc.model-compare.report";
72
+
73
+ // The list dialogs' lifecycle token (see showListDialog): a module-scope
74
+ // generation counter, bumped every time a dialog is armed, plus the armed
75
+ // dialog's close. A dialog's close() tears down the shared state (the modal
76
+ // keymap layer, the host dialog) only while that dialog is STILL the armed
77
+ // one — a stale close must not clear a newer dialog's keyboard and surface —
78
+ // and arming a new dialog closes whatever is still armed first.
79
+ let dialogGeneration = 0;
80
+ let armedClose = null;
81
+ // The armed dialog's reveal hook (see showListDialog): its own
82
+ // revealOnFrame, exposed module-scope so the setup-scope resize handler can
83
+ // re-reveal the cursor when a resize re-caps the row area under it. Set
84
+ // when a dialog is armed, cleared by its close() alongside armedClose.
85
+ let armedReveal = null;
86
+ function StatusLine(props) {
87
+ return (() => {
88
+ var _el$ = _$createElement("text");
89
+ _$insert(_el$, () => props.line());
90
+ _$effect(_$p => _$setProp(_el$, "fg", props.fg, _$p));
91
+ return _el$;
92
+ })();
93
+ }
94
+
95
+ // One list-dialog row (see showListDialog).
96
+
97
+ // The rows' ONE height rule, shared by three places that must agree or the
98
+ // scroll lands on the wrong line: the rows' JSX (the heading's blank line
99
+ // above), rowOffset's arithmetic and the row-area cap's content measure. A
100
+ // header-line row renders as its text ONLY — the JSX checks headerLine
101
+ // before heading — so a row carrying both fields counts the way it renders,
102
+ // never as both. Every other row is one line except the second and later
103
+ // headings, which carry one blank line above (the palette's heading
104
+ // recipe).
105
+ function isHeadingRow(row) {
106
+ return row.headerLine === undefined && row.heading !== undefined;
107
+ }
108
+ function rowLines(row, headingsSoFar) {
109
+ return isHeadingRow(row) && headingsSoFar > 0 ? 2 : 1;
110
+ }
111
+ // The rows' total LINE count (not their row count — heading blanks are
112
+ // content too); `upto` measures the first rows alone, which is how rowOffset
113
+ // gets a row's y within the content.
114
+ function rowsHeight(rows, upto = rows.length) {
115
+ let y = 0;
116
+ let headings = 0;
117
+ for (let k = 0; k < upto; k++) {
118
+ const row = rows[k];
119
+ y += rowLines(row, headings);
120
+ if (isHeadingRow(row)) headings += 1;
121
+ }
122
+ return y;
123
+ }
124
+
125
+ // The list dialogs' row-area cap, the host command palette's own formula:
126
+ // min(content LINES, floor(terminal height / 2) - 6) — the first argument is
127
+ // rowsHeight(rows), never the rows' count, or the cap clips tail rows even
128
+ // on a roomy terminal (with the scrollbar hidden). The host's dialog layer
129
+ // caps nothing itself and anchors UNcentered dialogs a quarter viewport
130
+ // down — without this cap an uncapped list overflows the viewport at large
131
+ // font sizes (the report dialog did exactly that). Exported for the unit
132
+ // rig.
133
+ export function cappedRowsHeight(contentLines, terminalHeight) {
134
+ return Math.min(contentLines, Math.max(1, Math.floor(terminalHeight / 2) - 6));
135
+ }
136
+
137
+ // One report entry's defensive normalization, shared by the two paths that
138
+ // carry report data across a trust boundary (the /model-compare command's
139
+ // event and the TUI's direct RPC call): an entry missing any identity field
140
+ // is dropped, every other field falls back to its neutral value.
141
+ function toReportEntries(raw) {
142
+ if (!Array.isArray(raw)) return [];
143
+ return raw.flatMap(item => {
144
+ const entry = item;
145
+ if (typeof entry?.id !== "string" || typeof entry?.value !== "string" || typeof entry?.priority !== "string") return [];
146
+ return [{
147
+ priority: entry.priority,
148
+ rank: typeof entry.rank === "number" ? entry.rank : null,
149
+ // The F-20 split (see ReportEntry): `id` sanitized for display,
150
+ // `value` the raw catalog id for switching.
151
+ id: entry.id,
152
+ value: entry.value,
153
+ score: typeof entry.score === "string" ? entry.score : "-",
154
+ quality: typeof entry.quality === "string" ? entry.quality : "-",
155
+ price: typeof entry.price === "string" ? entry.price : "-",
156
+ current: entry.current === true,
157
+ fromFallback: entry.fromFallback === true
158
+ }];
159
+ });
160
+ }
161
+ export default Plugin.define({
162
+ id: "model-compare-tui",
163
+ async setup(context) {
164
+ const location = context.location ?? context.data.location.default();
165
+ const here = location?.directory;
166
+ const notify = (message, variant = "success") => context.ui.toast.show({
167
+ title: "model-compare",
168
+ message,
169
+ variant,
170
+ duration: TOAST_MS
171
+ });
172
+
173
+ // The terminal's size, reactive: the renderer emits "resize" carrying the
174
+ // fresh dimensions, and the dialogs' row-area caps must track font-size
175
+ // and window changes the way the host's own dialogs do (the palette caps
176
+ // its list at a fraction of the viewport; the dialog layer gives plugin
177
+ // dialogs no cap of their own). Detached in the returned cleanup.
178
+ const [terminalSize, setTerminalSize] = createSignal({
179
+ width: context.renderer.width,
180
+ height: context.renderer.height
181
+ });
182
+ const onRendererResize = (width, height) => {
183
+ setTerminalSize({
184
+ width,
185
+ height
186
+ });
187
+ // A resize re-caps the armed dialog's row area under its cursor: the
188
+ // reveal re-runs on the next frame (see showListDialog's
189
+ // revealOnFrame) or a shrunken viewport can leave the cursor below
190
+ // the fold until the next key move.
191
+ armedReveal?.(false);
192
+ };
193
+ context.renderer.on("resize", onRendererResize);
194
+ let disposeRoot = () => {};
195
+ // The three slot disposers (the two footers and the app slot below):
196
+ // slots claimed but never released survive the plugin's unload and keep
197
+ // rendering into a torn-down UI. Called from the returned cleanup.
198
+ let disposePromptFooter = () => {};
199
+ let disposeHomeFooter = () => {};
200
+ let disposeAppSlot = () => {};
201
+ let resyncTimer;
202
+ // The report dialog's hand-off: the /model-compare event callback runs
203
+ // ownerless (data.listen), and a dialog mounted from an ownerless
204
+ // callback renders but its keyboard never attaches — the mount lands
205
+ // outside the provider tree no matter how the call is wrapped. So the
206
+ // callback only PARKS the data on this signal; the app slot below owns
207
+ // an effect (created inside the tree) that opens the dialog when it
208
+ // fires.
209
+ const [pendingReport, setPendingReport] = createSignal(null);
210
+
211
+ // The registry view, reactive: OpenCode's data layer updates the list
212
+ // when the runtime plugin's reloads commit, so the memo re-runs. Created
213
+ // at setup scope (see the owner-discipline note above): the footers'
214
+ // slots and the flip-watch effect below read it inside the root, and the
215
+ // keymap layer's run() reads it outside — a memo works from both.
216
+ const aliases = createMemo(() => readAliasViews(context.data.location.model.list(location) ?? []));
217
+ const line = createMemo(() => statusLine(aliases()));
218
+
219
+ // The focused session: the router's current route when it is a session
220
+ // route; null otherwise (home, dialogs). Defensive: the route's shape
221
+ // is the host's, so every field is read guardedly.
222
+ function currentSessionID() {
223
+ const route = context.ui.router.current();
224
+ if (route === undefined || route === null) return null;
225
+ if (route.type !== undefined && route.type !== "session") return null;
226
+ if (typeof route.sessionID === "string") return route.sessionID;
227
+ if (typeof route.data?.sessionID === "string") return route.data.sessionID;
228
+ return null;
229
+ }
230
+
231
+ // The runtime plugin's configured default priority, read through the
232
+ // client's config API: the config documents carry `plugins[].options`.
233
+ // Falls back to "balanced" when the option is absent or unreadable.
234
+ async function readDefaultPriority() {
235
+ try {
236
+ const entries = await context.client.config.get();
237
+ for (const entry of entries ?? []) {
238
+ // ConfigEntry is a union; only document entries carry the config.
239
+ if (entry.type !== "document") continue;
240
+ const plugins = entry.info.plugins;
241
+ if (!Array.isArray(plugins)) continue;
242
+ for (const plugin of plugins) {
243
+ if (typeof plugin !== "object" || plugin === null) continue;
244
+ const record = plugin;
245
+ const pkg = typeof record.package === "string" ? record.package : "";
246
+ if (!pkg.includes(PACKAGE_FRAGMENT)) continue;
247
+ const priority = record.options?.defaultPriority;
248
+ if (priority === "balanced" || priority === "price" || priority === "quality") {
249
+ return priority;
250
+ }
251
+ }
252
+ }
253
+ } catch {
254
+ // Unreadable config: the default applies (§3 semantics).
255
+ }
256
+ return "balanced";
257
+ }
258
+
259
+ // Switch to one of the plugin's aliases — the shared decision chain
260
+ // behind /use-best, the palette command and the picks dialog's
261
+ // selection. With a session open it switches THAT session; with none
262
+ // (the opening screen) it creates a session already bound to the alias
263
+ // and opens it — the pick is the session's starting model. A disabled
264
+ // alias refuses before anything is created.
265
+ async function switchToPriority(priority) {
266
+ const views = aliases();
267
+ if (!views.some(view => view.priority === priority && view.enabled)) {
268
+ notify(useBestRefusal(priority), "error");
269
+ return;
270
+ }
271
+ const view = views.find(item => item.priority === priority);
272
+ const notice = useBestNotice(priority, view?.modelID ?? pickFromName(view?.name ?? ""));
273
+ const sessionID = currentSessionID();
274
+ if (sessionID === null) {
275
+ await createSessionOnModel({
276
+ providerID: ALIAS_PROVIDER,
277
+ id: aliasId(priority)
278
+ }, notice);
279
+ return;
280
+ }
281
+ try {
282
+ await context.client.session.switchModel({
283
+ sessionID,
284
+ model: {
285
+ providerID: ALIAS_PROVIDER,
286
+ id: aliasId(priority)
287
+ }
288
+ });
289
+ notify(notice);
290
+ } catch (error) {
291
+ notify(`use-best failed: ${error instanceof Error ? error.message : String(error)}`, "error");
292
+ }
293
+ }
294
+
295
+ // Switch the current session straight to a concrete model (the report
296
+ // dialog's selection).
297
+ async function switchToModel(sessionID, id) {
298
+ try {
299
+ await context.client.session.switchModel({
300
+ sessionID,
301
+ model: {
302
+ providerID: ALIAS_PROVIDER,
303
+ id
304
+ }
305
+ });
306
+ // F-20: `id` here is the raw catalog id (the switch needs it) — the
307
+ // toast shows the flattened form so it cannot be line-injected.
308
+ notify(`switched to openrouter/${sanitizeCatalogText(id)}`);
309
+ } catch (error) {
310
+ notify(`switch failed: ${error instanceof Error ? error.message : String(error)}`, "error");
311
+ }
312
+ }
313
+
314
+ // No session open (the opening screen's pick paths): create one already
315
+ // bound to the given model and navigate into it. Returns whether the
316
+ // session came up; a failure is toasted and nothing else happens — no
317
+ // half-open session to clean up.
318
+ async function createSessionOnModel(model, success) {
319
+ try {
320
+ const session = await context.client.session.create({
321
+ model
322
+ });
323
+ context.ui.router.navigate({
324
+ type: "session",
325
+ sessionID: session.id
326
+ });
327
+ notify(success);
328
+ return true;
329
+ } catch (error) {
330
+ notify(`could not open a session: ${error instanceof Error ? error.message : String(error)}`, "error");
331
+ return false;
332
+ }
333
+ }
334
+
335
+ // The custom list dialog. The host's `dialog.select` (SDK 2.0.16
336
+ // runtime) renders one GROUPED box per category with one cursor each —
337
+ // its search flattening (verified against main) does not exist in this
338
+ // build — so three simultaneous cursors move independently. This
339
+ // renderer replaces it: `dialog.show` + our own rows — one cursor,
340
+ // palette styling (bold title, dim esc, theme action colors on the
341
+ // selected row). The modal keymap layer cannot be registered from the
342
+ // render closure (context-less — "Keymap.Provider is missing", the same
343
+ // trap as setup scope), so the app slot's IN-TREE effect registers it
344
+ // while `listKeys` is armed, and the host unmounts it with the dialog.
345
+
346
+ // The keymap layer hand-off (see showListDialog): set while a list
347
+ // dialog is open, cleared on close; the app slot's in-tree effect maps
348
+ // it to a modal layer.
349
+ const [listKeys, setListKeys] = createSignal(null);
350
+ function showListDialog(title, rows, onPick) {
351
+ const selectable = rows.filter(row => row.value !== undefined);
352
+ if (selectable.length === 0) {
353
+ notify("nothing to select", "error");
354
+ return;
355
+ }
356
+ const initial = Math.max(0, selectable.findIndex(row => row.selected !== undefined));
357
+ // The DIALOG surface: the exact theme call the host's own dialogs make
358
+ // (dialog-select/dialog-alert) — uniform colors with the host chrome
359
+ // and the palette's heading color.
360
+ const theme = context.theme.surface("dialog");
361
+ const base = theme.text.base;
362
+ const muted = theme.text.muted;
363
+ const accent = theme.hue.accent[200];
364
+ const hiFg = theme.text.action.primary.focused;
365
+ const hiBg = theme.background.action.primary.focused;
366
+ // The native rows' inactive background: explicitly transparent, so the
367
+ // dialog surface shows through.
368
+ const transparent = RGBA.fromInts(0, 0, 0, 0);
369
+
370
+ // This dialog's lifecycle token: its generation in the module-scope
371
+ // counter. Replaced dialogs are closed first (below), so a close()
372
+ // that finds a newer generation knows it lost ownership and must not
373
+ // touch the armed dialog's shared state.
374
+ armedClose?.();
375
+ const generation = ++dialogGeneration;
376
+ let alive = true;
377
+ // The cursor as a SIGNAL: the JSX closure reads it reactively, so
378
+ // every move repaints the dialog (a plain `let` painted once and
379
+ // never updated — the cursor sat on row 1 however many times the
380
+ // keymap moved the internal index; observed live, 2026-10-11).
381
+ const [cursor, setCursor] = createSignal(initial);
382
+ // The scrollbox that carries the rows (see the JSX below); its ref
383
+ // lands when the host renders the dialog. The cursor's row is kept in
384
+ // view with the native lists' look-ahead: the content scrolls as soon
385
+ // as the cursor comes within three lines of an edge, and the cursor
386
+ // only touches an edge line when the content ends (the scroll clamps
387
+ // at its maximum).
388
+ //
389
+ // The row offsets are pure ARITHMETIC, not renderable geometry —
390
+ // rowsHeight (module scope) is the ONE row-height rule the rows' JSX
391
+ // and the row-area cap share. Reading the host's .y was tried and is
392
+ // unreliable across the plugin boundary — it reported the scrollbox's
393
+ // own position for every row — while scrollTop/scrollHeight/height
394
+ // behave as plain numbers.
395
+ let listScroll;
396
+ let pendingReveal;
397
+ const rowOffset = index => rowsHeight(rows, index);
398
+ const reveal = center => {
399
+ const box = listScroll;
400
+ if (box === undefined) return;
401
+ const top = rowOffset(rows.indexOf(selectable[cursor()]));
402
+ const max = Math.max(0, box.scrollHeight - box.height);
403
+ // The look-ahead band, clamped to the window: at box.height <= 3 the
404
+ // three-line margin degenerates to plain ensure-visible (the row
405
+ // lands ON a window line instead of the overscroll an unclamped
406
+ // `box.height - 4` produced once it went negative — cap values of
407
+ // 1-3 are exactly what large font sizes reach).
408
+ const band = Math.min(3, Math.max(0, box.height - 1));
409
+ // Keep the row on-screen whatever the branch: the row's line must
410
+ // stay within [scrollTop, scrollTop + box.height - 1].
411
+ const visible = target => Math.min(top, Math.max(top - box.height + 1, target));
412
+ let target = box.scrollTop;
413
+ if (center) target = top - Math.floor(box.height / 2);else if (top - box.scrollTop > box.height - 1 - band) target = visible(top - (box.height - 1 - band));else if (top - box.scrollTop < band) target = visible(top - band);
414
+ box.scrollTo(Math.max(0, Math.min(target, max)));
415
+ };
416
+ // Positions exist only after a layout frame, so the reveal defers to
417
+ // the next rendered frame (the palette's own once(FRAME) +
418
+ // requestRender() pattern) rather than racing the renderer inside the
419
+ // key handler.
420
+ const revealOnFrame = center => {
421
+ if (pendingReveal !== undefined) context.renderer.off("frame", pendingReveal);
422
+ const run = () => {
423
+ pendingReveal = undefined;
424
+ if (!alive) return;
425
+ reveal(center);
426
+ };
427
+ pendingReveal = run;
428
+ context.renderer.once("frame", run);
429
+ context.renderer.requestRender();
430
+ };
431
+ const close = () => {
432
+ if (!alive) return;
433
+ alive = false;
434
+ // A pending frame reveal is THIS dialog's alone: cancel it here or
435
+ // the once("frame") listener (and its closure over the rows)
436
+ // outlives the dialog — it self-guarded on `alive`, but leaked until
437
+ // the next frame.
438
+ if (pendingReveal !== undefined) {
439
+ context.renderer.off("frame", pendingReveal);
440
+ pendingReveal = undefined;
441
+ }
442
+ // A newer dialog was armed since this one: its own close owns the
443
+ // shared state (the keymap layer, the host dialog, the reveal hook)
444
+ // now — clearing here would kill ITS keyboard and surface.
445
+ if (generation !== dialogGeneration) return;
446
+ armedClose = null;
447
+ armedReveal = null;
448
+ setListKeys(null);
449
+ context.ui.dialog.clear();
450
+ };
451
+ // move(±1) walks the SELECTABLE rows only; heading rows never take
452
+ // the cursor. Wraps: one past the last is the first, one before the
453
+ // first is the last (the command palette's behavior).
454
+ const move = delta => {
455
+ setCursor((cursor() + delta + selectable.length) % selectable.length);
456
+ // Frame-deferred like every reveal — and it CANCELS a pending one,
457
+ // so the preselected row's open-time centering can never land after
458
+ // the user has already moved the cursor off it.
459
+ revealOnFrame(false);
460
+ };
461
+ const pick = () => {
462
+ const row = selectable[cursor()];
463
+ close();
464
+ onPick(row.value);
465
+ };
466
+
467
+ // Arm the modal layer via the app slot's in-tree effect (see the app
468
+ // slot below); it registers on the next reactive run. This dialog is
469
+ // now the armed one (the lifecycle token above).
470
+ armedClose = close;
471
+ armedReveal = revealOnFrame;
472
+ setListKeys({
473
+ move,
474
+ pick
475
+ });
476
+ context.ui.dialog.show(() => // Top/bottom blanks follow the native boxes: 1 with a tip line at
477
+ // the bottom (the host's dialog panel contributes the other top
478
+ // blank, so our own top padding is 0).
479
+ (() => {
480
+ var _el$2 = _$createElement("box"),
481
+ _el$3 = _$createElement("box"),
482
+ _el$4 = _$createElement("text"),
483
+ _el$5 = _$createElement("text"),
484
+ _el$7 = _$createElement("scrollbox"),
485
+ _el$8 = _$createElement("box"),
486
+ _el$9 = _$createElement("text");
487
+ _$insertNode(_el$2, _el$3);
488
+ _$insertNode(_el$2, _el$7);
489
+ _$insertNode(_el$2, _el$8);
490
+ _$setProp(_el$2, "paddingTop", 0);
491
+ _$setProp(_el$2, "paddingBottom", 1);
492
+ _$setProp(_el$2, "gap", 1);
493
+ _$insertNode(_el$3, _el$4);
494
+ _$insertNode(_el$3, _el$5);
495
+ _$setProp(_el$3, "paddingLeft", 4);
496
+ _$setProp(_el$3, "paddingRight", 4);
497
+ _$setProp(_el$3, "flexDirection", "row");
498
+ _$setProp(_el$3, "justifyContent", "space-between");
499
+ _$setProp(_el$4, "fg", base);
500
+ _$insert(_el$4, title);
501
+ _$insertNode(_el$5, _$createTextNode(`esc`));
502
+ _$setProp(_el$5, "fg", muted);
503
+ _$setProp(_el$5, "onMouseUp", () => close());
504
+ _$use(element => {
505
+ listScroll = element;
506
+ // The report's preselected row (the current pick) opens
507
+ // centered in the scrollbox: scheduled from the ref, so the
508
+ // frame it waits for is the one that lays these rows out.
509
+ if (initial > 0) revealOnFrame(true);
510
+ }, _el$7);
511
+ _$setProp(_el$7, "paddingLeft", 1);
512
+ _$setProp(_el$7, "paddingRight", 1);
513
+ _$setProp(_el$7, "scrollbarOptions", {
514
+ visible: false
515
+ });
516
+ _$insert(_el$7, () => {
517
+ let headings = 0;
518
+ return rows.map(row => {
519
+ if (row.headerLine !== undefined) {
520
+ // The table's column header: dim, cursor-skipped, and
521
+ // directly above the first section (no blank line).
522
+ return (() => {
523
+ var _el$1 = _$createElement("text");
524
+ _$setProp(_el$1, "fg", muted);
525
+ _$insert(_el$1, () => row.headerLine);
526
+ return _el$1;
527
+ })();
528
+ }
529
+ if (row.heading !== undefined) {
530
+ // The palette's heading recipe: accent hue, bold, one
531
+ // blank line above every heading but the first — the
532
+ // blank is rowLines' call (the ONE row-height rule the
533
+ // offsets and the cap share), not a second copy of its
534
+ // condition. Flush with the title: 1 (list inset) + 3 = 4.
535
+ const paddingTop = rowLines(row, headings) - 1;
536
+ headings += 1;
537
+ return (() => {
538
+ var _el$10 = _$createElement("box"),
539
+ _el$11 = _$createElement("text");
540
+ _$insertNode(_el$10, _el$11);
541
+ _$setProp(_el$10, "paddingTop", paddingTop);
542
+ _$setProp(_el$10, "paddingLeft", 3);
543
+ _$setProp(_el$11, "fg", accent);
544
+ _$insert(_el$11, () => row.heading);
545
+ _$effect(_$p => _$setProp(_el$11, "attributes", TextAttributes.BOLD, _$p));
546
+ return _el$10;
547
+ })();
548
+ }
549
+ const active = row.value !== undefined && selectable[cursor()] === row;
550
+ // The row BOX carries the background: it stretches the
551
+ // list's full width, so the cursor is "justified" — from
552
+ // the 1-blank left inset to the 1-blank right inset —
553
+ // while the text stays at the content indent (4).
554
+ const nameFg = row.dim === true ? muted : active ? hiFg : base;
555
+ return (() => {
556
+ var _el$12 = _$createElement("box");
557
+ _$setProp(_el$12, "flexDirection", "row");
558
+ _$setProp(_el$12, "paddingLeft", 3);
559
+ _$setProp(_el$12, "paddingRight", 3);
560
+ _$setProp(_el$12, "backgroundColor", active ? hiBg : transparent);
561
+ _$insert(_el$12, (() => {
562
+ var _c$ = _$memo(() => row.columns !== undefined);
563
+ return () => _c$() ? (() => {
564
+ var _el$13 = _$createElement("text"),
565
+ _el$14 = _$createElement("span"),
566
+ _el$15 = _$createElement("span");
567
+ _$insertNode(_el$13, _el$14);
568
+ _$insertNode(_el$13, _el$15);
569
+ _$setProp(_el$14, "style", {
570
+ fg: nameFg
571
+ });
572
+ _$insert(_el$14, (() => {
573
+ var _c$2 = _$memo(() => !!(active && row.dim !== true));
574
+ return () => _c$2() ? (() => {
575
+ var _el$16 = _$createElement("b");
576
+ _$insert(_el$16, () => row.label);
577
+ return _el$16;
578
+ })() : row.label;
579
+ })());
580
+ _$insert(_el$15, () => row.columns);
581
+ _$effect(_$p => _$setProp(_el$15, "style", {
582
+ fg: row.dim === true || !active ? muted : hiFg
583
+ }, _$p));
584
+ return _el$13;
585
+ })() : (() => {
586
+ var _el$17 = _$createElement("text");
587
+ _$setProp(_el$17, "fg", nameFg);
588
+ _$insert(_el$17, () => row.label);
589
+ _$effect(_$p => _$setProp(_el$17, "attributes", active && row.dim !== true ? TextAttributes.BOLD : undefined, _$p));
590
+ return _el$17;
591
+ })();
592
+ })());
593
+ return _el$12;
594
+ })();
595
+ });
596
+ });
597
+ _$insertNode(_el$8, _el$9);
598
+ _$setProp(_el$8, "paddingLeft", 4);
599
+ _$insertNode(_el$9, _$createTextNode(`Details: https://canonical.github.io/model-compare`));
600
+ _$setProp(_el$9, "fg", muted);
601
+ _$effect(_p$ => {
602
+ var _v$ = TextAttributes.BOLD,
603
+ _v$2 = cappedRowsHeight(rowsHeight(rows), terminalSize().height);
604
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$4, "attributes", _v$, _p$.e));
605
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$7, "maxHeight", _v$2, _p$.t));
606
+ return _p$;
607
+ }, {
608
+ e: undefined,
609
+ t: undefined
610
+ });
611
+ return _el$2;
612
+ })(), close);
613
+ // The palette's presentation: vertically centered at the medium width.
614
+ // The size/centered state persists across dialogs (the host's own set
615
+ // it on every open), so the explicit set also clears a stale xlarge a
616
+ // host dialog may have left behind.
617
+ context.ui.dialog.set({
618
+ size: "medium",
619
+ centered: true
620
+ });
621
+ }
622
+
623
+ // The picks dialog: the three priorities, one cursor, palette styling.
624
+ // A snapshot at open time; unavailable priorities are dimmed — the
625
+ // cursor still reaches them, and choosing one is refused with a toast
626
+ // (switchToPriority's guard). Choosing an available priority switches
627
+ // the session to it — or, with no session open, creates one bound to
628
+ // it (the opening screen's path; opening the dialog never does).
629
+ function showPicksDialog() {
630
+ const views = aliases();
631
+ const rows = PRIORITIES.map(priority => {
632
+ const view = views.find(item => item.priority === priority);
633
+ const enabled = view !== undefined && view.enabled !== false;
634
+ return {
635
+ label: `${alignedPriority(priority)} → ${enabled ? pickOf(view) ?? "unavailable" : "disabled"}`,
636
+ value: priority,
637
+ dim: !enabled
638
+ };
639
+ });
640
+ showListDialog("Model Compare — current picks", rows, priority => {
641
+ void switchToPriority(priority);
642
+ });
643
+ }
644
+
645
+ // The report dialog: the ranked entries under bold-ish priority
646
+ // headings (palette category color, blank line above each — headings
647
+ // are skipped by the cursor), one cursor over all of them; choosing a
648
+ // model switches straight to it. Empty entries mean the command had no
649
+ // ranking data.
650
+ function showReportDialog(data) {
651
+ if (data.entries.length === 0) {
652
+ notify("no ranking data available yet", "error");
653
+ return;
654
+ }
655
+ // The approved table: one dim column header at the top (directly above
656
+ // the first section), section headings with a blank line above all but
657
+ // the first, and right-justified quality/price columns. No score
658
+ // column — the transcript's text report keeps it. Column widths are
659
+ // clamped (qual ≤ 8, $ in/out ≤ 16) and oversized cells are truncated
660
+ // to their clamped width, so with the name clamp below the row budget
661
+ // holds even for hostile/absurd column strings: what the clamp
662
+ // guarantees is that a row and the header stay within the dialog's
663
+ // fixed 52-char content width (hostile values get truncated rather
664
+ // than pushed into wrapping — the 40-char rankless fallback label did
665
+ // exactly that before the name clamp).
666
+ const labels = data.entries.map(entry => `${entry.rank === null ? "·" : entry.rank}. ${entry.id}${entry.fromFallback ? " (fallback)" : ""}`);
667
+ const labelOf = new Map(data.entries.map((entry, index) => [entry, labels[index]]));
668
+ const colW = (values, floor, cap) => Math.min(cap, Math.max(floor, ...values.map(value => value.length)));
669
+ const qualW = colW(data.entries.map(entry => entry.quality), "qual".length, 8);
670
+ const priceW = colW(data.entries.map(entry => entry.price), "$ in/out".length, 16);
671
+ // A cell wider than its clamped column is truncated to fit.
672
+ const cell = (value, width) => value.length > width ? value.slice(0, width - 1) + "…" : value;
673
+ // Prices use padEnd (not padStart): padded to a common width the
674
+ // right edges stay flush while the fixed 2-space gap between columns
675
+ // stays exactly 2 (padStart pushed each value right by its slack).
676
+ // The default dialog is 60 wide: 2 list insets + 6 row insets leave 52
677
+ // for row content; the header shares that budget (plus its 3-indent).
678
+ const fixed = 2 + qualW + 2 + priceW;
679
+ const nameW = Math.max(8, Math.min(52 - fixed, Math.max(...labels.map(label => label.length))));
680
+ const trim = label => label.length > nameW ? label.slice(0, nameW - 1) + "…" : label;
681
+ const headerLine = " " + " ".repeat(nameW) + " " + "qual".padStart(qualW) + " " + "$ in/out".padEnd(priceW);
682
+ const rows = [{
683
+ label: "",
684
+ headerLine
685
+ }];
686
+ for (const priority of PRIORITIES) {
687
+ const section = data.entries.filter(entry => entry.priority === priority);
688
+ if (section.length === 0) continue;
689
+ rows.push({
690
+ heading: priority,
691
+ label: ""
692
+ });
693
+ for (const entry of section) {
694
+ rows.push({
695
+ label: trim(labelOf.get(entry) ?? "").padEnd(nameW),
696
+ columns: " " + cell(entry.quality, qualW).padStart(qualW) + " " + cell(entry.price, priceW).padEnd(priceW),
697
+ // F-20 id split: the row DISPLAYS the sanitized id (in `label`)
698
+ // but picks `value`, the raw catalog id switchToModel needs.
699
+ value: entry.value,
700
+ selected: entry.current ? entry.value : undefined
701
+ });
702
+ }
703
+ }
704
+ showListDialog(`Model Compare — top 5 (data age: ${data.age ?? "unknown"})`, rows, id => {
705
+ if (data.sessionID !== null) {
706
+ void switchToModel(data.sessionID, id);
707
+ return;
708
+ }
709
+ // The opening screen: the pick becomes a NEW session's model —
710
+ // the same concrete model the in-session path switches to.
711
+ void createSessionOnModel({
712
+ providerID: ALIAS_PROVIDER,
713
+ id
714
+ }, `switched to openrouter/${sanitizeCatalogText(id)}`);
715
+ });
716
+ }
717
+
718
+ // The report dialog rides the /model-compare command's RPC event: ONE
719
+ // command serves both worlds — headless gets the transcript notice, the
720
+ // TUI gets this instant selectable dialog. Filtered to this plugin's
721
+ // location and the currently viewed session, so a headless run's report
722
+ // never pops a dialog over an unrelated session.
723
+ const offReportEvent = context.data.listen(({
724
+ details
725
+ }) => {
726
+ const event = details;
727
+ if (event.type !== REPORT_EVENT) return;
728
+ if (typeof event.location?.directory === "string" && here !== undefined && event.location.directory !== here) return;
729
+ const data = event.data;
730
+ if (typeof data?.sessionID !== "string" || data.sessionID !== currentSessionID()) return;
731
+ if (!Array.isArray(data.entries)) return;
732
+ const entries = toReportEntries(data.entries);
733
+ const eventSessionID = data.sessionID;
734
+ // Ownerless callback: park the report on the signal — the app slot's
735
+ // in-tree effect opens the dialog (see pendingReport above).
736
+ setPendingReport({
737
+ sessionID: eventSessionID,
738
+ age: typeof data.age === "string" ? data.age : null,
739
+ entries
740
+ });
741
+ });
742
+ createRoot(dispose => {
743
+ disposeRoot = dispose;
744
+
745
+ // Keep the data layer fresh: a belt-and-braces re-sync, since the
746
+ // registry also updates reactively. Cleared on unload below.
747
+ void context.data.location.model.sync(location);
748
+ resyncTimer = setInterval(() => {
749
+ void context.data.location.model.sync(location).catch(() => {});
750
+ }, RESYNC_MS);
751
+
752
+ // The status line in both footers, in the theme's text color (read
753
+ // inside the render, so a theme switch re-renders).
754
+ disposePromptFooter = context.ui.slot({
755
+ append: "prompt.footer.status",
756
+ render: () => _$createComponent(StatusLine, {
757
+ line: line,
758
+ get fg() {
759
+ return context.theme.text.base;
760
+ }
761
+ })
762
+ });
763
+ disposeHomeFooter = context.ui.slot({
764
+ append: "home.footer.status",
765
+ render: () => _$createComponent(StatusLine, {
766
+ line: line,
767
+ get fg() {
768
+ return context.theme.text.base;
769
+ }
770
+ })
771
+ });
772
+
773
+ // Toast when a pick flips. A real per-priority diff over the pick ids
774
+ // — the previous pick string or null per priority — so only the
775
+ // priorities whose pick ACTUALLY changed toast: a "(fallback)"
776
+ // name-marker change with the same pick is not a flip, and going
777
+ // disabled (enabled→disabled, pick → null) is never toasted. The first
778
+ // observation after load only seeds the map (a load is not a change);
779
+ // when several flips land in one reload, each changed priority gets
780
+ // its own toast.
781
+ const previousPicks = new Map();
782
+ createEffect(() => {
783
+ const byPriority = new Map(aliases().map(view => [view.priority, view]));
784
+ for (const p of PRIORITIES) {
785
+ const view = byPriority.get(p);
786
+ // The pick IDENTITY: the "(fallback)" marker is a name decoration,
787
+ // not part of the pick — compare and toast without it. No
788
+ // observable pick (disabled, or a name with no arrow and no
789
+ // modelID) reads as null.
790
+ const pick = view === undefined || view.enabled === false ? null : (view.modelID ?? pickFromName(view.name))?.replace(/ \(fallback\)$/, "") ?? null;
791
+ const seen = previousPicks.has(p);
792
+ const before = previousPicks.get(p) ?? null;
793
+ previousPicks.set(p, pick);
794
+ if (!seen || pick === null || pick === before) continue;
795
+ // F-20: the pick is catalog-derived — flattened for the toast.
796
+ notify(`Model Compare: ${p} → ${sanitizeCatalogText(pick)}`);
797
+ }
798
+ });
799
+ });
800
+
801
+ // The keymap layers. They CANNOT be registered from setup's scope: the
802
+ // host's layer bridge wraps registration in a createRoot attached to the
803
+ // calling owner, and Solid context does not flow through a created root
804
+ // — `Keymap.createLayer`'s context lookup fails ("Keymap.Provider is
805
+ // missing") whether the call sits inside our own createRoot (v1, the
806
+ // failed load) or at setup scope (v2, silently swallowed: plugin console
807
+ // output reaches no log). The documented pattern is an "app" slot whose
808
+ // render registers the layers — the render runs INSIDE the mounted
809
+ // provider tree, mounts once, and lives for the whole TUI session. Each
810
+ // layer registers in its own try/catch whose failure surfaces as a
811
+ // toast: a registration failure must be visible, and one layer's failure
812
+ // must not take the others down.
813
+ const layerFailure = (what, error) => {
814
+ notify(`${what} failed to register: ${error instanceof Error ? error.message : String(error)}`, "error");
815
+ };
816
+
817
+ // The picks dialog's command. No slash: /use-best already covers the
818
+ // switching surface (the server command is slash-routable; the TUI's
819
+ // duplicate of it was removed for exactly that reason). The dialog opens
820
+ // everywhere — with no session it shows the current picks, and only
821
+ // picking one creates a session bound to it (switchToPriority).
822
+ const picksCommand = {
823
+ id: "model-compare.picks",
824
+ title: "Model-compare top picks",
825
+ group: "Model Compare",
826
+ bind: "alt+m",
827
+ palette: true,
828
+ run: () => showPicksDialog()
829
+ };
830
+
831
+ // The report command: with a session open it posts the server's
832
+ // /model-compare command, whose RPC event opens the selectable report
833
+ // dialog here (and the transcript keeps the notice). With no session
834
+ // (the opening screen) there is nothing to post to, so the report is
835
+ // fetched straight from the plugin's report RPC — same builder, same
836
+ // entries — and picking a model creates a session bound to it (there is
837
+ // no composer-model setter to change instead; verified against the
838
+ // pinned runtime). The refusal-free opening also keeps the footer's
839
+ // status line honest: the shortcuts always DO something.
840
+ const reportCommand = {
841
+ id: "model-compare.report",
842
+ title: "Model-compare report",
843
+ group: "Model Compare",
844
+ bind: "alt+shift+m",
845
+ palette: true,
846
+ run: async () => {
847
+ const sessionID = currentSessionID();
848
+ if (sessionID !== null) {
849
+ try {
850
+ await context.client.session.command({
851
+ sessionID,
852
+ name: "model-compare",
853
+ text: ""
854
+ });
855
+ } catch (error) {
856
+ notify(`report failed: ${error instanceof Error ? error.message : String(error)}`, "error");
857
+ }
858
+ return;
859
+ }
860
+ try {
861
+ const report = await context.client.rpc(ModelCompare).report({}, here === undefined ? undefined : {
862
+ location: {
863
+ directory: here
864
+ }
865
+ });
866
+ const data = report;
867
+ showReportDialog({
868
+ sessionID: null,
869
+ age: typeof data?.age === "string" ? data.age : null,
870
+ entries: toReportEntries(data?.entries)
871
+ });
872
+ } catch (error) {
873
+ notify(`report failed: ${error instanceof Error ? error.message : String(error)}`, "error");
874
+ }
875
+ }
876
+ };
877
+
878
+ // The palette-only use-best: the immediate-toast variant. The SLASH
879
+ // surface is left to the server command (one entry everywhere); the
880
+ // palette is TUI-layer territory, where this variant's instant feedback
881
+ // lives.
882
+ const useBestCommand = {
883
+ id: "model-compare.use-best",
884
+ title: "Use best model",
885
+ group: "Model Compare",
886
+ palette: true,
887
+ run: async input => {
888
+ const decision = useBestDecision({
889
+ argument: input ?? "",
890
+ defaultPriority: await readDefaultPriority(),
891
+ enabled: p => aliases().some(view => view.priority === p && view.enabled)
892
+ });
893
+ if (!decision.ok) {
894
+ notify(decision.message, "error");
895
+ return;
896
+ }
897
+ await switchToPriority(decision.priority);
898
+ }
899
+ };
900
+ disposeAppSlot = context.ui.slot({
901
+ append: "app",
902
+ render: () => {
903
+ // The in-tree effect that opens the report dialog when the
904
+ // /model-compare event parks data on pendingReport (see the signal
905
+ // above): dialogs mounted from inside the tree get their keyboard;
906
+ // ownerless mounts do not (observed live, 2026-10-11).
907
+ createEffect(() => {
908
+ const pending = pendingReport();
909
+ if (pending === null) return;
910
+ setPendingReport(null);
911
+ showReportDialog(pending);
912
+ });
913
+ // The list dialogs' modal keymap layer (see showListDialog):
914
+ // registered HERE, inside the tree, while a list dialog is open —
915
+ // the render closure is context-less, so registration from there
916
+ // throws "Keymap.Provider is missing" (observed live 2026-10-11).
917
+ createEffect(() => {
918
+ const keys = listKeys();
919
+ try {
920
+ if (keys === null) return;
921
+ context.keymap.layer(() => ({
922
+ mode: "modal",
923
+ commands: [{
924
+ id: "model-compare.list.down",
925
+ title: "Next row",
926
+ group: "Dialog",
927
+ bind: "down",
928
+ run: () => keys.move(1)
929
+ }, {
930
+ id: "model-compare.list.up",
931
+ title: "Previous row",
932
+ group: "Dialog",
933
+ bind: "up",
934
+ run: () => keys.move(-1)
935
+ }, {
936
+ id: "model-compare.list.pick",
937
+ title: "Select",
938
+ group: "Dialog",
939
+ bind: "return",
940
+ run: () => keys.pick()
941
+ }]
942
+ }));
943
+ } catch (error) {
944
+ setListKeys(null);
945
+ layerFailure("the list dialog keymap layer", error);
946
+ }
947
+ });
948
+ // The event callbacks (data.listen) run ownerless; the layer
949
+ // registration below carries its own failure toast.
950
+ try {
951
+ context.keymap.layer(() => ({
952
+ mode: "global",
953
+ commands: [useBestCommand, picksCommand, reportCommand]
954
+ }));
955
+ } catch (error) {
956
+ layerFailure("the model-compare keymap layer", error);
957
+ }
958
+ return null;
959
+ }
960
+ });
961
+ return () => {
962
+ // Disarm whatever dialog is still armed FIRST: its pending frame
963
+ // reveal and modal keyboard must not outlive the plugin (the
964
+ // module-scope armedClose/armedReveal survive setup's cleanup
965
+ // otherwise).
966
+ armedClose?.();
967
+ if (resyncTimer !== undefined) clearInterval(resyncTimer);
968
+ context.renderer.off("resize", onRendererResize);
969
+ offReportEvent();
970
+ disposeRoot();
971
+ disposeAppSlot();
972
+ disposeHomeFooter();
973
+ disposePromptFooter();
974
+ };
975
+ }
976
+ });