model-compare-opencode-plugin 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tui.js ADDED
@@ -0,0 +1,741 @@
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 { createTextNode as _$createTextNode } from "@opentui/solid";
7
+ import { insertNode as _$insertNode } from "@opentui/solid";
8
+ import { setProp as _$setProp } from "@opentui/solid";
9
+ import { effect as _$effect } from "@opentui/solid";
10
+ import { insert as _$insert } from "@opentui/solid";
11
+ import { createElement as _$createElement } from "@opentui/solid";
12
+ // src/tui.tsx
13
+ // The model-compare CLI plugin (design §10, "Notices and status"): the
14
+ // terminal-UI half of the plugin, loaded from the `./tui` export. It owns
15
+ // everything the headless server cannot show:
16
+ //
17
+ // * a live status line in the prompt and home footers, derived from the
18
+ // model registry (the runtime plugin keeps the alias entries' display
19
+ // names current, so the registry IS the state; no messaging involved);
20
+ // * the picks dialog (alt+m): a select over the three priorities —
21
+ // choosing one switches the session to it, with the same decision logic
22
+ // as the server command;
23
+ // * the report dialog (alt+shift+m): the /model-compare command emits its
24
+ // structured report as an RPC event, and this half opens it as a
25
+ // selectable dialog (selecting a ranked model switches straight to it) —
26
+ // one command serves both worlds, so no TUI duplicate of the server
27
+ // command exists;
28
+ // * a toast when a pick flips (at most one per changed priority per
29
+ // reload, and never for the first observation after load).
30
+ //
31
+ // The headless path (servers, `opencode run`, API clients) has no TUI: its
32
+ // notices stay inbox items delivered at the session's next turn (the
33
+ // commands' `resume: false`), which is correct there — there is no UI to
34
+ // toast in.
35
+ //
36
+ // Owner discipline: everything the host's bridges read through Solid context
37
+ // (the keymap layer registration) runs in setup's own scope, under the owner
38
+ // the TUI host captured for this activation. The createRoot below detaches
39
+ // from that chain on purpose — it owns the solid-js lifetime of the memos
40
+ // and effects — so nothing inside it may call context APIs that resolve
41
+ // Solid context: `context.keymap.layer(...)` inside the root threw
42
+ // "Keymap.Provider is missing" and failed the whole TUI half (observed live,
43
+ // 2026-10-11). The memos and helper functions therefore live at setup scope,
44
+ // where the root's effects and the keymap layers can both close over them.
45
+
46
+ import { Plugin } from "@opencode/plugin/tui";
47
+ import { TextAttributes, RGBA } from "@opentui/core";
48
+ // The OpenTUI JSX element types (<text fg>, <box>): a type-only import loads
49
+ // the package's solid-js JSX augmentation without a runtime import — the TUI
50
+ // host already provides the runtime side.
51
+
52
+ import { createEffect, createMemo, createRoot, createSignal } from "solid-js";
53
+ import { ALIAS_PROVIDER, PRIORITIES, alignedPriority, aliasId, pickOf, pickFromName, readAliasViews, statusLine, useBestDecision, useBestNotice, useBestRefusal } from "../src/opencode/usebest";
54
+ import { sanitizeCatalogText } from "../src/opencode/commands";
55
+ const TOAST_MS = 6_000;
56
+ const RESYNC_MS = 30_000;
57
+
58
+ // The plugin package name fragment this entry shares state with: the config
59
+ // lookup matches `plugins[].package` entries against it to read the runtime
60
+ // plugin's options (defaultPriority) through the client's config API.
61
+ const PACKAGE_FRAGMENT = "model-compare-opencode-plugin";
62
+ // The RPC event the /model-compare command emits (see src/rpc.ts; the wire
63
+ // name is prefixed with the RPC's id).
64
+ const REPORT_EVENT = "rpc.model-compare.report";
65
+
66
+ // The list dialogs' lifecycle token (see showListDialog): a module-scope
67
+ // generation counter, bumped every time a dialog is armed, plus the armed
68
+ // dialog's close. A dialog's close() tears down the shared state (the modal
69
+ // keymap layer, the host dialog) only while that dialog is STILL the armed
70
+ // one — a stale close must not clear a newer dialog's keyboard and surface —
71
+ // and arming a new dialog closes whatever is still armed first.
72
+ let dialogGeneration = 0;
73
+ let armedClose = null;
74
+ function StatusLine(props) {
75
+ return (() => {
76
+ var _el$ = _$createElement("text");
77
+ _$insert(_el$, () => props.line());
78
+ _$effect(_$p => _$setProp(_el$, "fg", props.fg, _$p));
79
+ return _el$;
80
+ })();
81
+ }
82
+ export default Plugin.define({
83
+ id: "model-compare-tui",
84
+ async setup(context) {
85
+ const location = context.location ?? context.data.location.default();
86
+ const here = location?.directory;
87
+ const notify = (message, variant = "success") => context.ui.toast.show({
88
+ title: "model-compare",
89
+ message,
90
+ variant,
91
+ duration: TOAST_MS
92
+ });
93
+ let disposeRoot = () => {};
94
+ // The three slot disposers (the two footers and the app slot below):
95
+ // slots claimed but never released survive the plugin's unload and keep
96
+ // rendering into a torn-down UI. Called from the returned cleanup.
97
+ let disposePromptFooter = () => {};
98
+ let disposeHomeFooter = () => {};
99
+ let disposeAppSlot = () => {};
100
+ let resyncTimer;
101
+ // The report dialog's hand-off: the /model-compare event callback runs
102
+ // ownerless (data.listen), and a dialog mounted from an ownerless
103
+ // callback renders but its keyboard never attaches — the mount lands
104
+ // outside the provider tree no matter how the call is wrapped. So the
105
+ // callback only PARKS the data on this signal; the app slot below owns
106
+ // an effect (created inside the tree) that opens the dialog when it
107
+ // fires.
108
+ const [pendingReport, setPendingReport] = createSignal(null);
109
+
110
+ // The registry view, reactive: OpenCode's data layer updates the list
111
+ // when the runtime plugin's reloads commit, so the memo re-runs. Created
112
+ // at setup scope (see the owner-discipline note above): the footers'
113
+ // slots and the flip-watch effect below read it inside the root, and the
114
+ // keymap layer's run() reads it outside — a memo works from both.
115
+ const aliases = createMemo(() => readAliasViews(context.data.location.model.list(location) ?? []));
116
+ const line = createMemo(() => statusLine(aliases()));
117
+
118
+ // The focused session: the router's current route when it is a session
119
+ // route; null otherwise (home, dialogs). Defensive: the route's shape
120
+ // is the host's, so every field is read guardedly.
121
+ function currentSessionID() {
122
+ const route = context.ui.router.current();
123
+ if (route === undefined || route === null) return null;
124
+ if (route.type !== undefined && route.type !== "session") return null;
125
+ if (typeof route.sessionID === "string") return route.sessionID;
126
+ if (typeof route.data?.sessionID === "string") return route.data.sessionID;
127
+ return null;
128
+ }
129
+
130
+ // The runtime plugin's configured default priority, read through the
131
+ // client's config API: the config documents carry `plugins[].options`.
132
+ // Falls back to "balanced" when the option is absent or unreadable.
133
+ async function readDefaultPriority() {
134
+ try {
135
+ const entries = await context.client.config.get();
136
+ for (const entry of entries ?? []) {
137
+ // ConfigEntry is a union; only document entries carry the config.
138
+ if (entry.type !== "document") continue;
139
+ const plugins = entry.info.plugins;
140
+ if (!Array.isArray(plugins)) continue;
141
+ for (const plugin of plugins) {
142
+ if (typeof plugin !== "object" || plugin === null) continue;
143
+ const record = plugin;
144
+ const pkg = typeof record.package === "string" ? record.package : "";
145
+ if (!pkg.includes(PACKAGE_FRAGMENT)) continue;
146
+ const priority = record.options?.defaultPriority;
147
+ if (priority === "balanced" || priority === "price" || priority === "quality") {
148
+ return priority;
149
+ }
150
+ }
151
+ }
152
+ } catch {
153
+ // Unreadable config: the default applies (§3 semantics).
154
+ }
155
+ return "balanced";
156
+ }
157
+
158
+ // Switch the current session to one of the plugin's aliases — the shared
159
+ // decision chain behind /use-best, the palette command and the picks
160
+ // dialog's selection.
161
+ async function switchToPriority(priority) {
162
+ const sessionID = currentSessionID();
163
+ if (sessionID === null) {
164
+ notify("Open a session first", "error");
165
+ return;
166
+ }
167
+ try {
168
+ const views = aliases();
169
+ if (!views.some(view => view.priority === priority && view.enabled)) {
170
+ notify(useBestRefusal(priority), "error");
171
+ return;
172
+ }
173
+ await context.client.session.switchModel({
174
+ sessionID,
175
+ model: {
176
+ providerID: ALIAS_PROVIDER,
177
+ id: aliasId(priority)
178
+ }
179
+ });
180
+ const view = views.find(item => item.priority === priority);
181
+ notify(useBestNotice(priority, view?.modelID ?? pickFromName(view?.name ?? "")));
182
+ } catch (error) {
183
+ notify(`use-best failed: ${error instanceof Error ? error.message : String(error)}`, "error");
184
+ }
185
+ }
186
+
187
+ // Switch the current session straight to a concrete model (the report
188
+ // dialog's selection).
189
+ async function switchToModel(sessionID, id) {
190
+ try {
191
+ await context.client.session.switchModel({
192
+ sessionID,
193
+ model: {
194
+ providerID: ALIAS_PROVIDER,
195
+ id
196
+ }
197
+ });
198
+ // F-20: `id` here is the raw catalog id (the switch needs it) — the
199
+ // toast shows the flattened form so it cannot be line-injected.
200
+ notify(`switched to openrouter/${sanitizeCatalogText(id)}`);
201
+ } catch (error) {
202
+ notify(`switch failed: ${error instanceof Error ? error.message : String(error)}`, "error");
203
+ }
204
+ }
205
+
206
+ // The custom list dialog. The host's `dialog.select` (SDK 2.0.16
207
+ // runtime) renders one GROUPED box per category with one cursor each —
208
+ // its search flattening (verified against main) does not exist in this
209
+ // build — so three simultaneous cursors move independently. This
210
+ // renderer replaces it: `dialog.show` + our own rows — one cursor,
211
+ // palette styling (bold title, dim esc, theme action colors on the
212
+ // selected row). The modal keymap layer cannot be registered from the
213
+ // render closure (context-less — "Keymap.Provider is missing", the same
214
+ // trap as setup scope), so the app slot's IN-TREE effect registers it
215
+ // while `listKeys` is armed, and the host unmounts it with the dialog.
216
+
217
+ // The keymap layer hand-off (see showListDialog): set while a list
218
+ // dialog is open, cleared on close; the app slot's in-tree effect maps
219
+ // it to a modal layer.
220
+ const [listKeys, setListKeys] = createSignal(null);
221
+ function showListDialog(title, rows, onPick) {
222
+ const selectable = rows.filter(row => row.value !== undefined);
223
+ if (selectable.length === 0) {
224
+ notify("nothing to select", "error");
225
+ return;
226
+ }
227
+ const initial = Math.max(0, selectable.findIndex(row => row.selected !== undefined));
228
+ // The DIALOG surface: the exact theme call the host's own dialogs make
229
+ // (dialog-select/dialog-alert) — uniform colors with the host chrome
230
+ // and the palette's heading color.
231
+ const theme = context.theme.surface("dialog");
232
+ const base = theme.text.base;
233
+ const muted = theme.text.muted;
234
+ const accent = theme.hue.accent[200];
235
+ const hiFg = theme.text.action.primary.focused;
236
+ const hiBg = theme.background.action.primary.focused;
237
+ // The native rows' inactive background: explicitly transparent, so the
238
+ // dialog surface shows through.
239
+ const transparent = RGBA.fromInts(0, 0, 0, 0);
240
+
241
+ // This dialog's lifecycle token: its generation in the module-scope
242
+ // counter. Replaced dialogs are closed first (below), so a close()
243
+ // that finds a newer generation knows it lost ownership and must not
244
+ // touch the armed dialog's shared state.
245
+ armedClose?.();
246
+ const generation = ++dialogGeneration;
247
+ let alive = true;
248
+ // The cursor as a SIGNAL: the JSX closure reads it reactively, so
249
+ // every move repaints the dialog (a plain `let` painted once and
250
+ // never updated — the cursor sat on row 1 however many times the
251
+ // keymap moved the internal index; observed live, 2026-10-11).
252
+ const [cursor, setCursor] = createSignal(initial);
253
+ const close = () => {
254
+ if (!alive) return;
255
+ alive = false;
256
+ // A newer dialog was armed since this one: its own close owns the
257
+ // shared state (the keymap layer, the host dialog) now — clearing
258
+ // here would kill ITS keyboard and surface.
259
+ if (generation !== dialogGeneration) return;
260
+ armedClose = null;
261
+ setListKeys(null);
262
+ context.ui.dialog.clear();
263
+ };
264
+ // move(±1) walks the SELECTABLE rows only; heading rows never take
265
+ // the cursor. Wraps: one past the last is the first, one before the
266
+ // first is the last (the command palette's behavior).
267
+ const move = delta => {
268
+ setCursor((cursor() + delta + selectable.length) % selectable.length);
269
+ };
270
+ const pick = () => {
271
+ const row = selectable[cursor()];
272
+ close();
273
+ onPick(row.value);
274
+ };
275
+
276
+ // Arm the modal layer via the app slot's in-tree effect (see the app
277
+ // slot below); it registers on the next reactive run. This dialog is
278
+ // now the armed one (the lifecycle token above).
279
+ armedClose = close;
280
+ setListKeys({
281
+ move,
282
+ pick
283
+ });
284
+ context.ui.dialog.show(() => // Top/bottom blanks follow the native boxes: 1 with a tip line at
285
+ // the bottom (the host's dialog panel contributes the other top
286
+ // blank, so our own top padding is 0).
287
+ (() => {
288
+ var _el$2 = _$createElement("box"),
289
+ _el$3 = _$createElement("box"),
290
+ _el$4 = _$createElement("text"),
291
+ _el$5 = _$createElement("text"),
292
+ _el$7 = _$createElement("box"),
293
+ _el$8 = _$createElement("box"),
294
+ _el$9 = _$createElement("text");
295
+ _$insertNode(_el$2, _el$3);
296
+ _$insertNode(_el$2, _el$7);
297
+ _$insertNode(_el$2, _el$8);
298
+ _$setProp(_el$2, "paddingTop", 0);
299
+ _$setProp(_el$2, "paddingBottom", 1);
300
+ _$setProp(_el$2, "gap", 1);
301
+ _$insertNode(_el$3, _el$4);
302
+ _$insertNode(_el$3, _el$5);
303
+ _$setProp(_el$3, "paddingLeft", 4);
304
+ _$setProp(_el$3, "paddingRight", 4);
305
+ _$setProp(_el$3, "flexDirection", "row");
306
+ _$setProp(_el$3, "justifyContent", "space-between");
307
+ _$setProp(_el$4, "fg", base);
308
+ _$insert(_el$4, title);
309
+ _$insertNode(_el$5, _$createTextNode(`esc`));
310
+ _$setProp(_el$5, "fg", muted);
311
+ _$setProp(_el$5, "onMouseUp", () => close());
312
+ _$setProp(_el$7, "paddingLeft", 1);
313
+ _$setProp(_el$7, "paddingRight", 1);
314
+ _$setProp(_el$7, "flexDirection", "column");
315
+ _$insert(_el$7, () => {
316
+ let headings = 0;
317
+ return rows.map(row => {
318
+ if (row.headerLine !== undefined) {
319
+ // The table's column header: dim, cursor-skipped, and
320
+ // directly above the first section (no blank line).
321
+ return (() => {
322
+ var _el$1 = _$createElement("text");
323
+ _$setProp(_el$1, "fg", muted);
324
+ _$insert(_el$1, () => row.headerLine);
325
+ return _el$1;
326
+ })();
327
+ }
328
+ if (row.heading !== undefined) {
329
+ // The palette's heading recipe: accent hue, bold, one
330
+ // blank line above every heading but the first. Flush
331
+ // with the title: 1 (list inset) + 3 = 4.
332
+ headings += 1;
333
+ return (() => {
334
+ var _el$10 = _$createElement("box"),
335
+ _el$11 = _$createElement("text");
336
+ _$insertNode(_el$10, _el$11);
337
+ _$setProp(_el$10, "paddingTop", headings > 1 ? 1 : 0);
338
+ _$setProp(_el$10, "paddingLeft", 3);
339
+ _$setProp(_el$11, "fg", accent);
340
+ _$insert(_el$11, () => row.heading);
341
+ _$effect(_$p => _$setProp(_el$11, "attributes", TextAttributes.BOLD, _$p));
342
+ return _el$10;
343
+ })();
344
+ }
345
+ const active = row.value !== undefined && selectable[cursor()] === row;
346
+ // The row BOX carries the background: it stretches the
347
+ // list's full width, so the cursor is "justified" — from
348
+ // the 1-blank left inset to the 1-blank right inset —
349
+ // while the text stays at the content indent (4).
350
+ const nameFg = row.dim === true ? muted : active ? hiFg : base;
351
+ return (() => {
352
+ var _el$12 = _$createElement("box");
353
+ _$setProp(_el$12, "flexDirection", "row");
354
+ _$setProp(_el$12, "paddingLeft", 3);
355
+ _$setProp(_el$12, "paddingRight", 3);
356
+ _$setProp(_el$12, "backgroundColor", active ? hiBg : transparent);
357
+ _$insert(_el$12, (() => {
358
+ var _c$ = _$memo(() => row.columns !== undefined);
359
+ return () => _c$() ? (() => {
360
+ var _el$13 = _$createElement("text"),
361
+ _el$14 = _$createElement("span"),
362
+ _el$15 = _$createElement("span");
363
+ _$insertNode(_el$13, _el$14);
364
+ _$insertNode(_el$13, _el$15);
365
+ _$setProp(_el$14, "style", {
366
+ fg: nameFg
367
+ });
368
+ _$insert(_el$14, (() => {
369
+ var _c$2 = _$memo(() => !!(active && row.dim !== true));
370
+ return () => _c$2() ? (() => {
371
+ var _el$16 = _$createElement("b");
372
+ _$insert(_el$16, () => row.label);
373
+ return _el$16;
374
+ })() : row.label;
375
+ })());
376
+ _$insert(_el$15, () => row.columns);
377
+ _$effect(_$p => _$setProp(_el$15, "style", {
378
+ fg: row.dim === true || !active ? muted : hiFg
379
+ }, _$p));
380
+ return _el$13;
381
+ })() : (() => {
382
+ var _el$17 = _$createElement("text");
383
+ _$setProp(_el$17, "fg", nameFg);
384
+ _$insert(_el$17, () => row.label);
385
+ _$effect(_$p => _$setProp(_el$17, "attributes", active && row.dim !== true ? TextAttributes.BOLD : undefined, _$p));
386
+ return _el$17;
387
+ })();
388
+ })());
389
+ return _el$12;
390
+ })();
391
+ });
392
+ });
393
+ _$insertNode(_el$8, _el$9);
394
+ _$setProp(_el$8, "paddingLeft", 4);
395
+ _$insertNode(_el$9, _$createTextNode(`Details: https://canonical.github.io/model-compare`));
396
+ _$setProp(_el$9, "fg", muted);
397
+ _$effect(_$p => _$setProp(_el$4, "attributes", TextAttributes.BOLD, _$p));
398
+ return _el$2;
399
+ })(), close);
400
+ }
401
+
402
+ // The picks dialog: the three priorities, one cursor, palette styling.
403
+ // A snapshot at open time; unavailable priorities are dimmed — the
404
+ // cursor still reaches them, and choosing one is refused with a toast
405
+ // (switchToPriority's guard). Choosing an available priority switches
406
+ // the session to it.
407
+ function showPicksDialog() {
408
+ const views = aliases();
409
+ const rows = PRIORITIES.map(priority => {
410
+ const view = views.find(item => item.priority === priority);
411
+ const enabled = view !== undefined && view.enabled !== false;
412
+ return {
413
+ label: `${alignedPriority(priority)} → ${enabled ? pickOf(view) ?? "unavailable" : "disabled"}`,
414
+ value: priority,
415
+ dim: !enabled
416
+ };
417
+ });
418
+ showListDialog("Model Compare — current picks", rows, priority => {
419
+ void switchToPriority(priority);
420
+ });
421
+ }
422
+
423
+ // The report dialog: the ranked entries under bold-ish priority
424
+ // headings (palette category color, blank line above each — headings
425
+ // are skipped by the cursor), one cursor over all of them; choosing a
426
+ // model switches straight to it. Empty entries mean the command had no
427
+ // ranking data.
428
+ function showReportDialog(data) {
429
+ if (data.entries.length === 0) {
430
+ notify("no ranking data available yet", "error");
431
+ return;
432
+ }
433
+ // The approved table: one dim column header at the top (directly above
434
+ // the first section), section headings with a blank line above all but
435
+ // the first, and right-justified quality/price columns. No score
436
+ // column — the transcript's text report keeps it. Column widths are
437
+ // clamped (qual ≤ 8, $ in/out ≤ 16) and oversized cells are truncated
438
+ // to their clamped width, so with the name clamp below the row budget
439
+ // holds even for hostile/absurd column strings: what the clamp
440
+ // guarantees is that a row and the header stay within the dialog's
441
+ // fixed 52-char content width (hostile values get truncated rather
442
+ // than pushed into wrapping — the 40-char rankless fallback label did
443
+ // exactly that before the name clamp).
444
+ const labels = data.entries.map(entry => `${entry.rank === null ? "·" : entry.rank}. ${entry.id}${entry.fromFallback ? " (fallback)" : ""}`);
445
+ const labelOf = new Map(data.entries.map((entry, index) => [entry, labels[index]]));
446
+ const colW = (values, floor, cap) => Math.min(cap, Math.max(floor, ...values.map(value => value.length)));
447
+ const qualW = colW(data.entries.map(entry => entry.quality), "qual".length, 8);
448
+ const priceW = colW(data.entries.map(entry => entry.price), "$ in/out".length, 16);
449
+ // A cell wider than its clamped column is truncated to fit.
450
+ const cell = (value, width) => value.length > width ? value.slice(0, width - 1) + "…" : value;
451
+ // Prices use padEnd (not padStart): padded to a common width the
452
+ // right edges stay flush while the fixed 2-space gap between columns
453
+ // stays exactly 2 (padStart pushed each value right by its slack).
454
+ // The default dialog is 60 wide: 2 list insets + 6 row insets leave 52
455
+ // for row content; the header shares that budget (plus its 3-indent).
456
+ const fixed = 2 + qualW + 2 + priceW;
457
+ const nameW = Math.max(8, Math.min(52 - fixed, Math.max(...labels.map(label => label.length))));
458
+ const trim = label => label.length > nameW ? label.slice(0, nameW - 1) + "…" : label;
459
+ const headerLine = " " + " ".repeat(nameW) + " " + "qual".padStart(qualW) + " " + "$ in/out".padEnd(priceW);
460
+ const rows = [{
461
+ label: "",
462
+ headerLine
463
+ }];
464
+ for (const priority of PRIORITIES) {
465
+ const section = data.entries.filter(entry => entry.priority === priority);
466
+ if (section.length === 0) continue;
467
+ rows.push({
468
+ heading: priority,
469
+ label: ""
470
+ });
471
+ for (const entry of section) {
472
+ rows.push({
473
+ label: trim(labelOf.get(entry) ?? "").padEnd(nameW),
474
+ columns: " " + cell(entry.quality, qualW).padStart(qualW) + " " + cell(entry.price, priceW).padEnd(priceW),
475
+ // F-20 id split: the row DISPLAYS the sanitized id (in `label`)
476
+ // but picks `value`, the raw catalog id switchToModel needs.
477
+ value: entry.value,
478
+ selected: entry.current ? entry.value : undefined
479
+ });
480
+ }
481
+ }
482
+ showListDialog(`Model Compare — top 5 (data age: ${data.age ?? "unknown"})`, rows, id => void switchToModel(data.sessionID, id));
483
+ }
484
+
485
+ // The report dialog rides the /model-compare command's RPC event: ONE
486
+ // command serves both worlds — headless gets the transcript notice, the
487
+ // TUI gets this instant selectable dialog. Filtered to this plugin's
488
+ // location and the currently viewed session, so a headless run's report
489
+ // never pops a dialog over an unrelated session.
490
+ const offReportEvent = context.data.listen(({
491
+ details
492
+ }) => {
493
+ const event = details;
494
+ if (event.type !== REPORT_EVENT) return;
495
+ if (typeof event.location?.directory === "string" && here !== undefined && event.location.directory !== here) return;
496
+ const data = event.data;
497
+ if (typeof data?.sessionID !== "string" || data.sessionID !== currentSessionID()) return;
498
+ if (!Array.isArray(data.entries)) return;
499
+ const entries = data.entries.flatMap(raw => {
500
+ const entry = raw;
501
+ if (typeof entry?.id !== "string" || typeof entry?.value !== "string" || typeof entry?.priority !== "string") return [];
502
+ return [{
503
+ priority: entry.priority,
504
+ rank: typeof entry.rank === "number" ? entry.rank : null,
505
+ // The F-20 split (see ReportEntry): `id` sanitized for display,
506
+ // `value` the raw catalog id for switching.
507
+ id: entry.id,
508
+ value: entry.value,
509
+ score: typeof entry.score === "string" ? entry.score : "-",
510
+ quality: typeof entry.quality === "string" ? entry.quality : "-",
511
+ price: typeof entry.price === "string" ? entry.price : "-",
512
+ current: entry.current === true,
513
+ fromFallback: entry.fromFallback === true
514
+ }];
515
+ });
516
+ const eventSessionID = data.sessionID;
517
+ // Ownerless callback: park the report on the signal — the app slot's
518
+ // in-tree effect opens the dialog (see pendingReport above).
519
+ setPendingReport({
520
+ sessionID: eventSessionID,
521
+ age: typeof data.age === "string" ? data.age : null,
522
+ entries
523
+ });
524
+ });
525
+ createRoot(dispose => {
526
+ disposeRoot = dispose;
527
+
528
+ // Keep the data layer fresh: a belt-and-braces re-sync, since the
529
+ // registry also updates reactively. Cleared on unload below.
530
+ void context.data.location.model.sync(location);
531
+ resyncTimer = setInterval(() => {
532
+ void context.data.location.model.sync(location).catch(() => {});
533
+ }, RESYNC_MS);
534
+
535
+ // The status line in both footers, in the theme's text color (read
536
+ // inside the render, so a theme switch re-renders).
537
+ disposePromptFooter = context.ui.slot({
538
+ append: "prompt.footer.status",
539
+ render: () => _$createComponent(StatusLine, {
540
+ line: line,
541
+ get fg() {
542
+ return context.theme.text.base;
543
+ }
544
+ })
545
+ });
546
+ disposeHomeFooter = context.ui.slot({
547
+ append: "home.footer.status",
548
+ render: () => _$createComponent(StatusLine, {
549
+ line: line,
550
+ get fg() {
551
+ return context.theme.text.base;
552
+ }
553
+ })
554
+ });
555
+
556
+ // Toast when a pick flips. A real per-priority diff over the pick ids
557
+ // — the previous pick string or null per priority — so only the
558
+ // priorities whose pick ACTUALLY changed toast: a "(fallback)"
559
+ // name-marker change with the same pick is not a flip, and going
560
+ // disabled (enabled→disabled, pick → null) is never toasted. The first
561
+ // observation after load only seeds the map (a load is not a change);
562
+ // when several flips land in one reload, each changed priority gets
563
+ // its own toast.
564
+ const previousPicks = new Map();
565
+ createEffect(() => {
566
+ const byPriority = new Map(aliases().map(view => [view.priority, view]));
567
+ for (const p of PRIORITIES) {
568
+ const view = byPriority.get(p);
569
+ // The pick IDENTITY: the "(fallback)" marker is a name decoration,
570
+ // not part of the pick — compare and toast without it. No
571
+ // observable pick (disabled, or a name with no arrow and no
572
+ // modelID) reads as null.
573
+ const pick = view === undefined || view.enabled === false ? null : (view.modelID ?? pickFromName(view.name))?.replace(/ \(fallback\)$/, "") ?? null;
574
+ const seen = previousPicks.has(p);
575
+ const before = previousPicks.get(p) ?? null;
576
+ previousPicks.set(p, pick);
577
+ if (!seen || pick === null || pick === before) continue;
578
+ // F-20: the pick is catalog-derived — flattened for the toast.
579
+ notify(`Model Compare: ${p} → ${sanitizeCatalogText(pick)}`);
580
+ }
581
+ });
582
+ });
583
+
584
+ // The keymap layers. They CANNOT be registered from setup's scope: the
585
+ // host's layer bridge wraps registration in a createRoot attached to the
586
+ // calling owner, and Solid context does not flow through a created root
587
+ // — `Keymap.createLayer`'s context lookup fails ("Keymap.Provider is
588
+ // missing") whether the call sits inside our own createRoot (v1, the
589
+ // failed load) or at setup scope (v2, silently swallowed: plugin console
590
+ // output reaches no log). The documented pattern is an "app" slot whose
591
+ // render registers the layers — the render runs INSIDE the mounted
592
+ // provider tree, mounts once, and lives for the whole TUI session. Each
593
+ // layer registers in its own try/catch whose failure surfaces as a
594
+ // toast: a registration failure must be visible, and one layer's failure
595
+ // must not take the others down.
596
+ const layerFailure = (what, error) => {
597
+ notify(`${what} failed to register: ${error instanceof Error ? error.message : String(error)}`, "error");
598
+ };
599
+
600
+ // The picks dialog's command. No slash: /use-best already covers the
601
+ // switching surface (the server command is slash-routable; the TUI's
602
+ // duplicate of it was removed for exactly that reason).
603
+ const picksCommand = {
604
+ id: "model-compare.picks",
605
+ title: "Model-compare top picks",
606
+ group: "Model Compare",
607
+ bind: "alt+m",
608
+ palette: true,
609
+ run: () => {
610
+ if (currentSessionID() === null) {
611
+ notify("no session yet — submit any prompt to create one; picking a priority switches that session", "error");
612
+ return;
613
+ }
614
+ showPicksDialog();
615
+ }
616
+ };
617
+
618
+ // The report command: posts the server's /model-compare command, whose
619
+ // RPC event opens the selectable report dialog here (and the transcript
620
+ // keeps the notice). A session is required: the dialog's whole point is
621
+ // switching a session's model, and with no session open there is
622
+ // nothing to switch — the pinned runtime (2.0.16) exposes no composer-
623
+ // model setter to plugins, so there is nothing to change either. The
624
+ // refusal says why and how.
625
+ const reportCommand = {
626
+ id: "model-compare.report",
627
+ title: "Model-compare report",
628
+ group: "Model Compare",
629
+ bind: "alt+shift+m",
630
+ palette: true,
631
+ run: async () => {
632
+ const sessionID = currentSessionID();
633
+ if (sessionID === null) {
634
+ notify("no session yet — submit any prompt to create one; a report pick switches that session", "error");
635
+ return;
636
+ }
637
+ try {
638
+ await context.client.session.command({
639
+ sessionID,
640
+ name: "model-compare",
641
+ text: ""
642
+ });
643
+ } catch (error) {
644
+ notify(`report failed: ${error instanceof Error ? error.message : String(error)}`, "error");
645
+ }
646
+ }
647
+ };
648
+
649
+ // The palette-only use-best: the immediate-toast variant. The SLASH
650
+ // surface is left to the server command (one entry everywhere); the
651
+ // palette is TUI-layer territory, where this variant's instant feedback
652
+ // lives.
653
+ const useBestCommand = {
654
+ id: "model-compare.use-best",
655
+ title: "Use best model",
656
+ group: "Model Compare",
657
+ palette: true,
658
+ run: async input => {
659
+ const decision = useBestDecision({
660
+ argument: input ?? "",
661
+ defaultPriority: await readDefaultPriority(),
662
+ enabled: p => aliases().some(view => view.priority === p && view.enabled)
663
+ });
664
+ if (!decision.ok) {
665
+ notify(decision.message, "error");
666
+ return;
667
+ }
668
+ await switchToPriority(decision.priority);
669
+ }
670
+ };
671
+ disposeAppSlot = context.ui.slot({
672
+ append: "app",
673
+ render: () => {
674
+ // The in-tree effect that opens the report dialog when the
675
+ // /model-compare event parks data on pendingReport (see the signal
676
+ // above): dialogs mounted from inside the tree get their keyboard;
677
+ // ownerless mounts do not (observed live, 2026-10-11).
678
+ createEffect(() => {
679
+ const pending = pendingReport();
680
+ if (pending === null) return;
681
+ setPendingReport(null);
682
+ showReportDialog(pending);
683
+ });
684
+ // The list dialogs' modal keymap layer (see showListDialog):
685
+ // registered HERE, inside the tree, while a list dialog is open —
686
+ // the render closure is context-less, so registration from there
687
+ // throws "Keymap.Provider is missing" (observed live 2026-10-11).
688
+ createEffect(() => {
689
+ const keys = listKeys();
690
+ try {
691
+ if (keys === null) return;
692
+ context.keymap.layer(() => ({
693
+ mode: "modal",
694
+ commands: [{
695
+ id: "model-compare.list.down",
696
+ title: "Next row",
697
+ group: "Dialog",
698
+ bind: "down",
699
+ run: () => keys.move(1)
700
+ }, {
701
+ id: "model-compare.list.up",
702
+ title: "Previous row",
703
+ group: "Dialog",
704
+ bind: "up",
705
+ run: () => keys.move(-1)
706
+ }, {
707
+ id: "model-compare.list.pick",
708
+ title: "Select",
709
+ group: "Dialog",
710
+ bind: "return",
711
+ run: () => keys.pick()
712
+ }]
713
+ }));
714
+ } catch (error) {
715
+ setListKeys(null);
716
+ layerFailure("the list dialog keymap layer", error);
717
+ }
718
+ });
719
+ // The event callbacks (data.listen) run ownerless; the layer
720
+ // registration below carries its own failure toast.
721
+ try {
722
+ context.keymap.layer(() => ({
723
+ mode: "global",
724
+ commands: [useBestCommand, picksCommand, reportCommand]
725
+ }));
726
+ } catch (error) {
727
+ layerFailure("the model-compare keymap layer", error);
728
+ }
729
+ return null;
730
+ }
731
+ });
732
+ return () => {
733
+ if (resyncTimer !== undefined) clearInterval(resyncTimer);
734
+ offReportEvent();
735
+ disposeRoot();
736
+ disposeAppSlot();
737
+ disposeHomeFooter();
738
+ disposePromptFooter();
739
+ };
740
+ }
741
+ });