model-compare-opencode-plugin 0.1.3 → 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
@@ -68,7 +68,7 @@ import {
68
68
  type AliasView,
69
69
  } from "./opencode/usebest"
70
70
  import type { ReportEntry } from "./opencode/commands"
71
- import { sanitizeCatalogText } from "./opencode/commands"
71
+ import { lastResponseLine, sanitizeCatalogText } from "./opencode/commands"
72
72
  import { ModelCompare } from "./rpc"
73
73
 
74
74
  const TOAST_MS = 6_000
@@ -81,6 +81,11 @@ const PACKAGE_FRAGMENT = "model-compare-opencode-plugin"
81
81
  // The RPC event the /model-compare command emits (see src/rpc.ts; the wire
82
82
  // name is prefixed with the RPC's id).
83
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"
84
89
 
85
90
  // The list dialogs' lifecycle token (see showListDialog): a module-scope
86
91
  // generation counter, bumped every time a dialog is armed, plus the armed
@@ -189,6 +194,30 @@ function toReportEntries(raw: unknown): ReportEntry[] {
189
194
  })
190
195
  }
191
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
+
192
221
  export default Plugin.define({
193
222
  id: "model-compare-tui",
194
223
  async setup(context) {
@@ -236,6 +265,8 @@ export default Plugin.define({
236
265
  sessionID: string
237
266
  age: string | null
238
267
  entries: ReportEntry[]
268
+ capture: { endpoint: string; model: string; downgrade: boolean } | null
269
+ steeringNote: string | null
239
270
  } | null>(null)
240
271
 
241
272
  // The registry view, reactive: OpenCode's data layer updates the list
@@ -387,7 +418,15 @@ export default Plugin.define({
387
418
  pick: () => void
388
419
  } | null>(null)
389
420
 
390
- 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 {
391
430
  const selectable = rows.filter((row) => row.value !== undefined)
392
431
  if (selectable.length === 0) {
393
432
  notify("nothing to select", "error")
@@ -604,8 +643,13 @@ export default Plugin.define({
604
643
  })
605
644
  })()}
606
645
  </scrollbox>
646
+ {footerLines.map((line) => (
647
+ <box paddingLeft={4}>
648
+ <text fg={muted}>{line}</text>
649
+ </box>
650
+ ))}
607
651
  <box paddingLeft={4}>
608
- <text fg={muted}>Details: https://canonical.github.io/model-compare</text>
652
+ <text fg={muted}>Details: https://model-compare.dev</text>
609
653
  </box>
610
654
  </box>
611
655
  ),
@@ -652,6 +696,11 @@ export default Plugin.define({
652
696
  sessionID: string | null
653
697
  age: string | null
654
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
655
704
  }): void {
656
705
  if (data.entries.length === 0) {
657
706
  notify("no ranking data available yet", "error")
@@ -718,12 +767,20 @@ export default Plugin.define({
718
767
  })
719
768
  }
720
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)
721
777
  showListDialog(
722
778
  `Model Compare — top 5 (data age: ${data.age ?? "unknown"})`,
723
779
  rows,
724
780
  (id) => {
725
- if (data.sessionID !== null) {
726
- void switchToModel(data.sessionID, id)
781
+ const sessionID = data.sessionID
782
+ if (sessionID !== null) {
783
+ void switchToModel(sessionID, id)
727
784
  return
728
785
  }
729
786
  // The opening screen: the pick becomes a NEW session's model —
@@ -733,6 +790,7 @@ export default Plugin.define({
733
790
  `switched to openrouter/${sanitizeCatalogText(id)}`,
734
791
  )
735
792
  },
793
+ footerLines,
736
794
  )
737
795
  }
738
796
 
@@ -745,7 +803,13 @@ export default Plugin.define({
745
803
  const event = details as {
746
804
  type?: unknown
747
805
  location?: { directory?: unknown }
748
- 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
+ }
749
813
  }
750
814
  if (event.type !== REPORT_EVENT) return
751
815
  if (
@@ -760,14 +824,46 @@ export default Plugin.define({
760
824
  const entries = toReportEntries(data.entries)
761
825
  const eventSessionID = data.sessionID
762
826
  // Ownerless callback: park the report on the signal — the app slot's
763
- // 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).
764
830
  setPendingReport({
765
831
  sessionID: eventSessionID,
766
832
  age: typeof data.age === "string" ? data.age : null,
767
833
  entries,
834
+ capture: toCapture(data.capture),
835
+ steeringNote: toSteeringNote(data.steeringNote),
768
836
  })
769
837
  })
770
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
+
771
867
  createRoot((dispose) => {
772
868
  disposeRoot = dispose
773
869
 
@@ -882,11 +978,15 @@ export default Plugin.define({
882
978
  {},
883
979
  here === undefined ? undefined : { location: { directory: here } },
884
980
  )
885
- const data = report as { age?: unknown; entries?: unknown } | undefined
981
+ const data = report as
982
+ | { age?: unknown; entries?: unknown; capture?: unknown; steeringNote?: unknown }
983
+ | undefined
886
984
  showReportDialog({
887
985
  sessionID: null,
888
986
  age: typeof data?.age === "string" ? data.age : null,
889
987
  entries: toReportEntries(data?.entries),
988
+ capture: toCapture(data?.capture),
989
+ steeringNote: toSteeringNote(data?.steeringNote),
890
990
  })
891
991
  } catch (error) {
892
992
  notify(`report failed: ${error instanceof Error ? error.message : String(error)}`, "error")
@@ -894,6 +994,45 @@ export default Plugin.define({
894
994
  },
895
995
  }
896
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
+
897
1036
  // The palette-only use-best: the immediate-toast variant. The SLASH
898
1037
  // surface is left to the server command (one entry everywhere); the
899
1038
  // palette is TUI-layer territory, where this variant's instant feedback
@@ -974,7 +1113,7 @@ export default Plugin.define({
974
1113
  try {
975
1114
  context.keymap.layer(() => ({
976
1115
  mode: "global",
977
- commands: [useBestCommand, picksCommand, reportCommand],
1116
+ commands: [useBestCommand, picksCommand, reportCommand, endpointCommand],
978
1117
  }))
979
1118
  } catch (error) {
980
1119
  layerFailure("the model-compare keymap layer", error)
@@ -992,6 +1131,7 @@ export default Plugin.define({
992
1131
  if (resyncTimer !== undefined) clearInterval(resyncTimer)
993
1132
  context.renderer.off("resize", onRendererResize)
994
1133
  offReportEvent()
1134
+ offCannotSteerEvent()
995
1135
  disposeRoot()
996
1136
  disposeAppSlot()
997
1137
  disposeHomeFooter()
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "repo": "canonical/model-compare",
3
- "tag": "v0.3.1",
4
- "commit": "fb873265fb687b79ba828ae3561355360645cd0b",
5
- "sha256": "9c50207254d260cc442d7e65aaa17b5ce025ee9c1778fe131054ac74a78594e1"
3
+ "tag": "v0.4.0",
4
+ "commit": "6f4b59f93fec4bbad3068c531d401c91cb9a2ce9",
5
+ "sha256": "f70b3e02811c5d5dc53a4f23d5a0d1efa2b03ceda9124c5ad7b2013412b00135"
6
6
  }