model-compare-opencode-plugin 0.1.1 → 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/README.md CHANGED
@@ -82,7 +82,7 @@ Commands:
82
82
  | `return` | select the highlighted row (picks dialog: switch to that priority) |
83
83
  | `esc` | close the dialog |
84
84
 
85
- Both dialogs need an open session — with none, a toast explains and nothing else happens. The TUI has no `/use-best` keybinding: the slash command already covers switching.
85
+ Both dialogs open anywhere — including the opening screen, where no session exists yet. There, picking a row (a priority in the picks dialog, a model in the report) creates a new session already bound to that model and opens it; merely opening a dialog never creates a session. With a session open, a pick switches that session as before. The TUI has no `/use-best` keybinding: the slash command already covers switching.
86
86
 
87
87
  Status: the footer shows `MC: balanced → <pick>` (balanced only — `disabled`/`unavailable`/`(fallback)` as applicable; all three priorities live in the picks dialog). Toasts announce `/use-best` outcomes and pick changes — never for the first observation after load. Headless, all feedback is synthetic transcript messages at the next turn.
88
88
 
package/dist/tui.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // header for why the published package ships precompiled JSX.
4
4
  import { createComponent as _$createComponent } from "@opentui/solid";
5
5
  import { memo as _$memo } from "@opentui/solid";
6
+ import { use as _$use } from "@opentui/solid";
6
7
  import { createTextNode as _$createTextNode } from "@opentui/solid";
7
8
  import { insertNode as _$insertNode } from "@opentui/solid";
8
9
  import { setProp as _$setProp } from "@opentui/solid";
@@ -19,12 +20,17 @@ import { createElement as _$createElement } from "@opentui/solid";
19
20
  // names current, so the registry IS the state; no messaging involved);
20
21
  // * the picks dialog (alt+m): a select over the three priorities —
21
22
  // choosing one switches the session to it, with the same decision logic
22
- // as the server command;
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);
23
27
  // * the report dialog (alt+shift+m): the /model-compare command emits its
24
28
  // structured report as an RPC event, and this half opens it as a
25
29
  // selectable dialog (selecting a ranked model switches straight to it) —
26
30
  // one command serves both worlds, so no TUI duplicate of the server
27
- // command exists;
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;
28
34
  // * a toast when a pick flips (at most one per changed priority per
29
35
  // reload, and never for the first observation after load).
30
36
  //
@@ -52,6 +58,7 @@ import { TextAttributes, RGBA } from "@opentui/core";
52
58
  import { createEffect, createMemo, createRoot, createSignal } from "solid-js";
53
59
  import { ALIAS_PROVIDER, PRIORITIES, alignedPriority, aliasId, pickOf, pickFromName, readAliasViews, statusLine, useBestDecision, useBestNotice, useBestRefusal } from "../src/opencode/usebest";
54
60
  import { sanitizeCatalogText } from "../src/opencode/commands";
61
+ import { ModelCompare } from "../src/rpc";
55
62
  const TOAST_MS = 6_000;
56
63
  const RESYNC_MS = 30_000;
57
64
 
@@ -71,6 +78,11 @@ const REPORT_EVENT = "rpc.model-compare.report";
71
78
  // and arming a new dialog closes whatever is still armed first.
72
79
  let dialogGeneration = 0;
