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/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,
@@ -62,7 +68,8 @@ import {
62
68
  type AliasView,
63
69
  } from "./opencode/usebest"
64
70
  import type { ReportEntry } from "./opencode/commands"
65
- import { sanitizeCatalogText } from "./opencode/commands"
71
+ import { lastResponseLine, 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
@@ -74,6 +81,11 @@ const PACKAGE_FRAGMENT = "model-compare-opencode-plugin"
74
81
  // The RPC event the /model-compare command emits (see src/rpc.ts; the wire
75
82
  // name is prefixed with the RPC's id).
76
83
  const REPORT_EVENT = "rpc.model-compare.report"
84
+ // The event the runtime hook emits once per session when it would have
85
+ // steered a request but has no steering data (no catalog source) — the Q2
86
+ // fallback's runtime notification. The toast copy below is the whole message.
87
+ const CANNOT_STEER_EVENT = "rpc.model-compare.cannotSteer"
88
+ const CANNOT_STEER_COPY = "cannot steer this session; OpenRouter default routing applies"
77
89
 
78
90
  // The list dialogs' lifecycle token (see showListDialog): a module-scope
79
91
  // generation counter, bumped every time a dialog is armed, plus the armed
@@ -83,11 +95,129 @@ const REPORT_EVENT = "rpc.model-compare.report"
83
95
  // and arming a new dialog closes whatever is still armed first.
84
96
  let dialogGeneration = 0
85
97
  let armedClose: (() => void) | null = null
98
+ // The armed dialog's reveal hook (see showListDialog): its own
99
+ // revealOnFrame, exposed module-scope so the setup-scope resize handler can
100
+ // re-reveal the cursor when a resize re-caps the row area under it. Set
101
+ // when a dialog is armed, cleared by its close() alongside armedClose.
102
+ let armedReveal: ((center: boolean) => void) | null = null
86
103
 
87
104
  function StatusLine(props: { line: () => string; fg?: string }) {
88
105
  return <text fg={props.fg}>{props.line()}</text>
89
106
  }
90
107
 
108
+ // One list-dialog row (see showListDialog).
109
+ type ListRow = {
110
+ // Section headings render in the palette's category color, and are
111
+ // skipped by the cursor.
112
+ heading?: string
113
+ // A pre-built column-header line (the report table): dim, skipped by
114
+ // the cursor, and never gets a blank line above it.
115
+ headerLine?: string
116
+ label: string
117
+ // Trailing dim right-justified columns (the report table); when
118
+ // present, label renders in its own span so the two tones can differ.
119
+ columns?: string
120
+ selected?: string
121
+ value?: string
122
+ dim?: boolean
123
+ }
124
+
125
+ // The rows' ONE height rule, shared by three places that must agree or the
126
+ // scroll lands on the wrong line: the rows' JSX (the heading's blank line
127
+ // above), rowOffset's arithmetic and the row-area cap's content measure. A
128
+ // header-line row renders as its text ONLY — the JSX checks headerLine
129
+ // before heading — so a row carrying both fields counts the way it renders,
130
+ // never as both. Every other row is one line except the second and later
131
+ // headings, which carry one blank line above (the palette's heading
132
+ // recipe).
133
+ function isHeadingRow(row: ListRow): boolean {
134
+ return row.headerLine === undefined && row.heading !== undefined
135
+ }
136
+ function rowLines(row: ListRow, headingsSoFar: number): number {
137
+ return isHeadingRow(row) && headingsSoFar > 0 ? 2 : 1
138
+ }
139
+ // The rows' total LINE count (not their row count — heading blanks are
140
+ // content too); `upto` measures the first rows alone, which is how rowOffset
141
+ // gets a row's y within the content.
142
+ function rowsHeight(rows: readonly ListRow[], upto: number = rows.length): number {
143
+ let y = 0
144
+ let headings = 0
145
+ for (let k = 0; k < upto; k++) {
146
+ const row = rows[k]!
147
+ y += rowLines(row, headings)
148
+ if (isHeadingRow(row)) headings += 1
149
+ }
150
+ return y
151
+ }
152
+
153
+ // The list dialogs' row-area cap, the host command palette's own formula:
154
+ // min(content LINES, floor(terminal height / 2) - 6) — the first argument is
155
+ // rowsHeight(rows), never the rows' count, or the cap clips tail rows even
156
+ // on a roomy terminal (with the scrollbar hidden). The host's dialog layer
157
+ // caps nothing itself and anchors UNcentered dialogs a quarter viewport
158
+ // down — without this cap an uncapped list overflows the viewport at large
159
+ // font sizes (the report dialog did exactly that). Exported for the unit
160
+ // rig.
161
+ export function cappedRowsHeight(contentLines: number, terminalHeight: number): number {
162
+ return Math.min(contentLines, Math.max(1, Math.floor(terminalHeight / 2) - 6))
163
+ }
164
+
165
+ // One report entry's defensive normalization, shared by the two paths that
166
+ // carry report data across a trust boundary (the /model-compare command's
167
+ // event and the TUI's direct RPC call): an entry missing any identity field
168
+ // is dropped, every other field falls back to its neutral value.
169
+ function toReportEntries(raw: unknown): ReportEntry[] {
170
+ if (!Array.isArray(raw)) return []
171
+ return raw.flatMap((item): ReportEntry[] => {
172
+ const entry = item as Partial<ReportEntry>
173
+ if (
174
+ typeof entry?.id !== "string" ||
175
+ typeof entry?.value !== "string" ||
176
+ typeof entry?.priority !== "string"
177
+ )
178
+ return []
179
+ return [
180
+ {
181
+ priority: entry.priority as Priority,
182
+ rank: typeof entry.rank === "number" ? entry.rank : null,
183
+ // The F-20 split (see ReportEntry): `id` sanitized for display,
184
+ // `value` the raw catalog id for switching.
185
+ id: entry.id,
186
+ value: entry.value,
187
+ score: typeof entry.score === "string" ? entry.score : "-",
188
+ quality: typeof entry.quality === "string" ? entry.quality : "-",
189
+ price: typeof entry.price === "string" ? entry.price : "-",
190
+ current: entry.current === true,
191
+ fromFallback: entry.fromFallback === true,
192
+ },
193
+ ]
194
+ })
195
+ }
196
+
197
+ // The report payload's footer capture (the last-response line and the alt+e
198
+ // toast's data), defensively normalized at the trust boundary like the
199
+ // entries: anything but a well-shaped object — or one without an endpoint —
200
+ // degrades to null (no capture), never a placeholder line.
201
+ function toCapture(raw: unknown): { endpoint: string; model: string; downgrade: boolean } | null {
202
+ if (typeof raw !== "object" || raw === null) return null
203
+ const capture = raw as { endpoint?: unknown; model?: unknown; downgrade?: unknown }
204
+ if (typeof capture.endpoint !== "string" || capture.endpoint === "") return null
205
+ return {
206
+ endpoint: capture.endpoint,
207
+ // The Task-5 handoff: a capture may carry an empty model (a response
208
+ // whose provider had no model id) — carried as-is, and the footer omits
209
+ // the model part rather than rendering an empty slot.
210
+ model: typeof capture.model === "string" ? capture.model : "",
211
+ downgrade: capture.downgrade === true,
212
+ }
213
+ }
214
+
215
+ // The footer's steering note, defensively normalized: anything but a
216
+ // non-empty string means the note does not render (no steering data).
217
+ function toSteeringNote(raw: unknown): string | null {
218
+ return typeof raw === "string" && raw !== "" ? raw : null
219
+ }
220
+
91
221
  export default Plugin.define({
92
222
  id: "model-compare-tui",
93
223
  async setup(context) {
@@ -97,6 +227,25 @@ export default Plugin.define({
97
227
  const notify = (message: string, variant: "success" | "error" = "success") =>
98
228
  context.ui.toast.show({ title: "model-compare", message, variant, duration: TOAST_MS })
99
229
 
230
+ // The terminal's size, reactive: the renderer emits "resize" carrying the
231
+ // fresh dimensions, and the dialogs' row-area caps must track font-size
232
+ // and window changes the way the host's own dialogs do (the palette caps
233
+ // its list at a fraction of the viewport; the dialog layer gives plugin
234
+ // dialogs no cap of their own). Detached in the returned cleanup.
235
+ const [terminalSize, setTerminalSize] = createSignal({
236
+ width: context.renderer.width,
237
+ height: context.renderer.height,
238
+ })
239
+ const onRendererResize = (width: number, height: number) => {
240
+ setTerminalSize({ width, height })
241
+ // A resize re-caps the armed dialog's row area under its cursor: the
242
+ // reveal re-runs on the next frame (see showListDialog's
243
+ // revealOnFrame) or a shrunken viewport can leave the cursor below
244
+ // the fold until the next key move.
245
+ armedReveal?.(false)
246
+ }
247
+ context.renderer.on("resize", onRendererResize)
248
+
100
249
  let disposeRoot = () => {}
101
250
  // The three slot disposers (the two footers and the app slot below):
102
251
  // slots claimed but never released survive the plugin's unload and keep
@@ -116,6 +265,8 @@ export default Plugin.define({
116
265
  sessionID: string
117
266
  age: string | null
118
267
  entries: ReportEntry[]
268
+ capture: { endpoint: string; model: string; downgrade: boolean } | null
269
+ steeringNote: string | null
119
270
  } | null>(null)
120
271
 
121
272
  // The registry view, reactive: OpenCode's data layer updates the list
@@ -177,27 +328,34 @@ export default Plugin.define({
177
328
  return "balanced"
178
329
  }
179
330
 
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.
331
+ // Switch to one of the plugin's aliases — the shared decision chain
332
+ // behind /use-best, the palette command and the picks dialog's
333
+ // selection. With a session open it switches THAT session; with none
334
+ // (the opening screen) it creates a session already bound to the alias
335
+ // and opens it — the pick is the session's starting model. A disabled
336
+ // alias refuses before anything is created.
183
337
  async function switchToPriority(priority: Priority): Promise<void> {
338
+ const views = aliases()
339
+ if (!views.some((view) => view.priority === priority && view.enabled)) {
340
+ notify(useBestRefusal(priority), "error")
341
+ return
342
+ }
343
+ const view = views.find((item) => item.priority === priority)
344
+ const notice = useBestNotice(priority, view?.modelID ?? pickFromName(view?.name ?? ""))
184
345
  const sessionID = currentSessionID()
185
346
  if (sessionID === null) {
186
- notify("Open a session first", "error")
347
+ await createSessionOnModel(
348
+ { providerID: ALIAS_PROVIDER, id: aliasId(priority) },
349
+ notice,
350
+ )
187
351
  return
188
352
  }
189
353
  try {
190
- const views = aliases()
191
- if (!views.some((view) => view.priority === priority && view.enabled)) {
192
- notify(useBestRefusal(priority), "error")
193
- return
194
- }
195
354
  await context.client.session.switchModel({
196
355
  sessionID,
197
356
  model: { providerID: ALIAS_PROVIDER, id: aliasId(priority) },
198
357
  })
199
- const view = views.find((item) => item.priority === priority)
200
- notify(useBestNotice(priority, view?.modelID ?? pickFromName(view?.name ?? "")))
358
+ notify(notice)
201
359
  } catch (error) {
202
360
  notify(`use-best failed: ${error instanceof Error ? error.message : String(error)}`, "error")
203
361
  }
@@ -219,6 +377,28 @@ export default Plugin.define({
219
377
  }
220
378
  }
221
379
 
380
+ // No session open (the opening screen's pick paths): create one already
381
+ // bound to the given model and navigate into it. Returns whether the
382
+ // session came up; a failure is toasted and nothing else happens — no
383
+ // half-open session to clean up.
384
+ async function createSessionOnModel(
385
+ model: { providerID: string; id: string },
386
+ success: string,
387
+ ): Promise<boolean> {
388
+ try {
389
+ const session = await context.client.session.create({ model })
390
+ context.ui.router.navigate({ type: "session", sessionID: session.id })
391
+ notify(success)
392
+ return true
393
+ } catch (error) {
394
+ notify(
395
+ `could not open a session: ${error instanceof Error ? error.message : String(error)}`,
396
+ "error",
397
+ )
398
+ return false
399
+ }
400
+ }
401
+
222
402
  // The custom list dialog. The host's `dialog.select` (SDK 2.0.16
223
403
  // runtime) renders one GROUPED box per category with one cursor each —
224
404
  // its search flattening (verified against main) does not exist in this
@@ -229,21 +409,6 @@ export default Plugin.define({
229
409
  // render closure (context-less — "Keymap.Provider is missing", the same
230
410
  // trap as setup scope), so the app slot's IN-TREE effect registers it
231
411
  // 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
412
 
248
413
  // The keymap layer hand-off (see showListDialog): set while a list
249
414
  // dialog is open, cleared on close; the app slot's in-tree effect maps
@@ -253,7 +418,15 @@ export default Plugin.define({
253
418
  pick: () => void
254
419
  } | null>(null)
255
420
 
256
- function showListDialog(title: string, rows: ListRow[], onPick: (value: string) => void): void {
421
+ function showListDialog(
422
+ title: string,
423
+ rows: ListRow[],
424
+ onPick: (value: string) => void,
425
+ // The footer's extra lines (the report dialog's steering note and
426
+ // last-response line), rendered muted ABOVE the Details line. Absent
427
+ // for the other dialogs (the picks dialog carries no footer lines).
428
+ footerLines: string[] = [],
429
+ ): void {
257
430
  const selectable = rows.filter((row) => row.value !== undefined)
258
431
  if (selectable.length === 0) {
259
432
  notify("nothing to select", "error")
@@ -289,14 +462,75 @@ export default Plugin.define({
289
462
  // never updated — the cursor sat on row 1 however many times the
290
463
  // keymap moved the internal index; observed live, 2026-10-11).
291
464
  const [cursor, setCursor] = createSignal(initial)
465
+ // The scrollbox that carries the rows (see the JSX below); its ref
466
+ // lands when the host renders the dialog. The cursor's row is kept in
467
+ // view with the native lists' look-ahead: the content scrolls as soon
468
+ // as the cursor comes within three lines of an edge, and the cursor
469
+ // only touches an edge line when the content ends (the scroll clamps
470
+ // at its maximum).
471
+ //
472
+ // The row offsets are pure ARITHMETIC, not renderable geometry —
473
+ // rowsHeight (module scope) is the ONE row-height rule the rows' JSX
474
+ // and the row-area cap share. Reading the host's .y was tried and is
475
+ // unreliable across the plugin boundary — it reported the scrollbox's
476
+ // own position for every row — while scrollTop/scrollHeight/height
477
+ // behave as plain numbers.
478
+ let listScroll: ScrollBoxRenderable | undefined
479
+ let pendingReveal: (() => void) | undefined
480
+ const rowOffset = (index: number): number => rowsHeight(rows, index)
481
+ const reveal = (center: boolean) => {
482
+ const box = listScroll
483
+ if (box === undefined) return
484
+ const top = rowOffset(rows.indexOf(selectable[cursor()]))
485
+ const max = Math.max(0, box.scrollHeight - box.height)
486
+ // The look-ahead band, clamped to the window: at box.height <= 3 the
487
+ // three-line margin degenerates to plain ensure-visible (the row
488
+ // lands ON a window line instead of the overscroll an unclamped
489
+ // `box.height - 4` produced once it went negative — cap values of
490
+ // 1-3 are exactly what large font sizes reach).
491
+ const band = Math.min(3, Math.max(0, box.height - 1))
492
+ // Keep the row on-screen whatever the branch: the row's line must
493
+ // stay within [scrollTop, scrollTop + box.height - 1].
494
+ const visible = (target: number): number =>
495
+ Math.min(top, Math.max(top - box.height + 1, target))
496
+ let target = box.scrollTop
497
+ if (center) target = top - Math.floor(box.height / 2)
498
+ else if (top - box.scrollTop > box.height - 1 - band) target = visible(top - (box.height - 1 - band))
499
+ else if (top - box.scrollTop < band) target = visible(top - band)
500
+ box.scrollTo(Math.max(0, Math.min(target, max)))
501
+ }
502
+ // Positions exist only after a layout frame, so the reveal defers to
503
+ // the next rendered frame (the palette's own once(FRAME) +
504
+ // requestRender() pattern) rather than racing the renderer inside the
505
+ // key handler.
506
+ const revealOnFrame = (center: boolean) => {
507
+ if (pendingReveal !== undefined) context.renderer.off("frame", pendingReveal)
508
+ const run = () => {
509
+ pendingReveal = undefined
510
+ if (!alive) return
511
+ reveal(center)
512
+ }
513
+ pendingReveal = run
514
+ context.renderer.once("frame", run)
515
+ context.renderer.requestRender()
516
+ }
292
517
  const close = () => {
293
518
  if (!alive) return
294
519
  alive = false
520
+ // A pending frame reveal is THIS dialog's alone: cancel it here or
521
+ // the once("frame") listener (and its closure over the rows)
522
+ // outlives the dialog — it self-guarded on `alive`, but leaked until
523
+ // the next frame.
524
+ if (pendingReveal !== undefined) {
525
+ context.renderer.off("frame", pendingReveal)
526
+ pendingReveal = undefined
527
+ }
295
528
  // 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.
529
+ // shared state (the keymap layer, the host dialog, the reveal hook)
530
+ // now — clearing here would kill ITS keyboard and surface.
298
531
  if (generation !== dialogGeneration) return
299
532
  armedClose = null
533
+ armedReveal = null
300
534
  setListKeys(null)
301
535
  context.ui.dialog.clear()
302
536
  }
@@ -305,6 +539,10 @@ export default Plugin.define({
305
539
  // first is the last (the command palette's behavior).
306
540
  const move = (delta: number) => {
307
541
  setCursor((cursor() + delta + selectable.length) % selectable.length)
542
+ // Frame-deferred like every reveal — and it CANCELS a pending one,
543
+ // so the preselected row's open-time centering can never land after
544
+ // the user has already moved the cursor off it.
545
+ revealOnFrame(false)
308
546
  }
309
547
  const pick = () => {
310
548
  const row = selectable[cursor()]
@@ -316,6 +554,7 @@ export default Plugin.define({
316
554
  // slot below); it registers on the next reactive run. This dialog is
317
555
  // now the armed one (the lifecycle token above).
318
556
  armedClose = close
557
+ armedReveal = revealOnFrame
319
558
  setListKeys({ move, pick })
320
559
 
321
560
  context.ui.dialog.show(
@@ -332,7 +571,19 @@ export default Plugin.define({
332
571
  esc
333
572
  </text>
334
573
  </box>
335
- <box paddingLeft={1} paddingRight={1} flexDirection="column">
574
+ <scrollbox
575
+ ref={(element) => {
576
+ listScroll = element
577
+ // The report's preselected row (the current pick) opens
578
+ // centered in the scrollbox: scheduled from the ref, so the
579
+ // frame it waits for is the one that lays these rows out.
580
+ if (initial > 0) revealOnFrame(true)
581
+ }}
582
+ maxHeight={cappedRowsHeight(rowsHeight(rows), terminalSize().height)}
583
+ paddingLeft={1}
584
+ paddingRight={1}
585
+ scrollbarOptions={{ visible: false }}
586
+ >
336
587
  {(() => {
337
588
  let headings = 0
338
589
  return rows.map((row) => {
@@ -343,11 +594,14 @@ export default Plugin.define({
343
594
  }
344
595
  if (row.heading !== undefined) {
345
596
  // 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.
597
+ // blank line above every heading but the first — the
598
+ // blank is rowLines' call (the ONE row-height rule the
599
+ // offsets and the cap share), not a second copy of its
600
+ // condition. Flush with the title: 1 (list inset) + 3 = 4.
601
+ const paddingTop = rowLines(row, headings) - 1
348
602
  headings += 1
349
603
  return (
350
- <box paddingTop={headings > 1 ? 1 : 0} paddingLeft={3}>
604
+ <box paddingTop={paddingTop} paddingLeft={3}>
351
605
  <text fg={accent} attributes={TextAttributes.BOLD}>
352
606
  {row.heading}
353
607
  </text>
@@ -388,21 +642,32 @@ export default Plugin.define({
388
642
  )
389
643
  })
390
644
  })()}
391
- </box>
645
+ </scrollbox>
646
+ {footerLines.map((line) => (
647
+ <box paddingLeft={4}>
648
+ <text fg={muted}>{line}</text>
649
+ </box>
650
+ ))}
392
651
  <box paddingLeft={4}>
393
- <text fg={muted}>Details: https://canonical.github.io/model-compare</text>
652
+ <text fg={muted}>Details: https://model-compare.dev</text>
394
653
  </box>
395
654
  </box>
396
655
  ),
397
656
  close,
398
657
  )
658
+ // The palette's presentation: vertically centered at the medium width.
659
+ // The size/centered state persists across dialogs (the host's own set
660
+ // it on every open), so the explicit set also clears a stale xlarge a
661
+ // host dialog may have left behind.
662
+ context.ui.dialog.set({ size: "medium", centered: true })
399
663
  }
400
664
 
401
665
  // The picks dialog: the three priorities, one cursor, palette styling.
402
666
  // A snapshot at open time; unavailable priorities are dimmed — the
403
667
  // cursor still reaches them, and choosing one is refused with a toast
404
668
  // (switchToPriority's guard). Choosing an available priority switches
405
- // 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).
406
671
  function showPicksDialog(): void {
407
672
  const views = aliases()
408
673
  const rows: ListRow[] = PRIORITIES.map((priority) => {
@@ -425,9 +690,17 @@ export default Plugin.define({
425
690
  // model switches straight to it. Empty entries mean the command had no
426
691
  // ranking data.
427
692
  function showReportDialog(data: {
428
- sessionID: string
693
+ // The session the report belongs to — null when the dialog was opened
694
+ // with no session (the opening screen's direct-RPC path); a pick then
695
+ // creates a session bound to the chosen model instead of switching.
696
+ sessionID: string | null
429
697
  age: string | null
430
698
  entries: ReportEntry[]
699
+ // The footer (design §Steering visibility, the user's option C): the
700
+ // steering note above the last-response line, both omitted when their
701
+ // data is absent — never a placeholder.
702
+ capture: { endpoint: string; model: string; downgrade: boolean } | null
703
+ steeringNote: string | null
431
704
  }): void {
432
705
  if (data.entries.length === 0) {
433
706
  notify("no ranking data available yet", "error")
@@ -494,10 +767,30 @@ export default Plugin.define({
494
767
  })
495
768
  }
496
769
  }
770
+ // The report dialog's footer (design §Decisions, option C): the
771
+ // steering note ABOVE the last-response line, both omitted when their
772
+ // data is absent — never a placeholder line.
773
+ const footerLines: string[] = []
774
+ if (data.steeringNote !== null) footerLines.push(data.steeringNote)
775
+ const lastResponse = lastResponseLine(data.capture)
776
+ if (lastResponse !== null) footerLines.push(lastResponse)
497
777
  showListDialog(
498
778
  `Model Compare — top 5 (data age: ${data.age ?? "unknown"})`,
499
779
  rows,
500
- (id) => void switchToModel(data.sessionID, id),
780
+ (id) => {
781
+ const sessionID = data.sessionID
782
+ if (sessionID !== null) {
783
+ void switchToModel(sessionID, id)
784
+ return
785
+ }
786
+ // The opening screen: the pick becomes a NEW session's model —
787
+ // the same concrete model the in-session path switches to.
788
+ void createSessionOnModel(
789
+ { providerID: ALIAS_PROVIDER, id },
790
+ `switched to openrouter/${sanitizeCatalogText(id)}`,
791
+ )
792
+ },
793
+ footerLines,
501
794
  )
502
795
  }
503
796
 
@@ -510,7 +803,13 @@ export default Plugin.define({
510
803
  const event = details as {
511
804
  type?: unknown
512
805
  location?: { directory?: unknown }
513
- data?: { sessionID?: unknown; age?: unknown; entries?: unknown }
806
+ data?: {
807
+ sessionID?: unknown
808
+ age?: unknown
809
+ entries?: unknown
810
+ capture?: unknown
811
+ steeringNote?: unknown
812
+ }
514
813
  }
515
814
  if (event.type !== REPORT_EVENT) return
516
815
  if (
@@ -522,40 +821,49 @@ export default Plugin.define({
522
821
  const data = event.data
523
822
  if (typeof data?.sessionID !== "string" || data.sessionID !== currentSessionID()) return
524
823
  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
- })
824
+ const entries = toReportEntries(data.entries)
549
825
  const eventSessionID = data.sessionID
550
826
  // Ownerless callback: park the report on the signal — the app slot's
551
- // in-tree effect opens the dialog (see pendingReport above).
827
+ // in-tree effect opens the dialog (see pendingReport above). The
828
+ // footer's fields ride the payload like the entries (defensively
829
+ // normalized at this trust boundary).
552
830
  setPendingReport({
553
831
  sessionID: eventSessionID,
554
832
  age: typeof data.age === "string" ? data.age : null,
555
833
  entries,
834
+ capture: toCapture(data.capture),
835
+ steeringNote: toSteeringNote(data.steeringNote),
556
836
  })
557
837
  })
558
838
 
839
+ // The cannot-steer fallback (Task 6): the runtime hook emits this event
840
+ // once per session when a steerable alias request finds no steering data
841
+ // (no catalog source). The TUI surfaces it as a toast with the exact
842
+ // fallback copy; headless shows nothing — like every TUI-coupled surface.
843
+ // Scoped exactly like the report listener: a foreign directory's session
844
+ // is never viewable here, and only the CURRENTLY viewed session toasts.
845
+ // Trade-off: a cannotSteer notice for a same-project background session
846
+ // (or a session the user isn't currently viewing) is dropped forever —
847
+ // the emit is once-per-session — but noise from unopened sessions is the
848
+ // worse failure; the notice is about the session the user interacts with.
849
+ const offCannotSteerEvent = context.data.listen(({ details }) => {
850
+ const event = details as {
851
+ type?: unknown
852
+ location?: { directory?: unknown }
853
+ data?: { sessionID?: unknown }
854
+ }
855
+ if (event.type !== CANNOT_STEER_EVENT) return
856
+ if (
857
+ typeof event.location?.directory === "string" &&
858
+ here !== undefined &&
859
+ event.location.directory !== here
860
+ )
861
+ return
862
+ const data = event.data
863
+ if (typeof data?.sessionID !== "string" || data.sessionID !== currentSessionID()) return
864
+ notify(CANNOT_STEER_COPY)
865
+ })
866
+
559
867
  createRoot((dispose) => {
560
868
  disposeRoot = dispose
561
869
 
@@ -628,29 +936,27 @@ export default Plugin.define({
628
936
 
629
937
  // The picks dialog's command. No slash: /use-best already covers the
630
938
  // switching surface (the server command is slash-routable; the TUI's
631
- // duplicate of it was removed for exactly that reason).
939
+ // duplicate of it was removed for exactly that reason). The dialog opens
940
+ // everywhere — with no session it shows the current picks, and only
941
+ // picking one creates a session bound to it (switchToPriority).
632
942
  const picksCommand: KeymapCommand = {
633
943
  id: "model-compare.picks",
634
944
  title: "Model-compare top picks",
635
945
  group: "Model Compare",
636
946
  bind: "alt+m",
637
947
  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
- },
948
+ run: () => showPicksDialog(),
645
949
  }
646
950
 
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.
951
+ // The report command: with a session open it posts the server's
952
+ // /model-compare command, whose RPC event opens the selectable report
953
+ // dialog here (and the transcript keeps the notice). With no session
954
+ // (the opening screen) there is nothing to post to, so the report is
955
+ // fetched straight from the plugin's report RPC — same builder, same
956
+ // entries — and picking a model creates a session bound to it (there is
957
+ // no composer-model setter to change instead; verified against the
958
+ // pinned runtime). The refusal-free opening also keeps the footer's
959
+ // status line honest: the shortcuts always DO something.
654
960
  const reportCommand: KeymapCommand = {
655
961
  id: "model-compare.report",
656
962
  title: "Model-compare report",
@@ -659,18 +965,74 @@ export default Plugin.define({
659
965
  palette: true,
660
966
  run: async () => {
661
967
  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")
968
+ if (sessionID !== null) {
969
+ try {
970
+ await context.client.session.command({ sessionID, name: "model-compare", text: "" })
971
+ } catch (error) {
972
+ notify(`report failed: ${error instanceof Error ? error.message : String(error)}`, "error")
973
+ }
664
974
  return
665
975
  }
666
976
  try {
667
- await context.client.session.command({ sessionID, name: "model-compare", text: "" })
977
+ const report: unknown = await context.client.rpc(ModelCompare).report(
978
+ {},
979
+ here === undefined ? undefined : { location: { directory: here } },
980
+ )
981
+ const data = report as
982
+ | { age?: unknown; entries?: unknown; capture?: unknown; steeringNote?: unknown }
983
+ | undefined
984
+ showReportDialog({
985
+ sessionID: null,
986
+ age: typeof data?.age === "string" ? data.age : null,
987
+ entries: toReportEntries(data?.entries),
988
+ capture: toCapture(data?.capture),
989
+ steeringNote: toSteeringNote(data?.steeringNote),
990
+ })
668
991
  } catch (error) {
669
992
  notify(`report failed: ${error instanceof Error ? error.message : String(error)}`, "error")
670
993
  }
671
994
  },
672
995
  }
673
996
 
997
+ // The served-endpoint toast (design §Steering visibility): alt+e toasts
998
+ // the CURRENT session's last response's served endpoint, looked up
999
+ // through the report RPC's footer fields (the capture record rides the
1000
+ // established report payload path). The copy is exact — normal:
1001
+ // "last response served by <endpoint>"; downgrade (the served provider
1002
+ // matched none of the applied steering order): "! served by <endpoint> —
1003
+ // not in steering order"; nothing toasts when nothing was captured for
1004
+ // the session (and with no session open there is nothing to look up).
1005
+ const endpointCommand: KeymapCommand = {
1006
+ id: "model-compare.endpoint",
1007
+ title: "Last served endpoint",
1008
+ group: "Model Compare",
1009
+ bind: "alt+e",
1010
+ palette: true,
1011
+ run: async () => {
1012
+ const sessionID = currentSessionID()
1013
+ if (sessionID === null) return
1014
+ try {
1015
+ const report: unknown = await context.client.rpc(ModelCompare).report(
1016
+ { sessionID },
1017
+ here === undefined ? undefined : { location: { directory: here } },
1018
+ )
1019
+ const capture = toCapture((report as { capture?: unknown } | undefined)?.capture)
1020
+ if (capture === null) return
1021
+ // F-20: the endpoint is response data — flattened for the toast.
1022
+ notify(
1023
+ capture.downgrade
1024
+ ? `! served by ${sanitizeCatalogText(capture.endpoint)} — not in steering order`
1025
+ : `last response served by ${sanitizeCatalogText(capture.endpoint)}`,
1026
+ )
1027
+ } catch (error) {
1028
+ notify(
1029
+ `endpoint lookup failed: ${error instanceof Error ? error.message : String(error)}`,
1030
+ "error",
1031
+ )
1032
+ }
1033
+ },
1034
+ }
1035
+
674
1036
  // The palette-only use-best: the immediate-toast variant. The SLASH
675
1037
  // surface is left to the server command (one entry everywhere); the
676
1038
  // palette is TUI-layer territory, where this variant's instant feedback
@@ -751,7 +1113,7 @@ export default Plugin.define({
751
1113
  try {
752
1114
  context.keymap.layer(() => ({
753
1115
  mode: "global",
754
- commands: [useBestCommand, picksCommand, reportCommand],
1116
+ commands: [useBestCommand, picksCommand, reportCommand, endpointCommand],
755
1117
  }))
756
1118
  } catch (error) {
757
1119
  layerFailure("the model-compare keymap layer", error)
@@ -761,8 +1123,15 @@ export default Plugin.define({
761
1123
  })
762
1124
 
763
1125
  return () => {
1126
+ // Disarm whatever dialog is still armed FIRST: its pending frame
1127
+ // reveal and modal keyboard must not outlive the plugin (the
1128
+ // module-scope armedClose/armedReveal survive setup's cleanup
1129
+ // otherwise).
1130
+ armedClose?.()
764
1131
  if (resyncTimer !== undefined) clearInterval(resyncTimer)
1132
+ context.renderer.off("resize", onRendererResize)
765
1133
  offReportEvent()
1134
+ offCannotSteerEvent()
766
1135
  disposeRoot()
767
1136
  disposeAppSlot()
768
1137
  disposeHomeFooter()