model-compare-opencode-plugin 0.1.1 → 0.3.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/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
  //
@@ -51,7 +57,8 @@ import { TextAttributes, RGBA } from "@opentui/core";
51
57
 
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
- import { sanitizeCatalogText } from "../src/opencode/commands";
60
+ import { lastResponseLine, 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
 
@@ -62,6 +69,11 @@ const PACKAGE_FRAGMENT = "model-compare-opencode-plugin";
62
69
  // The RPC event the /model-compare command emits (see src/rpc.ts; the wire
63
70
  // name is prefixed with the RPC's id).
64
71
  const REPORT_EVENT = "rpc.model-compare.report";
72
+ // The event the runtime hook emits once per session when it would have
73
+ // steered a request but has no steering data (no catalog source) — the Q2
74
+ // fallback's runtime notification. The toast copy below is the whole message.
75
+ const CANNOT_STEER_EVENT = "rpc.model-compare.cannotSteer";
76
+ const CANNOT_STEER_COPY = "cannot steer this session; OpenRouter default routing applies";
65
77
 
66
78
  // The list dialogs' lifecycle token (see showListDialog): a module-scope
67
79
  // generation counter, bumped every time a dialog is armed, plus the armed
@@ -71,6 +83,11 @@ const REPORT_EVENT = "rpc.model-compare.report";
71
83
  // and arming a new dialog closes whatever is still armed first.
72
84
  let dialogGeneration = 0;
73
85
  let armedClose = null;
86
+ // The armed dialog's reveal hook (see showListDialog): its own
87
+ // revealOnFrame, exposed module-scope so the setup-scope resize handler can
88
+ // re-reveal the cursor when a resize re-caps the row area under it. Set
89
+ // when a dialog is armed, cleared by its close() alongside armedClose.
90
+ let armedReveal = null;
74
91
  function StatusLine(props) {
75
92
  return (() => {
76
93
  var _el$ = _$createElement("text");
@@ -79,6 +96,97 @@ function StatusLine(props) {
79
96
  return _el$;
80
97
  })();
81
98
  }
99
+
100
+ // One list-dialog row (see showListDialog).
101
+
102
+ // The rows' ONE height rule, shared by three places that must agree or the
103
+ // scroll lands on the wrong line: the rows' JSX (the heading's blank line
104
+ // above), rowOffset's arithmetic and the row-area cap's content measure. A
105
+ // header-line row renders as its text ONLY — the JSX checks headerLine
106
+ // before heading — so a row carrying both fields counts the way it renders,
107
+ // never as both. Every other row is one line except the second and later
108
+ // headings, which carry one blank line above (the palette's heading
109
+ // recipe).
110
+ function isHeadingRow(row) {
111
+ return row.headerLine === undefined && row.heading !== undefined;
112
+ }
113
+ function rowLines(row, headingsSoFar) {
114
+ return isHeadingRow(row) && headingsSoFar > 0 ? 2 : 1;
115
+ }
116
+ // The rows' total LINE count (not their row count — heading blanks are
117
+ // content too); `upto` measures the first rows alone, which is how rowOffset
118
+ // gets a row's y within the content.
119
+ function rowsHeight(rows, upto = rows.length) {
120
+ let y = 0;
121
+ let headings = 0;
122
+ for (let k = 0; k < upto; k++) {
123
+ const row = rows[k];
124
+ y += rowLines(row, headings);
125
+ if (isHeadingRow(row)) headings += 1;
126
+ }
127
+ return y;
128
+ }
129
+
130
+ // The list dialogs' row-area cap, the host command palette's own formula:
131
+ // min(content LINES, floor(terminal height / 2) - 6) — the first argument is
132
+ // rowsHeight(rows), never the rows' count, or the cap clips tail rows even
133
+ // on a roomy terminal (with the scrollbar hidden). The host's dialog layer
134
+ // caps nothing itself and anchors UNcentered dialogs a quarter viewport
135
+ // down — without this cap an uncapped list overflows the viewport at large
136
+ // font sizes (the report dialog did exactly that). Exported for the unit
137
+ // rig.
138
+ export function cappedRowsHeight(contentLines, terminalHeight) {
139
+ return Math.min(contentLines, Math.max(1, Math.floor(terminalHeight / 2) - 6));
140
+ }
141
+
142
+ // One report entry's defensive normalization, shared by the two paths that
143
+ // carry report data across a trust boundary (the /model-compare command's
144
+ // event and the TUI's direct RPC call): an entry missing any identity field
145
+ // is dropped, every other field falls back to its neutral value.
146
+ function toReportEntries(raw) {
147
+ if (!Array.isArray(raw)) return [];
148
+ return raw.flatMap(item => {
149
+ const entry = item;
150
+ if (typeof entry?.id !== "string" || typeof entry?.value !== "string" || typeof entry?.priority !== "string") return [];
151
+ return [{
152
+ priority: entry.priority,
153
+ rank: typeof entry.rank === "number" ? entry.rank : null,
154
+ // The F-20 split (see ReportEntry): `id` sanitized for display,
155
+ // `value` the raw catalog id for switching.
156
+ id: entry.id,
157
+ value: entry.value,
158
+ score: typeof entry.score === "string" ? entry.score : "-",
159
+ quality: typeof entry.quality === "string" ? entry.quality : "-",
160
+ price: typeof entry.price === "string" ? entry.price : "-",
161
+ current: entry.current === true,
162
+ fromFallback: entry.fromFallback === true
163
+ }];
164
+ });
165
+ }
166
+
167
+ // The report payload's footer capture (the last-response line and the alt+e
168
+ // toast's data), defensively normalized at the trust boundary like the
169
+ // entries: anything but a well-shaped object — or one without an endpoint —
170
+ // degrades to null (no capture), never a placeholder line.
171
+ function toCapture(raw) {
172
+ if (typeof raw !== "object" || raw === null) return null;
173
+ const capture = raw;
174
+ if (typeof capture.endpoint !== "string" || capture.endpoint === "") return null;
175
+ return {
176
+ endpoint: capture.endpoint,
177
+ // The Task-5 handoff: a capture may carry an empty model (a response
178
+ // whose provider had no model id) — carried as-is, and the footer omits
179
+ // the model part rather than rendering an empty slot.
180
+ model: typeof capture.model === "string" ? capture.model : "",
181
+ downgrade: capture.downgrade === true
182
+ };
183
+ }
184
+
185
+ // The footer's steering note, defensively normalized: anything but a
186
+ // non-empty string means the note does not render (no steering data).
187
+ function toSteeringNote(raw) {
188
+ return typeof raw === "string" && raw !== "" ? raw : null;
189
+ }
82
190
  export default Plugin.define({
83
191
  id: "model-compare-tui",
84
192
  async setup(context) {
@@ -90,6 +198,28 @@ export default Plugin.define({
90
198
  variant,
91
199
  duration: TOAST_MS
92
200
  });
201
+
202
+ // The terminal's size, reactive: the renderer emits "resize" carrying the
203
+ // fresh dimensions, and the dialogs' row-area caps must track font-size
204
+ // and window changes the way the host's own dialogs do (the palette caps
205
+ // its list at a fraction of the viewport; the dialog layer gives plugin
206
+ // dialogs no cap of their own). Detached in the returned cleanup.
207
+ const [terminalSize, setTerminalSize] = createSignal({
208
+ width: context.renderer.width,
209
+ height: context.renderer.height
210
+ });
211
+ const onRendererResize = (width, height) => {
212
+ setTerminalSize({
213
+ width,
214
+ height
215
+ });
216
+ // A resize re-caps the armed dialog's row area under its cursor: the
217
+ // reveal re-runs on the next frame (see showListDialog's
218
+ // revealOnFrame) or a shrunken viewport can leave the cursor below
219
+ // the fold until the next key move.
220
+ armedReveal?.(false);
221
+ };
222
+ context.renderer.on("resize", onRendererResize);
93
223
  let disposeRoot = () => {};
94
224
  // The three slot disposers (the two footers and the app slot below):
95
225
  // slots claimed but never released survive the plugin's unload and keep
@@ -155,21 +285,29 @@ export default Plugin.define({
155
285
  return "balanced";
156
286
  }
157
287
 
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.
288
+ // Switch to one of the plugin's aliases — the shared decision chain
289
+ // behind /use-best, the palette command and the picks dialog's
290
+ // selection. With a session open it switches THAT session; with none
291
+ // (the opening screen) it creates a session already bound to the alias
292
+ // and opens it — the pick is the session's starting model. A disabled
293
+ // alias refuses before anything is created.
161
294
  async function switchToPriority(priority) {
295
+ const views = aliases();
296
+ if (!views.some(view => view.priority === priority && view.enabled)) {
297
+ notify(useBestRefusal(priority), "error");
298
+ return;
299
+ }
300
+ const view = views.find(item => item.priority === priority);
301
+ const notice = useBestNotice(priority, view?.modelID ?? pickFromName(view?.name ?? ""));
162
302
  const sessionID = currentSessionID();
163
303
  if (sessionID === null) {
164
- notify("Open a session first", "error");
304
+ await createSessionOnModel({
305
+ providerID: ALIAS_PROVIDER,
306
+ id: aliasId(priority)
307
+ }, notice);
165
308
  return;
166
309
  }
167
310
  try {
168
- const views = aliases();
169
- if (!views.some(view => view.priority === priority && view.enabled)) {
170
- notify(useBestRefusal(priority), "error");
171
- return;
172
- }
173
311
  await context.client.session.switchModel({
174
312
  sessionID,
175
313
  model: {
@@ -177,8 +315,7 @@ export default Plugin.define({
177
315
  id: aliasId(priority)
178
316
  }
179
317
  });
180
- const view = views.find(item => item.priority === priority);
181
- notify(useBestNotice(priority, view?.modelID ?? pickFromName(view?.name ?? "")));
318
+ notify(notice);
182
319
  } catch (error) {
183
320
  notify(`use-best failed: ${error instanceof Error ? error.message : String(error)}`, "error");
184
321
  }
@@ -203,6 +340,27 @@ export default Plugin.define({
203
340
  }
204
341
  }
205
342
 
343
+ // No session open (the opening screen's pick paths): create one already
344
+ // bound to the given model and navigate into it. Returns whether the
345
+ // session came up; a failure is toasted and nothing else happens — no
346
+ // half-open session to clean up.
347
+ async function createSessionOnModel(model, success) {
348
+ try {
349
+ const session = await context.client.session.create({
350
+ model
351
+ });
352
+ context.ui.router.navigate({
353
+ type: "session",
354
+ sessionID: session.id
355
+ });
356
+ notify(success);
357
+ return true;
358
+ } catch (error) {
359
+ notify(`could not open a session: ${error instanceof Error ? error.message : String(error)}`, "error");
360
+ return false;
361
+ }
362
+ }
363
+
206
364
  // The custom list dialog. The host's `dialog.select` (SDK 2.0.16
207
365
  // runtime) renders one GROUPED box per category with one cursor each —
208
366
  // its search flattening (verified against main) does not exist in this
@@ -218,7 +376,11 @@ export default Plugin.define({
218
376
  // dialog is open, cleared on close; the app slot's in-tree effect maps
219
377
  // it to a modal layer.
220
378
  const [listKeys, setListKeys] = createSignal(null);
221
- function showListDialog(title, rows, onPick) {
379
+ function showListDialog(title, rows, onPick,
380
+ // The footer's extra lines (the report dialog's steering note and
381
+ // last-response line), rendered muted ABOVE the Details line. Absent
382
+ // for the other dialogs (the picks dialog carries no footer lines).
383
+ footerLines = []) {
222
384
  const selectable = rows.filter(row => row.value !== undefined);
223
385
  if (selectable.length === 0) {
224
386
  notify("nothing to select", "error");
@@ -250,14 +412,72 @@ export default Plugin.define({
250
412
  // never updated — the cursor sat on row 1 however many times the
251
413
  // keymap moved the internal index; observed live, 2026-10-11).
252
414
  const [cursor, setCursor] = createSignal(initial);
415
+ // The scrollbox that carries the rows (see the JSX below); its ref
416
+ // lands when the host renders the dialog. The cursor's row is kept in
417
+ // view with the native lists' look-ahead: the content scrolls as soon
418
+ // as the cursor comes within three lines of an edge, and the cursor
419
+ // only touches an edge line when the content ends (the scroll clamps
420
+ // at its maximum).
421
+ //
422
+ // The row offsets are pure ARITHMETIC, not renderable geometry —
423
+ // rowsHeight (module scope) is the ONE row-height rule the rows' JSX
424
+ // and the row-area cap share. Reading the host's .y was tried and is
425
+ // unreliable across the plugin boundary — it reported the scrollbox's
426
+ // own position for every row — while scrollTop/scrollHeight/height
427
+ // behave as plain numbers.
428
+ let listScroll;
429
+ let pendingReveal;
430
+ const rowOffset = index => rowsHeight(rows, index);
431
+ const reveal = center => {
432
+ const box = listScroll;
433
+ if (box === undefined) return;
434
+ const top = rowOffset(rows.indexOf(selectable[cursor()]));
435
+ const max = Math.max(0, box.scrollHeight - box.height);
436
+ // The look-ahead band, clamped to the window: at box.height <= 3 the
437
+ // three-line margin degenerates to plain ensure-visible (the row
438
+ // lands ON a window line instead of the overscroll an unclamped
439
+ // `box.height - 4` produced once it went negative — cap values of
440
+ // 1-3 are exactly what large font sizes reach).
441
+ const band = Math.min(3, Math.max(0, box.height - 1));
442
+ // Keep the row on-screen whatever the branch: the row's line must
443
+ // stay within [scrollTop, scrollTop + box.height - 1].
444
+ const visible = target => Math.min(top, Math.max(top - box.height + 1, target));
445
+ let target = box.scrollTop;
446
+ 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);
447
+ box.scrollTo(Math.max(0, Math.min(target, max)));
448
+ };
449
+ // Positions exist only after a layout frame, so the reveal defers to
450
+ // the next rendered frame (the palette's own once(FRAME) +
451
+ // requestRender() pattern) rather than racing the renderer inside the
452
+ // key handler.
453
+ const revealOnFrame = center => {
454
+ if (pendingReveal !== undefined) context.renderer.off("frame", pendingReveal);
455
+ const run = () => {
456
+ pendingReveal = undefined;
457
+ if (!alive) return;
458
+ reveal(center);
459
+ };
460
+ pendingReveal = run;
461
+ context.renderer.once("frame", run);
462
+ context.renderer.requestRender();
463
+ };
253
464
  const close = () => {
254
465
  if (!alive) return;
255
466
  alive = false;
467
+ // A pending frame reveal is THIS dialog's alone: cancel it here or
468
+ // the once("frame") listener (and its closure over the rows)
469
+ // outlives the dialog — it self-guarded on `alive`, but leaked until
470
+ // the next frame.
471
+ if (pendingReveal !== undefined) {
472
+ context.renderer.off("frame", pendingReveal);
473
+ pendingReveal = undefined;
474
+ }
256
475
  // 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.
476
+ // shared state (the keymap layer, the host dialog, the reveal hook)
477
+ // now — clearing here would kill ITS keyboard and surface.
259
478
  if (generation !== dialogGeneration) return;
260
479
  armedClose = null;
480
+ armedReveal = null;
261
481
  setListKeys(null);
262
482
  context.ui.dialog.clear();
263
483
  };
@@ -266,6 +486,10 @@ export default Plugin.define({
266
486
  // first is the last (the command palette's behavior).
267
487
  const move = delta => {
268
488
  setCursor((cursor() + delta + selectable.length) % selectable.length);
489
+ // Frame-deferred like every reveal — and it CANCELS a pending one,
490
+ // so the preselected row's open-time centering can never land after
491
+ // the user has already moved the cursor off it.
492
+ revealOnFrame(false);
269
493
  };
270
494
  const pick = () => {
271
495
  const row = selectable[cursor()];
@@ -277,6 +501,7 @@ export default Plugin.define({
277
501
  // slot below); it registers on the next reactive run. This dialog is
278
502
  // now the armed one (the lifecycle token above).
279
503
  armedClose = close;
504
+ armedReveal = revealOnFrame;
280
505
  setListKeys({
281
506
  move,
282
507
  pick
@@ -289,7 +514,7 @@ export default Plugin.define({
289
514
  _el$3 = _$createElement("box"),
290
515
  _el$4 = _$createElement("text"),
291
516
  _el$5 = _$createElement("text"),
292
- _el$7 = _$createElement("box"),
517
+ _el$7 = _$createElement("scrollbox"),
293
518
  _el$8 = _$createElement("box"),
294
519
  _el$9 = _$createElement("text");
295
520
  _$insertNode(_el$2, _el$3);
@@ -309,9 +534,18 @@ export default Plugin.define({
309
534
  _$insertNode(_el$5, _$createTextNode(`esc`));
310
535
  _$setProp(_el$5, "fg", muted);
311
536
  _$setProp(_el$5, "onMouseUp", () => close());
537
+ _$use(element => {
538
+ listScroll = element;
539
+ // The report's preselected row (the current pick) opens
540
+ // centered in the scrollbox: scheduled from the ref, so the
541
+ // frame it waits for is the one that lays these rows out.
542
+ if (initial > 0) revealOnFrame(true);
543
+ }, _el$7);
312
544
  _$setProp(_el$7, "paddingLeft", 1);
313
545
  _$setProp(_el$7, "paddingRight", 1);
314
- _$setProp(_el$7, "flexDirection", "column");
546
+ _$setProp(_el$7, "scrollbarOptions", {
547
+ visible: false
548
+ });
315
549
  _$insert(_el$7, () => {
316
550
  let headings = 0;
317
551
  return rows.map(row => {
@@ -327,14 +561,17 @@ export default Plugin.define({
327
561
  }
328
562
  if (row.heading !== undefined) {
329
563
  // 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.
564
+ // blank line above every heading but the first — the
565
+ // blank is rowLines' call (the ONE row-height rule the
566
+ // offsets and the cap share), not a second copy of its
567
+ // condition. Flush with the title: 1 (list inset) + 3 = 4.
568
+ const paddingTop = rowLines(row, headings) - 1;
332
569
  headings += 1;
333
570
  return (() => {
334
571
  var _el$10 = _$createElement("box"),
335
572
  _el$11 = _$createElement("text");
336
573
  _$insertNode(_el$10, _el$11);
337
- _$setProp(_el$10, "paddingTop", headings > 1 ? 1 : 0);
574
+ _$setProp(_el$10, "paddingTop", paddingTop);
338
575
  _$setProp(_el$10, "paddingLeft", 3);
339
576
  _$setProp(_el$11, "fg", accent);
340
577
  _$insert(_el$11, () => row.heading);
@@ -390,20 +627,47 @@ export default Plugin.define({
390
627
  })();
391
628
  });
392
629
  });
630
+ _$insert(_el$2, () => footerLines.map(line => (() => {
631
+ var _el$18 = _$createElement("box"),
632
+ _el$19 = _$createElement("text");
633
+ _$insertNode(_el$18, _el$19);
634
+ _$setProp(_el$18, "paddingLeft", 4);
635
+ _$setProp(_el$19, "fg", muted);
636
+ _$insert(_el$19, line);
637
+ return _el$18;
638
+ })()), _el$8);
393
639
  _$insertNode(_el$8, _el$9);
394
640
  _$setProp(_el$8, "paddingLeft", 4);
395
- _$insertNode(_el$9, _$createTextNode(`Details: https://canonical.github.io/model-compare`));
641
+ _$insertNode(_el$9, _$createTextNode(`Details: https://model-compare.dev`));
396
642
  _$setProp(_el$9, "fg", muted);
397
- _$effect(_$p => _$setProp(_el$4, "attributes", TextAttributes.BOLD, _$p));
643
+ _$effect(_p$ => {
644
+ var _v$ = TextAttributes.BOLD,
645
+ _v$2 = cappedRowsHeight(rowsHeight(rows), terminalSize().height);
646
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$4, "attributes", _v$, _p$.e));
647
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$7, "maxHeight", _v$2, _p$.t));
648
+ return _p$;
649
+ }, {
650
+ e: undefined,
651
+ t: undefined
652
+ });
398
653
  return _el$2;
399
654
  })(), close);
655
+ // The palette's presentation: vertically centered at the medium width.
656
+ // The size/centered state persists across dialogs (the host's own set
657
+ // it on every open), so the explicit set also clears a stale xlarge a
658
+ // host dialog may have left behind.
659
+ context.ui.dialog.set({
660
+ size: "medium",
661
+ centered: true
662
+ });
400
663
  }
401
664
 
402
665
  // The picks dialog: the three priorities, one cursor, palette styling.
403
666
  // A snapshot at open time; unavailable priorities are dimmed — the
404
667
  // cursor still reaches them, and choosing one is refused with a toast
405
668
  // (switchToPriority's guard). Choosing an available priority switches
406
- // the session to it.
669
+ // the session to it — or, with no session open, creates one bound to
670
+ // it (the opening screen's path; opening the dialog never does).
407
671
  function showPicksDialog() {
408
672
  const views = aliases();
409
673
  const rows = PRIORITIES.map(priority => {
@@ -479,7 +743,26 @@ export default Plugin.define({
479
743
  });
480
744
  }
481
745
  }
482
- showListDialog(`Model Compare — top 5 (data age: ${data.age ?? "unknown"})`, rows, id => void switchToModel(data.sessionID, id));
746
+ // The report dialog's footer (design §Decisions, option C): the
747
+ // steering note ABOVE the last-response line, both omitted when their
748
+ // data is absent — never a placeholder line.
749
+ const footerLines = [];
750
+ if (data.steeringNote !== null) footerLines.push(data.steeringNote);
751
+ const lastResponse = lastResponseLine(data.capture);
752
+ if (lastResponse !== null) footerLines.push(lastResponse);
753
+ showListDialog(`Model Compare — top 5 (data age: ${data.age ?? "unknown"})`, rows, id => {
754
+ const sessionID = data.sessionID;
755
+ if (sessionID !== null) {
756
+ void switchToModel(sessionID, id);
757
+ return;
758
+ }
759
+ // The opening screen: the pick becomes a NEW session's model —
760
+ // the same concrete model the in-session path switches to.
761
+ void createSessionOnModel({
762
+ providerID: ALIAS_PROVIDER,
763
+ id
764
+ }, `switched to openrouter/${sanitizeCatalogText(id)}`);
765
+ }, footerLines);
483
766
  }
484
767
 
485
768
  // The report dialog rides the /model-compare command's RPC event: ONE
@@ -496,32 +779,41 @@ export default Plugin.define({
496
779
  const data = event.data;
497
780
  if (typeof data?.sessionID !== "string" || data.sessionID !== currentSessionID()) return;
498
781
  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
- });
782
+ const entries = toReportEntries(data.entries);
516
783
  const eventSessionID = data.sessionID;
517
784
  // Ownerless callback: park the report on the signal — the app slot's
518
- // in-tree effect opens the dialog (see pendingReport above).
785
+ // in-tree effect opens the dialog (see pendingReport above). The
786
+ // footer's fields ride the payload like the entries (defensively
787
+ // normalized at this trust boundary).
519
788
  setPendingReport({
520
789
  sessionID: eventSessionID,
521
790
  age: typeof data.age === "string" ? data.age : null,
522
- entries
791
+ entries,
792
+ capture: toCapture(data.capture),
793
+ steeringNote: toSteeringNote(data.steeringNote)
523
794
  });
524
795
  });
796
+
797
+ // The cannot-steer fallback (Task 6): the runtime hook emits this event
798
+ // once per session when a steerable alias request finds no steering data
799
+ // (no catalog source). The TUI surfaces it as a toast with the exact
800
+ // fallback copy; headless shows nothing — like every TUI-coupled surface.
801
+ // Scoped exactly like the report listener: a foreign directory's session
802
+ // is never viewable here, and only the CURRENTLY viewed session toasts.
803
+ // Trade-off: a cannotSteer notice for a same-project background session
804
+ // (or a session the user isn't currently viewing) is dropped forever —
805
+ // the emit is once-per-session — but noise from unopened sessions is the
806
+ // worse failure; the notice is about the session the user interacts with.
807
+ const offCannotSteerEvent = context.data.listen(({
808
+ details
809
+ }) => {
810
+ const event = details;
811
+ if (event.type !== CANNOT_STEER_EVENT) return;
812
+ if (typeof event.location?.directory === "string" && here !== undefined && event.location.directory !== here) return;
813
+ const data = event.data;
814
+ if (typeof data?.sessionID !== "string" || data.sessionID !== currentSessionID()) return;
815
+ notify(CANNOT_STEER_COPY);
816
+ });
525
817
  createRoot(dispose => {
526
818
  disposeRoot = dispose;
527
819
 
@@ -599,29 +891,27 @@ export default Plugin.define({
599
891
 
600
892
  // The picks dialog's command. No slash: /use-best already covers the
601
893
  // switching surface (the server command is slash-routable; the TUI's
602
- // duplicate of it was removed for exactly that reason).
894
+ // duplicate of it was removed for exactly that reason). The dialog opens
895
+ // everywhere — with no session it shows the current picks, and only
896
+ // picking one creates a session bound to it (switchToPriority).
603
897
  const picksCommand = {
604
898
  id: "model-compare.picks",
605
899
  title: "Model-compare top picks",
606
900
  group: "Model Compare",
607
901
  bind: "alt+m",
608
902
  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
- }
903
+ run: () => showPicksDialog()
616
904
  };
617
905
 
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.
906
+ // The report command: with a session open it posts the server's
907
+ // /model-compare command, whose RPC event opens the selectable report
908
+ // dialog here (and the transcript keeps the notice). With no session
909
+ // (the opening screen) there is nothing to post to, so the report is
910
+ // fetched straight from the plugin's report RPC — same builder, same
911
+ // entries — and picking a model creates a session bound to it (there is
912
+ // no composer-model setter to change instead; verified against the
913
+ // pinned runtime). The refusal-free opening also keeps the footer's
914
+ // status line honest: the shortcuts always DO something.
625
915
  const reportCommand = {
626
916
  id: "model-compare.report",
627
917
  title: "Model-compare report",
@@ -630,15 +920,31 @@ export default Plugin.define({
630
920
  palette: true,
631
921
  run: async () => {
632
922
  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");
923
+ if (sessionID !== null) {
924
+ try {
925
+ await context.client.session.command({
926
+ sessionID,
927
+ name: "model-compare",
928
+ text: ""
929
+ });
930
+ } catch (error) {
931
+ notify(`report failed: ${error instanceof Error ? error.message : String(error)}`, "error");
932
+ }
635
933
  return;
636
934
  }
637
935
  try {
638
- await context.client.session.command({
639
- sessionID,
640
- name: "model-compare",
641
- text: ""
936
+ const report = await context.client.rpc(ModelCompare).report({}, here === undefined ? undefined : {
937
+ location: {
938
+ directory: here
939
+ }
940
+ });
941
+ const data = report;
942
+ showReportDialog({
943
+ sessionID: null,
944
+ age: typeof data?.age === "string" ? data.age : null,
945
+ entries: toReportEntries(data?.entries),
946
+ capture: toCapture(data?.capture),
947
+ steeringNote: toSteeringNote(data?.steeringNote)
642
948
  });
643
949
  } catch (error) {
644
950
  notify(`report failed: ${error instanceof Error ? error.message : String(error)}`, "error");
@@ -646,6 +952,41 @@ export default Plugin.define({
646
952
  }
647
953
  };
648
954
 
955
+ // The served-endpoint toast (design §Steering visibility): alt+e toasts
956
+ // the CURRENT session's last response's served endpoint, looked up
957
+ // through the report RPC's footer fields (the capture record rides the
958
+ // established report payload path). The copy is exact — normal:
959
+ // "last response served by <endpoint>"; downgrade (the served provider
960
+ // matched none of the applied steering order): "! served by <endpoint> —
961
+ // not in steering order"; nothing toasts when nothing was captured for
962
+ // the session (and with no session open there is nothing to look up).
963
+ const endpointCommand = {
964
+ id: "model-compare.endpoint",
965
+ title: "Last served endpoint",
966
+ group: "Model Compare",
967
+ bind: "alt+e",
968
+ palette: true,
969
+ run: async () => {
970
+ const sessionID = currentSessionID();
971
+ if (sessionID === null) return;
972
+ try {
973
+ const report = await context.client.rpc(ModelCompare).report({
974
+ sessionID
975
+ }, here === undefined ? undefined : {
976
+ location: {
977
+ directory: here
978
+ }
979
+ });
980
+ const capture = toCapture(report?.capture);
981
+ if (capture === null) return;
982
+ // F-20: the endpoint is response data — flattened for the toast.
983
+ notify(capture.downgrade ? `! served by ${sanitizeCatalogText(capture.endpoint)} — not in steering order` : `last response served by ${sanitizeCatalogText(capture.endpoint)}`);
984
+ } catch (error) {
985
+ notify(`endpoint lookup failed: ${error instanceof Error ? error.message : String(error)}`, "error");
986
+ }
987
+ }
988
+ };
989
+
649
990
  // The palette-only use-best: the immediate-toast variant. The SLASH
650
991
  // surface is left to the server command (one entry everywhere); the
651
992
  // palette is TUI-layer territory, where this variant's instant feedback
@@ -721,7 +1062,7 @@ export default Plugin.define({
721
1062
  try {
722
1063
  context.keymap.layer(() => ({
723
1064
  mode: "global",
724
- commands: [useBestCommand, picksCommand, reportCommand]
1065
+ commands: [useBestCommand, picksCommand, reportCommand, endpointCommand]
725
1066
  }));
726
1067
  } catch (error) {
727
1068
  layerFailure("the model-compare keymap layer", error);
@@ -730,8 +1071,15 @@ export default Plugin.define({
730
1071
  }
731
1072
  });
732
1073
  return () => {
1074
+ // Disarm whatever dialog is still armed FIRST: its pending frame
1075
+ // reveal and modal keyboard must not outlive the plugin (the
1076
+ // module-scope armedClose/armedReveal survive setup's cleanup
1077
+ // otherwise).
1078
+ armedClose?.();
733
1079
  if (resyncTimer !== undefined) clearInterval(resyncTimer);
1080
+ context.renderer.off("resize", onRendererResize);
734
1081
  offReportEvent();
1082
+ offCannotSteerEvent();
735
1083
  disposeRoot();
736
1084
  disposeAppSlot();
737
1085
  disposeHomeFooter();