73
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;
74
86
  function StatusLine(props) {
75
87
  return (() => {
76
88
  var _el$ = _$createElement("text");
@@ -79,6 +91,73 @@ function StatusLine(props) {
79
91
  return _el$;
80
92
  })();
81
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
+ }
82
161
  export default Plugin.define({
83
162
  id: "model-compare-tui",
84
163
  async setup(context) {
@@ -90,6 +169,28 @@ export default Plugin.define({
90
169
  variant,
91
170
  duration: TOAST_MS
92
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);
93
194
  let disposeRoot = () => {};
94
195
  // The three slot disposers (the two footers and the app slot below):
95
196
  // slots claimed but never released survive the plugin's unload and keep
@@ -155,21 +256,29 @@ export default Plugin.define({
155
256
  return "balanced";
156
257
  }
157
258
 
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.
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.
161
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 ?? ""));
162
273
  const sessionID = currentSessionID();
163
274
  if (sessionID === null) {
164
- notify("Open a session first", "error");
275
+ await createSessionOnModel({
276
+ providerID: ALIAS_PROVIDER,
277
+ id: aliasId(priority)
278
+ }, notice);
165
279
  return;
166
280
  }
167
281
  try {
168
- const views = aliases();
169
- if (!views.some(view => view.priority === priority && view.enabled)) {
170
- notify(useBestRefusal(priority), "error");
171
- return;
172
- }
173
282
  await context.client.session.switchModel({
174
283
  sessionID,
175
284
  model: {
@@ -177,8 +286,7 @@ export default Plugin.define({
177
286
  id: aliasId(priority)
178
287
  }
179
288
  });
180
- const view = views.find(item => item.priority === priority);
181
- notify(useBestNotice(priority, view?.modelID ?? pickFromName(view?.name ?? "")));
289
+ notify(notice);
182
290
  } catch (error) {
183
291
  notify(`use-best failed: ${error instanceof Error ? error.message : String(error)}`, "error");
184
292
  }
@@ -203,6 +311,27 @@ export default Plugin.define({
203
311
  }
204
312
  }
205
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
+
206
335
  // The custom list dialog. The host's `dialog.select` (SDK 2.0.16
207
336
  // runtime) renders one GROUPED box per category with one cursor each —
208
337
  // its search flattening (verified against main) does not exist in this
@@ -250,14 +379,72 @@ export default Plugin.define({
250
379
  // never updated — the cursor sat on row 1 however many times the
251
380
  // keymap moved the internal index; observed live, 2026-10-11).
252
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
+ };
253
431
  const close = () => {
254
432
  if (!alive) return;
255
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
+ }
256
442
  // 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.
443
+ // shared state (the keymap layer, the host dialog, the reveal hook)
444
+ // now — clearing here would kill ITS keyboard and surface.
259
445
  if (generation !== dialogGeneration) return;
260
446
  armedClose = null;
447
+ armedReveal = null;
261
448
  setListKeys(null);
262
449
  context.ui.dialog.clear();
263
450
  };
@@ -266,6 +453,10 @@ export default Plugin.define({
266
453
  // first is the last (the command palette's behavior).
267
454
  const move = delta => {
268
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);
269
460
  };
270
461
  const pick = () => {
271
462
  const row = selectable[cursor()];
@@ -277,6 +468,7 @@ export default Plugin.define({
277
468
  // slot below); it registers on the next reactive run. This dialog is
278
469
  // now the armed one (the lifecycle token above).
279
470
  armedClose = close;
471
+ armedReveal = revealOnFrame;
280
472
  setListKeys({
281
473
  move,
282
474
  pick
@@ -289,7 +481,7 @@ export default Plugin.define({
289
481
  _el$3 = _$createElement("box"),
290
482
  _el$4 = _$createElement("text"),
291
483
  _el$5 = _$createElement("text"),
292
- _el$7 = _$createElement("box"),
484
+ _el$7 = _$createElement("scrollbox"),
293
485
  _el$8 = _$createElement("box"),
294
486
  _el$9 = _$createElement("text");
295
487
  _$insertNode(_el$2, _el$3);
@@ -309,9 +501,18 @@ export default Plugin.define({
309
501
  _$insertNode(_el$5, _$createTextNode(`esc`));
310
502
  _$setProp(_el$5, "fg", muted);
311
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);
312
511
  _$setProp(_el$7, "paddingLeft", 1);
313
512
  _$setProp(_el$7, "paddingRight", 1);
314
- _$setProp(_el$7, "flexDirection", "column");
513
+ _$setProp(_el$7, "scrollbarOptions", {
514
+ visible: false
515
+ });
315
516
  _$insert(_el$7, () => {
316
517
  let headings = 0;
317
518
  return rows.map(row => {
@@ -327,14 +528,17 @@ export default Plugin.define({
327
528
  }
328
529
  if (row.heading !== undefined) {
329
530
  // 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.
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;
332
536
  headings += 1;
333
537
  return (() => {
334
538
  var _el$10 = _$createElement("box"),
335
539
  _el$11 = _$createElement("text");
336
540
  _$insertNode(_el$10, _el$11);
337
- _$setProp(_el$10, "paddingTop", headings > 1 ? 1 : 0);
541
+ _$setProp(_el$10, "paddingTop", paddingTop);
338
542
  _$setProp(_el$10, "paddingLeft", 3);
339
543
  _$setProp(_el$11, "fg", accent);
340
544
  _$insert(_el$11, () => row.heading);
@@ -394,16 +598,34 @@ export default Plugin.define({
394
598
  _$setProp(_el$8, "paddingLeft", 4);
395
599
  _$insertNode(_el$9, _$createTextNode(`Details: https://canonical.github.io/model-compare`));
396
600
  _$setProp(_el$9, "fg", muted);
397
- _$effect(_$p => _$setProp(_el$4, "attributes", TextAttributes.BOLD, _$p));
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
+ });
398
611
  return _el$2;
399
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
+ });
400
621
  }
401
622
 
402
623
  // The picks dialog: the three priorities, one cursor, palette styling.
403
624
  // A snapshot at open time; unavailable priorities are dimmed — the
404
625
  // cursor still reaches them, and choosing one is refused with a toast
405
626
  // (switchToPriority's guard). Choosing an available priority switches
406
- // the session to it.
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).
407
629
  function showPicksDialog() {
408
630
  const views = aliases();
409
631
  const rows = PRIORITIES.map(priority => {
@@ -479,7 +701,18 @@ export default Plugin.define({
479
701
  });
480
702
  }
481
703
  }
482
- showListDialog(`Model Compare — top 5 (data age: ${data.age ?? "unknown"})`, rows, id => void switchToModel(data.sessionID, id));
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
+ });
483
716
  }
484
717
 
485
718
  // The report dialog rides the /model-compare command's RPC event: ONE
@@ -496,23 +729,7 @@ export default Plugin.define({
496
729
  const data = event.data;
497
730
  if (typeof data?.sessionID !== "string" || data.sessionID !== currentSessionID()) return;
498
731
  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
- });
732
+ const entries = toReportEntries(data.entries);
516
733
  const eventSessionID = data.sessionID;
517
734
  // Ownerless callback: park the report on the signal — the app slot's
518
735
  // in-tree effect opens the dialog (see pendingReport above).
@@ -599,29 +816,27 @@ export default Plugin.define({
599
816
 
600
817
  // The picks dialog's command. No slash: /use-best already covers the
601
818
  // switching surface (the server command is slash-routable; the TUI's
602
- // duplicate of it was removed for exactly that reason).
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).
603
822
  const picksCommand = {
604
823
  id: "model-compare.picks",
605
824
  title: "Model-compare top picks",
606
825
  group: "Model Compare",
607
826
  bind: "alt+m",
608
827
  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
- }
828
+ run: () => showPicksDialog()
616
829
  };
617
830
 
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.
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.
625
840
  const reportCommand = {
626
841
  id: "model-compare.report",
627
842
  title: "Model-compare report",
@@ -630,15 +845,29 @@ export default Plugin.define({
630
845
  palette: true,
631
846
  run: async () => {
632
847
  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");
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
+ }
635
858
  return;
636
859
  }
637
860
  try {
638
- await context.client.session.command({
639
- sessionID,
640
- name: "model-compare",
641
- text: ""
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)
642
871
  });
643
872
  } catch (error) {
644
873
  notify(`report failed: ${error instanceof Error ? error.message : String(error)}`, "error");
@@ -730,7 +959,13 @@ export default Plugin.define({
730
959
  }
731
960
  });
732
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?.();
733
967
  if (resyncTimer !== undefined) clearInterval(resyncTimer);
968
+ context.renderer.off("resize", onRendererResize);
734
969
  offReportEvent();
735
970
  disposeRoot();
736
971
  disposeAppSlot();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "model-compare-opencode-plugin",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "OpenCode plugin that switches OpenRouter model aliases to the best value-for-money pick from canonical/model-compare's ranking (balanced, price, quality).",
5
5
  "keywords": [
6
6
  "opencode",
package/src/tui.tsx CHANGED
@@ -8,12 +8,17 @@
8
8
  // names current, so the registry IS the state; no messaging involved);
9
9
  // * the picks dialog (alt+m): a select over the three priorities —
10
10
  // choosing one switches the session to it, with the same decision logic
11
- // as the server command;
11
+ // as the server command; with no session open (the opening screen)
12
+ // choosing one CREATES a session already bound to that alias and opens
13
+ // it, so the shortcuts work from the first keystroke (merely OPENING a
14
+ // dialog never creates a session);
12
15
  // * the report dialog (alt+shift+m): the /model-compare command emits its
13
16
  // structured report as an RPC event, and this half opens it as a
14
17
  // selectable dialog (selecting a ranked model switches straight to it) —
15
18
  // one command serves both worlds, so no TUI duplicate of the server
16
- // command exists;
19
+ // command exists; with no session open the report is fetched directly
20
+ // over the plugin's RPC (no session command to post it to) and a pick
21
+ // creates a session bound to that model;
17
22
  // * a toast when a pick flips (at most one per changed priority per
18
23
  // reload, and never for the first observation after load).
19
24
  //
@@ -39,6 +44,7 @@ import { TextAttributes, RGBA } from "@opentui/core"
39
44
  // the package's solid-js JSX augmentation without a runtime import — the TUI
40
45
  // host already provides the runtime side.
41
46
  import type { JSX } from "@opentui/solid"
47
+ import type { ScrollBoxRenderable } from "@opentui/core"
42
48
  import {
43
49
  createEffect,
44
50
  createMemo,
@@ -63,6 +69,7 @@ import {
63
69
  } from "./opencode/usebest"
64
70
  import type { ReportEntry } from "./opencode/commands"
65
71
  import { sanitizeCatalogText } from "./opencode/commands"
72
+ import { ModelCompare } from "./rpc"
66
73
 
67
74
  const TOAST_MS = 6_000
68
75
  const RESYNC_MS = 30_000
@@ -83,11 +90,105 @@ const REPORT_EVENT = "rpc.model-compare.report"
83
90
  // and arming a new dialog closes whatever is still armed first.
84
91
  let dialogGeneration = 0
85
92
  let armedClose: (() => void) | null = null
93
+ // The armed dialog's reveal hook (see showListDialog): its own
94
+ // revealOnFrame, exposed module-scope so the setup-scope resize handler can
95
+ // re-reveal the cursor when a resize re-caps the row area under it. Set
96
+ // when a dialog is armed, cleared by its close() alongside armedClose.
97
+ let armedReveal: ((center: boolean) => void) | null = null
86
98
 
87
99
  function StatusLine(props: { line: () => string; fg?: string }) {
88
100
  return <text fg={props.fg}>{props.line()}</text>
89
101
  }
90
102
 
103
+ // One list-dialog row (see showListDialog).
104
+ type ListRow = {
105
+ // Section headings render in the palette's category color, and are
106
+ // skipped by the cursor.
107
+ heading?: string
108
+ // A pre-built column-header line (the report table): dim, skipped by
109
+ // the cursor, and never gets a blank line above it.
110
+ headerLine?: string
111
+ label: string
112
+ // Trailing dim right-justified columns (the report table); when
113
+ // present, label renders in its own span so the two tones can differ.
114
+ columns?: string
115
+ selected?: string
116
+ value?: string
117
+ dim?: boolean
118
+ }
119
+
120
+ // The rows' ONE height rule, shared by three places that must agree or the
121
+ // scroll lands on the wrong line: the rows' JSX (the heading's blank line
122
+ // above), rowOffset's arithmetic and the row-area cap's content measure. A
123
+ // header-line row renders as its text ONLY — the JSX checks headerLine
124
+ // before heading — so a row carrying both fields counts the way it renders,
125
+ // never as both. Every other row is one line except the second and later
126
+ // headings, which carry one blank line above (the palette's heading
127
+ // recipe).
128
+ function isHeadingRow(row: ListRow): boolean {
129
+ return row.headerLine === undefined && row.heading !== undefined
130
+ }
131
+ function rowLines(row: ListRow, headingsSoFar: number): number {
132
+ return isHeadingRow(row) && headingsSoFar > 0 ? 2 : 1
133
+ }
134
+ // The rows' total LINE count (not their row count — heading blanks are
135
+ // content too); `upto` measures the first rows alone, which is how rowOffset
136
+ // gets a row's y within the content.
137
+ function rowsHeight(rows: readonly ListRow[], upto: number = rows.length): number {
138
+ let y = 0
139
+ let headings = 0
140
+ for (let k = 0; k < upto; k++) {
141
+ const row = rows[k]!
142
+ y += rowLines(row, headings)
143
+ if (isHeadingRow(row)) headings += 1
144
+ }
145
+ return y
146
+ }
147
+
148
+ // The list dialogs' row-area cap, the host command palette's own formula:
149
+ // min(content LINES, floor(terminal height / 2) - 6) — the first argument is
150
+ // rowsHeight(rows), never the rows' count, or the cap clips tail rows even
151
+ // on a roomy terminal (with the scrollbar hidden). The host's dialog layer
152
+ // caps nothing itself and anchors UNcentered dialogs a quarter viewport
153
+ // down — without this cap an uncapped list overflows the viewport at large
154
+ // font sizes (the report dialog did exactly that). Exported for the unit
155
+ // rig.
156
+ export function cappedRowsHeight(contentLines: number, terminalHeight: number): number {
157
+ return Math.min(contentLines, Math.max(1, Math.floor(terminalHeight / 2) - 6))
158
+ }
159
+
160
+ // One report entry's defensive normalization, shared by the two paths that
161
+ // carry report data across a trust boundary (the /model-compare command's
162
+ // event and the TUI's direct RPC call): an entry missing any identity field
163
+ // is dropped, every other field falls back to its neutral value.
164
+ function toReportEntries(raw: unknown): ReportEntry[] {
165
+ if (!Array.isArray(raw)) return []
166
+ return raw.flatMap((item): ReportEntry[] => {
167
+ const entry = item as Partial<ReportEntry>
168
+ if (
169
+ typeof entry?.id !== "string" ||
170
+ typeof entry?.value !== "string" ||
171
+ typeof entry?.priority !== "string"
172
+ )
173
+ return []
174
+ return [
175
+ {
176
+ priority: entry.priority as Priority,
177
+ rank: typeof entry.rank === "number" ? entry.rank : null,
178
+ // The F-20 split (see ReportEntry): `id` sanitized for display,
179
+ // `value` the raw catalog id for switching.
180
+ id: entry.id,
181
+ value: entry.value,
182
+ score: typeof entry.score === "string" ? entry.score : "-",
183
+ quality: typeof entry.quality === "string" ? entry.quality : "-",
184
+ price: typeof entry.price === "string" ? entry.price : "-",
185
+ current: entry.current === true,
186
+ fromFallback: entry.fromFallback === true,
187
+ },
188
+ ]
189
+ })
190
+ }
191
+
91
192
  export default Plugin.define({
92
193
  id: "model-compare-tui",
93
194
  async setup(context) {
@@ -97,6 +198,25 @@ export default Plugin.define({
97
198
  const notify = (message: string, variant: "success" | "error" = "success") =>
98
199
  context.ui.toast.show({ title: "model-compare", message, variant, duration: TOAST_MS })
99
200
 
201
+ // The terminal's size, reactive: the renderer emits "resize" carrying the
202
+ // fresh dimensions, and the dialogs' row-area caps must track font-size
203
+ // and window changes the way the host's own dialogs do (the palette caps
204
+ // its list at a fraction of the viewport; the dialog layer gives plugin
205
+ // dialogs no cap of their own). Detached in the returned cleanup.
206
+ const [terminalSize, setTerminalSize] = createSignal({
207
+ width: context.renderer.width,
208
+ height: context.renderer.height,
209
+ })
210
+ const onRendererResize = (width: number, height: number) => {
211
+ setTerminalSize({ width, height })
212
+ // A resize re-caps the armed dialog's row area under its cursor: the
213
+ // reveal re-runs on the next frame (see showListDialog's
214
+ // revealOnFrame) or a shrunken viewport can leave the cursor below
215
+ // the fold until the next key move.
216
+ armedReveal?.(false)
217
+ }
218
+ context.renderer.on("resize", onRendererResize)
219
+
100
220
  let disposeRoot = () => {}
101
221
  // The three slot disposers (the two footers and the app slot below):
102
222
  // slots claimed but never released survive the plugin's unload and keep
@@ -177,27 +297,34 @@ export default Plugin.define({
177
297
  return "balanced"
178
298
  }
179
299
 
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.
300
+ // Switch to one of the plugin's aliases — the shared decision chain
301
+ // behind /use-best, the palette command and the picks dialog's
302
+ // selection. With a session open it switches THAT session; with none
303
+ // (the opening screen) it creates a session already bound to the alias
304
+ // and opens it — the pick is the session's starting model. A disabled
305
+ // alias refuses before anything is created.
183
306
  async function switchToPriority(priority: Priority): Promise<void> {
307
+ const views = aliases()
308
+ if (!views.some((view) => view.priority === priority && view.enabled)) {
309
+ notify(useBestRefusal(priority), "error")
310
+ return
311
+ }
312
+ const view = views.find((item) => item.priority === priority)
313
+ const notice = useBestNotice(priority, view?.modelID ?? pickFromName(view?.name ?? ""))
184
314
  const sessionID = currentSessionID()
185
315
  if (sessionID === null) {
186
- notify("Open a session first", "error")
316
+ await createSessionOnModel(
317
+ { providerID: ALIAS_PROVIDER, id: aliasId(priority) },
318
+ notice,
319
+ )
187
320
  return
188
321
  }
189
322
  try {
190
- const views = aliases()
191
- if (!views.some((view) => view.priority === priority && view.enabled)) {
192
- notify(useBestRefusal(priority), "error")
193
- return
194
- }
195
323
  await context.client.session.switchModel({
196
324
  sessionID,
197
325
  model: { providerID: ALIAS_PROVIDER, id: aliasId(priority) },
198
326
  })
199
- const view = views.find((item) => item.priority === priority)
200
- notify(useBestNotice(priority, view?.modelID ?? pickFromName(view?.name ?? "")))
327
+ notify(notice)
201
328
  } catch (error) {
202
329
  notify(`use-best failed: ${error instanceof Error ? error.message : String(error)}`, "error")
203
330
  }
@@ -219,6 +346,28 @@ export default Plugin.define({
219
346
  }
220
347
  }
221
348
 
349
+ // No session open (the opening screen's pick paths): create one already
350
+ // bound to the given model and navigate into it. Returns whether the
351
+ // session came up; a failure is toasted and nothing else happens — no
352
+ // half-open session to clean up.
353
+ async function createSessionOnModel(
354
+ model: { providerID: string; id: string },
355
+ success: string,
356
+ ): Promise<boolean> {
357
+ try {
358
+ const session = await context.client.session.create({ model })
359
+ context.ui.router.navigate({ type: "session", sessionID: session.id })
360
+ notify(success)
361
+ return true
362
+ } catch (error) {
363
+ notify(
364
+ `could not open a session: ${error instanceof Error ? error.message : String(error)}`,
365
+ "error",
366
+ )
367
+ return false
368
+ }
369
+ }
370
+
222
371
  // The custom list dialog. The host's `dialog.select` (SDK 2.0.16
223
372
  // runtime) renders one GROUPED box per category with one cursor each —
224
373
  // its search flattening (verified against main) does not exist in this
@@ -229,21 +378,6 @@ export default Plugin.define({
229
378
  // render closure (context-less — "Keymap.Provider is missing", the same
230
379
  // trap as setup scope), so the app slot's IN-TREE effect registers it
231
380
  // 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
381
 
248
382
  // The keymap layer hand-off (see showListDialog): set while a list
249
383
  // dialog is open, cleared on close; the app slot's in-tree effect maps
@@ -289,14 +423,75 @@ export default Plugin.define({
289
423
  // never updated — the cursor sat on row 1 however many times the
290
424
  // keymap moved the internal index; observed live, 2026-10-11).
291
425
  const [cursor, setCursor] = createSignal(initial)
426
+ // The scrollbox that carries the rows (see the JSX below); its ref
427
+ // lands when the host renders the dialog. The cursor's row is kept in
428
+ // view with the native lists' look-ahead: the content scrolls as soon
429
+ // as the cursor comes within three lines of an edge, and the cursor
430
+ // only touches an edge line when the content ends (the scroll clamps
431
+ // at its maximum).
432
+ //
433
+ // The row offsets are pure ARITHMETIC, not renderable geometry —
434
+ // rowsHeight (module scope) is the ONE row-height rule the rows' JSX
435
+ // and the row-area cap share. Reading the host's .y was tried and is
436
+ // unreliable across the plugin boundary — it reported the scrollbox's
437
+ // own position for every row — while scrollTop/scrollHeight/height
438
+ // behave as plain numbers.
439
+ let listScroll: ScrollBoxRenderable | undefined
440
+ let pendingReveal: (() => void) | undefined
441
+ const rowOffset = (index: number): number => rowsHeight(rows, index)
442
+ const reveal = (center: boolean) => {
443
+ const box = listScroll
444
+ if (box === undefined) return
445
+ const top = rowOffset(rows.indexOf(selectable[cursor()]))
446
+ const max = Math.max(0, box.scrollHeight - box.height)
447
+ // The look-ahead band, clamped to the window: at box.height <= 3 the
448
+ // three-line margin degenerates to plain ensure-visible (the row
449
+ // lands ON a window line instead of the overscroll an unclamped
450
+ // `box.height - 4` produced once it went negative — cap values of
451
+ // 1-3 are exactly what large font sizes reach).
452
+ const band = Math.min(3, Math.max(0, box.height - 1))
453
+ // Keep the row on-screen whatever the branch: the row's line must
454
+ // stay within [scrollTop, scrollTop + box.height - 1].
455
+ const visible = (target: number): number =>
456
+ Math.min(top, Math.max(top - box.height + 1, target))
457
+ let target = box.scrollTop
458
+ if (center) target = top - Math.floor(box.height / 2)
459
+ else if (top - box.scrollTop > box.height - 1 - band) target = visible(top - (box.height - 1 - band))
460
+ else if (top - box.scrollTop < band) target = visible(top - band)
461
+ box.scrollTo(Math.max(0, Math.min(target, max)))
462
+ }
463
+ // Positions exist only after a layout frame, so the reveal defers to
464
+ // the next rendered frame (the palette's own once(FRAME) +
465
+ // requestRender() pattern) rather than racing the renderer inside the
466
+ // key handler.
467
+ const revealOnFrame = (center: boolean) => {
468
+ if (pendingReveal !== undefined) context.renderer.off("frame", pendingReveal)
469
+ const run = () => {
470
+ pendingReveal = undefined
471
+ if (!alive) return
472
+ reveal(center)
473
+ }
474
+ pendingReveal = run
475
+ context.renderer.once("frame", run)
476
+ context.renderer.requestRender()
477
+ }
292
478
  const close = () => {
293
479
  if (!alive) return
294
480
  alive = false
481
+ // A pending frame reveal is THIS dialog's alone: cancel it here or
482
+ // the once("frame") listener (and its closure over the rows)
483
+ // outlives the dialog — it self-guarded on `alive`, but leaked until
484
+ // the next frame.
485
+ if (pendingReveal !== undefined) {
486
+ context.renderer.off("frame", pendingReveal)
487
+ pendingReveal = undefined
488
+ }
295
489
  // 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.
490
+ // shared state (the keymap layer, the host dialog, the reveal hook)
491
+ // now — clearing here would kill ITS keyboard and surface.
298
492
  if (generation !== dialogGeneration) return
299
493
  armedClose = null
494
+ armedReveal = null
300
495
  setListKeys(null)
301
496
  context.ui.dialog.clear()
302
497
  }
@@ -305,6 +500,10 @@ export default Plugin.define({
305
500
  // first is the last (the command palette's behavior).
306
501
  const move = (delta: number) => {
307
502
  setCursor((cursor() + delta + selectable.length) % selectable.length)
503
+ // Frame-deferred like every reveal — and it CANCELS a pending one,
504
+ // so the preselected row's open-time centering can never land after
505
+ // the user has already moved the cursor off it.
506
+ revealOnFrame(false)
308
507
  }
309
508
  const pick = () => {
310
509
  const row = selectable[cursor()]
@@ -316,6 +515,7 @@ export default Plugin.define({
316
515
  // slot below); it registers on the next reactive run. This dialog is
317
516
  // now the armed one (the lifecycle token above).
318
517
  armedClose = close
518
+ armedReveal = revealOnFrame
319
519
  setListKeys({ move, pick })
320
520
 
321
521
  context.ui.dialog.show(
@@ -332,7 +532,19 @@ export default Plugin.define({
332
532
  esc
333
533
  </text>
334
534
  </box>
335
- <box paddingLeft={1} paddingRight={1} flexDirection="column">
535
+ <scrollbox
536
+ ref={(element) => {
537
+ listScroll = element
538
+ // The report's preselected row (the current pick) opens
539
+ // centered in the scrollbox: scheduled from the ref, so the
540
+ // frame it waits for is the one that lays these rows out.
541
+ if (initial > 0) revealOnFrame(true)
542
+ }}
543
+ maxHeight={cappedRowsHeight(rowsHeight(rows), terminalSize().height)}
544
+ paddingLeft={1}
545
+ paddingRight={1}
546
+ scrollbarOptions={{ visible: false }}
547
+ >
336
548
  {(() => {
337
549
  let headings = 0
338
550
  return rows.map((row) => {
@@ -343,11 +555,14 @@ export default Plugin.define({
343
555
  }
344
556
  if (row.heading !== undefined) {
345
557
  // 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.
558
+ // blank line above every heading but the first — the
559
+ // blank is rowLines' call (the ONE row-height rule the
560
+ // offsets and the cap share), not a second copy of its
561
+ // condition. Flush with the title: 1 (list inset) + 3 = 4.
562
+ const paddingTop = rowLines(row, headings) - 1
348
563
  headings += 1
349
564
  return (
350
- <box paddingTop={headings > 1 ? 1 : 0} paddingLeft={3}>
565
+ <box paddingTop={paddingTop} paddingLeft={3}>
351
566
  <text fg={accent} attributes={TextAttributes.BOLD}>
352
567
  {row.heading}
353
568
  </text>
@@ -388,7 +603,7 @@ export default Plugin.define({
388
603
  )
389
604
  })
390
605
  })()}
391
- </box>
606
+ </scrollbox>
392
607
  <box paddingLeft={4}>
393
608
  <text fg={muted}>Details: https://canonical.github.io/model-compare</text>
394
609
  </box>
@@ -396,13 +611,19 @@ export default Plugin.define({
396
611
  ),
397
612
  close,
398
613
  )
614
+ // The palette's presentation: vertically centered at the medium width.
615
+ // The size/centered state persists across dialogs (the host's own set
616
+ // it on every open), so the explicit set also clears a stale xlarge a
617
+ // host dialog may have left behind.
618
+ context.ui.dialog.set({ size: "medium", centered: true })
399
619
  }
400
620
 
401
621
  // The picks dialog: the three priorities, one cursor, palette styling.
402
622
  // A snapshot at open time; unavailable priorities are dimmed — the
403
623
  // cursor still reaches them, and choosing one is refused with a toast
404
624
  // (switchToPriority's guard). Choosing an available priority switches
405
- // the session to it.
625
+ // the session to it — or, with no session open, creates one bound to
626
+ // it (the opening screen's path; opening the dialog never does).
406
627
  function showPicksDialog(): void {
407
628
  const views = aliases()
408
629
  const rows: ListRow[] = PRIORITIES.map((priority) => {
@@ -425,7 +646,10 @@ export default Plugin.define({
425
646
  // model switches straight to it. Empty entries mean the command had no
426
647
  // ranking data.
427
648
  function showReportDialog(data: {
428
- sessionID: string
649
+ // The session the report belongs to — null when the dialog was opened
650
+ // with no session (the opening screen's direct-RPC path); a pick then
651
+ // creates a session bound to the chosen model instead of switching.
652
+ sessionID: string | null
429
653
  age: string | null
430
654
  entries: ReportEntry[]
431
655
  }): void {
@@ -497,7 +721,18 @@ export default Plugin.define({
497
721
  showListDialog(
498
722
  `Model Compare — top 5 (data age: ${data.age ?? "unknown"})`,
499
723
  rows,
500
- (id) => void switchToModel(data.sessionID, id),
724
+ (id) => {
725
+ if (data.sessionID !== null) {
726
+ void switchToModel(data.sessionID, id)
727
+ return
728
+ }
729
+ // The opening screen: the pick becomes a NEW session's model —
730
+ // the same concrete model the in-session path switches to.
731
+ void createSessionOnModel(
732
+ { providerID: ALIAS_PROVIDER, id },
733
+ `switched to openrouter/${sanitizeCatalogText(id)}`,
734
+ )
735
+ },
501
736
  )
502
737
  }
503
738
 
@@ -522,30 +757,7 @@ export default Plugin.define({
522
757
  const data = event.data
523
758
  if (typeof data?.sessionID !== "string" || data.sessionID !== currentSessionID()) return
524
759
  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
- })
760
+ const entries = toReportEntries(data.entries)
549
761
  const eventSessionID = data.sessionID
550
762
  // Ownerless callback: park the report on the signal — the app slot's
551
763
  // in-tree effect opens the dialog (see pendingReport above).
@@ -628,29 +840,27 @@ export default Plugin.define({
628
840
 
629
841
  // The picks dialog's command. No slash: /use-best already covers the
630
842
  // switching surface (the server command is slash-routable; the TUI's
631
- // duplicate of it was removed for exactly that reason).
843
+ // duplicate of it was removed for exactly that reason). The dialog opens
844
+ // everywhere — with no session it shows the current picks, and only
845
+ // picking one creates a session bound to it (switchToPriority).
632
846
  const picksCommand: KeymapCommand = {
633
847
  id: "model-compare.picks",
634
848
  title: "Model-compare top picks",
635
849
  group: "Model Compare",
636
850
  bind: "alt+m",
637
851
  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
- },
852
+ run: () => showPicksDialog(),
645
853
  }
646
854
 
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.
855
+ // The report command: with a session open it posts the server's
856
+ // /model-compare command, whose RPC event opens the selectable report
857
+ // dialog here (and the transcript keeps the notice). With no session
858
+ // (the opening screen) there is nothing to post to, so the report is
859
+ // fetched straight from the plugin's report RPC — same builder, same
860
+ // entries — and picking a model creates a session bound to it (there is
861
+ // no composer-model setter to change instead; verified against the
862
+ // pinned runtime). The refusal-free opening also keeps the footer's
863
+ // status line honest: the shortcuts always DO something.
654
864
  const reportCommand: KeymapCommand = {
655
865
  id: "model-compare.report",
656
866
  title: "Model-compare report",
@@ -659,12 +869,25 @@ export default Plugin.define({
659
869
  palette: true,
660
870
  run: async () => {
661
871
  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")
872
+ if (sessionID !== null) {
873
+ try {
874
+ await context.client.session.command({ sessionID, name: "model-compare", text: "" })
875
+ } catch (error) {
876
+ notify(`report failed: ${error instanceof Error ? error.message : String(error)}`, "error")
877
+ }
664
878
  return
665
879
  }
666
880
  try {
667
- await context.client.session.command({ sessionID, name: "model-compare", text: "" })
881
+ const report: unknown = await context.client.rpc(ModelCompare).report(
882
+ {},
883
+ here === undefined ? undefined : { location: { directory: here } },
884
+ )
885
+ const data = report as { age?: unknown; entries?: unknown } | undefined
886
+ showReportDialog({
887
+ sessionID: null,
888
+ age: typeof data?.age === "string" ? data.age : null,
889
+ entries: toReportEntries(data?.entries),
890
+ })
668
891
  } catch (error) {
669
892
  notify(`report failed: ${error instanceof Error ? error.message : String(error)}`, "error")
670
893
  }
@@ -761,7 +984,13 @@ export default Plugin.define({
761
984
  })
762
985
 
763
986
  return () => {
987
+ // Disarm whatever dialog is still armed FIRST: its pending frame
988
+ // reveal and modal keyboard must not outlive the plugin (the
989
+ // module-scope armedClose/armedReveal survive setup's cleanup
990
+ // otherwise).
991
+ armedClose?.()
764
992
  if (resyncTimer !== undefined) clearInterval(resyncTimer)
993
+ context.renderer.off("resize", onRendererResize)
765
994
  offReportEvent()
766
995
  disposeRoot()
767
996
  disposeAppSlot()