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

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