@qadanm/opencode-smart-router 0.0.0-stage → 0.4.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/CONTRIBUTING.md +96 -0
  3. package/LICENSE +21 -0
  4. package/README.md +341 -2
  5. package/SECURITY.md +174 -0
  6. package/config.example.json +108 -0
  7. package/dist/allowance.js +487 -0
  8. package/dist/gui-experimental.cjs +1 -0
  9. package/dist/index.js +11111 -0
  10. package/dist/npm/index.js +40 -0
  11. package/dist/tui.js +587 -0
  12. package/docs/ARCHITECTURE.md +104 -0
  13. package/docs/DEBUGGING-GUI.md +70 -0
  14. package/docs/INSTALL.md +120 -0
  15. package/docs/LIMITATIONS.md +213 -0
  16. package/docs/MODELS.md +124 -0
  17. package/docs/OPENDESIGN-INTEGRATION.md +635 -0
  18. package/docs/QA.md +142 -0
  19. package/docs/SHARED-CORE.md +72 -0
  20. package/install +75 -0
  21. package/package.json +92 -4
  22. package/scripts/install.sh +324 -0
  23. package/scripts/uninstall.sh +91 -0
  24. package/src/allowance.ts +135 -0
  25. package/src/core/classify/classifier.ts +305 -0
  26. package/src/core/config-writer.ts +167 -0
  27. package/src/core/config.ts +1030 -0
  28. package/src/core/context/strategy.ts +188 -0
  29. package/src/core/context-health.ts +39 -0
  30. package/src/core/failures.ts +398 -0
  31. package/src/core/history/opencode.ts +173 -0
  32. package/src/core/models/protocol.ts +22 -0
  33. package/src/core/models/registry.ts +203 -0
  34. package/src/core/models/seed.ts +771 -0
  35. package/src/core/offline/mode.ts +321 -0
  36. package/src/core/plan-page.ts +390 -0
  37. package/src/core/plans/catalog.ts +481 -0
  38. package/src/core/policy/calibration.ts +259 -0
  39. package/src/core/policy/eligibility.ts +578 -0
  40. package/src/core/providers/enablement.ts +143 -0
  41. package/src/core/providers/opendesign-credits.ts +206 -0
  42. package/src/core/providers/opendesign-models.ts +628 -0
  43. package/src/core/providers/opendesign.ts +474 -0
  44. package/src/core/quota/calibration.ts +166 -0
  45. package/src/core/quota/commandcode.ts +471 -0
  46. package/src/core/quota/credentials.ts +490 -0
  47. package/src/core/quota/ledger.ts +1273 -0
  48. package/src/core/quota/opencode-go.ts +148 -0
  49. package/src/core/quota/opendesign.ts +1043 -0
  50. package/src/core/quota/reconcile.ts +155 -0
  51. package/src/core/quota/report.ts +261 -0
  52. package/src/core/quota/service.ts +273 -0
  53. package/src/core/reprompt.ts +48 -0
  54. package/src/core/routing/engine.ts +919 -0
  55. package/src/core/types.ts +825 -0
  56. package/src/core/ui.ts +236 -0
  57. package/src/core/util.ts +114 -0
  58. package/src/gui/index.tsx +1670 -0
  59. package/src/gui/viewmodel.ts +456 -0
  60. package/src/index.ts +48 -0
  61. package/src/rpc.ts +204 -0
  62. package/src/server/controller.ts +3281 -0
  63. package/src/server/route-tracker.ts +60 -0
  64. package/src/server/shared-core.ts +671 -0
  65. package/src/tui/index.tsx +168 -0
@@ -0,0 +1,1670 @@
1
+ import { Command, Panel, Plugin, Slot, usePlugin, type PanelTab, type PanelProps } from "@opencode/plugin/gui"
2
+ import { createSignal, For, onCleanup, onMount, Show } from "solid-js"
3
+ import { SmartRouterRpc } from "../rpc.js"
4
+ import {
5
+ formatPercent,
6
+ formatUsd,
7
+ formatWindowUsed,
8
+ providerGroups,
9
+ statusLine,
10
+ summariseDecision,
11
+ toProviderRow,
12
+ type DecisionView,
13
+ type ProviderGroupView,
14
+ type ProviderRow,
15
+ } from "./viewmodel.js"
16
+
17
+ /**
18
+ * The Smart Router panel.
19
+ *
20
+ * Design intent: this should read as one product, not a debug dump with a title.
21
+ * Three rules hold it together.
22
+ *
23
+ * 1. **One scale.** Every size comes from `type`, every gap from `space`. The
24
+ * previous version used seven font sizes chosen ad hoc, which is what makes
25
+ * an interface look assembled rather than designed.
26
+ * 2. **Hierarchy by weight and space, not by lines.** Four full-width dividers
27
+ * with equal weight said "here are five unrelated things". Sections now
28
+ * carry their own label and use whitespace to separate.
29
+ * 3. **The brand earns its place.** The mark and its teal-to-cyan gradient are
30
+ * the same assets shipped in `assets/`. An accent used once, on the thing
31
+ * that matters, reads as deliberate; used everywhere it reads as decoration.
32
+ */
33
+
34
+ /**
35
+ * Type scale.
36
+ *
37
+ * Sized against published guidance rather than taste. Body text is 14px and
38
+ * secondary text 13px, which is the floor for comfortable reading on a desktop;
39
+ * the previous scale ran 9.5–13px and was a caption scale applied to prose. That
40
+ * is the single most common accessibility failure in dense interfaces, and it
41
+ * reads as miniature rather than compact.
42
+ *
43
+ * `label` exists for uppercase section headings only. Uppercase with letter
44
+ * spacing survives at 11px in a way lowercase prose does not, so it gets its own
45
+ * step instead of being borrowed for body copy.
46
+ *
47
+ * Inputs are 14px to match body: the iOS zoom-on-focus behaviour starts below
48
+ * 16px, and 16px looks oversized inside a side panel, so 14 is the compromise
49
+ * that stays readable without zoom.
50
+ */
51
+ const type = {
52
+ label: "11px",
53
+ caption: "13px",
54
+ body: "14px",
55
+ title: "16px",
56
+ display: "20px",
57
+ } as const
58
+
59
+ const space = { xxs: "2px", xs: "4px", sm: "6px", md: "8px", lg: "12px", xl: "16px" } as const
60
+ const radius = { sm: "6px", md: "8px", lg: "10px" } as const
61
+
62
+ const theme = {
63
+ text: "var(--text-primary, #e8e8ec)",
64
+ muted: "var(--text-secondary, #9a9aa6)",
65
+ faint: "var(--text-tertiary, #6f6f7c)",
66
+ border: "var(--border-subtle, rgba(255,255,255,0.09))",
67
+ borderStrong: "var(--border-default, rgba(255,255,255,0.16))",
68
+ surface: "var(--surface-raised, rgba(255,255,255,0.04))",
69
+ raised: "var(--surface-overlay, rgba(255,255,255,0.07))",
70
+ good: "var(--text-success, #7ad19a)",
71
+ warn: "var(--text-warning, #e0b463)",
72
+ bad: "var(--text-danger, #e08a8a)",
73
+ /** Brand accents, taken from assets/logo.svg. */
74
+ brand: "#0f766e",
75
+ brandTo: "#0891b2",
76
+ } as const
77
+
78
+ const mono = "ui-monospace, SFMono-Regular, Menlo, monospace"
79
+ const brandGradient = `linear-gradient(135deg, ${theme.brand} 0%, ${theme.brandTo} 100%)`
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Primitives
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /** A labelled region. The label is the hierarchy; no rule line needed. */
86
+ function Section(props: {
87
+ label: string
88
+ aside?: unknown
89
+ children: unknown
90
+ first?: boolean
91
+ }) {
92
+ return (
93
+ <div
94
+ style={{
95
+ display: "flex",
96
+ "flex-direction": "column",
97
+ gap: space.sm,
98
+ ...(props.first ? {} : { "padding-top": space.lg, "border-top": `1px solid ${theme.border}` }),
99
+ }}
100
+ >
101
+ <div style={{ display: "flex", "justify-content": "space-between", "align-items": "baseline", gap: space.md }}>
102
+ <span
103
+ style={{
104
+ color: theme.faint,
105
+ "font-size": type.label,
106
+ "text-transform": "uppercase",
107
+ "letter-spacing": "0.06em",
108
+ "font-weight": 600,
109
+ }}
110
+ >
111
+ {props.label}
112
+ </span>
113
+ {props.aside as never}
114
+ </div>
115
+ {props.children as never}
116
+ </div>
117
+ )
118
+ }
119
+
120
+ /** A surface that groups related values. Used sparingly: too many cards is noise. */
121
+ function Card(props: { children: unknown; accent?: boolean }) {
122
+ return (
123
+ <div
124
+ style={{
125
+ display: "flex",
126
+ "flex-direction": "column",
127
+ gap: space.sm,
128
+ padding: "10px 11px",
129
+ "border-radius": radius.md,
130
+ border: `1px solid ${props.accent ? theme.borderStrong : theme.border}`,
131
+ background: theme.surface,
132
+ }}
133
+ >
134
+ {props.children as never}
135
+ </div>
136
+ )
137
+ }
138
+
139
+ /** A label above a value. Numbers are tabular so columns line up. */
140
+ function Stat(props: { label: string; value: unknown; tone?: "good" | "warn" | "bad" | "muted" }) {
141
+ const colour =
142
+ props.tone === "good"
143
+ ? theme.good
144
+ : props.tone === "warn"
145
+ ? theme.warn
146
+ : props.tone === "bad"
147
+ ? theme.bad
148
+ : theme.muted
149
+ return (
150
+ <span style={{ display: "flex", "flex-direction": "column", gap: "1px", "min-width": 0 }}>
151
+ <span style={{ color: theme.faint, "font-size": type.label }}>{props.label}</span>
152
+ <span style={{ color: colour, "font-size": type.body, "font-variant-numeric": "tabular-nums" }}>
153
+ {props.value as never}
154
+ </span>
155
+ </span>
156
+ )
157
+ }
158
+
159
+ /** A usage meter. Width is the value; colour is the urgency. */
160
+ function Meter(props: { usedPct: number; exhausted?: boolean }) {
161
+ const width = Math.min(100, Math.max(0, props.usedPct))
162
+ const colour = props.exhausted
163
+ ? theme.bad
164
+ : width >= 80
165
+ ? theme.warn
166
+ : width >= 50
167
+ ? theme.brandTo
168
+ : theme.good
169
+ return (
170
+ <div
171
+ role="meter"
172
+ aria-valuenow={Math.round(width)}
173
+ aria-valuemin={0}
174
+ aria-valuemax={100}
175
+ style={{
176
+ flex: 1,
177
+ height: "3px",
178
+ "border-radius": "2px",
179
+ background: "rgba(255,255,255,0.09)",
180
+ overflow: "hidden",
181
+ "min-width": "36px",
182
+ }}
183
+ >
184
+ <div style={{ width: `${width}%`, height: "100%", background: colour, "border-radius": "2px" }} />
185
+ </div>
186
+ )
187
+ }
188
+
189
+ function Chip(props: { children: unknown; tone?: "good" | "warn" | "bad" | "neutral" | "brand" }) {
190
+ const bg =
191
+ props.tone === "good"
192
+ ? "rgba(122,209,154,0.12)"
193
+ : props.tone === "warn"
194
+ ? "rgba(224,180,99,0.12)"
195
+ : props.tone === "bad"
196
+ ? "rgba(224,138,138,0.12)"
197
+ : props.tone === "brand"
198
+ ? "rgba(8,145,178,0.16)"
199
+ : "rgba(255,255,255,0.06)"
200
+ const fg =
201
+ props.tone === "good"
202
+ ? theme.good
203
+ : props.tone === "warn"
204
+ ? theme.warn
205
+ : props.tone === "bad"
206
+ ? theme.bad
207
+ : props.tone === "brand"
208
+ ? theme.brandTo
209
+ : theme.muted
210
+ return (
211
+ <span
212
+ style={{
213
+ background: bg,
214
+ color: fg,
215
+ "font-size": type.label,
216
+ "font-weight": 600,
217
+ "text-transform": "uppercase",
218
+ "letter-spacing": "0.05em",
219
+ padding: "2px 6px",
220
+ "border-radius": "4px",
221
+ "white-space": "nowrap",
222
+ }}
223
+ >
224
+ {props.children as never}
225
+ </span>
226
+ )
227
+ }
228
+
229
+ /** Teaching empty state. Never a blank box: say what would fill it. */
230
+ function Empty(props: { title: string; body: string }) {
231
+ return (
232
+ <div
233
+ style={{
234
+ display: "flex",
235
+ "flex-direction": "column",
236
+ gap: "3px",
237
+ padding: "12px 11px",
238
+ "border-radius": radius.md,
239
+ border: `1px dashed ${theme.border}`,
240
+ }}
241
+ >
242
+ <span style={{ color: theme.muted, "font-size": type.body, "font-weight": 550 }}>{props.title}</span>
243
+ <span style={{ color: theme.faint, "font-size": type.caption, "line-height": 1.5 }}>{props.body}</span>
244
+ </div>
245
+ )
246
+ }
247
+
248
+ /** A row that opens. Caret, hover, cursor and aria together — one alone is invisible. */
249
+ function Disclosure(props: {
250
+ id: string
251
+ open: boolean
252
+ onToggle: () => void
253
+ hovered: boolean
254
+ onHover: (id: string | undefined) => void
255
+ children: unknown
256
+ aside?: unknown
257
+ size?: "section" | "row"
258
+ }) {
259
+ const section = props.size !== "row"
260
+ return (
261
+ <button
262
+ type="button"
263
+ onClick={props.onToggle}
264
+ onMouseEnter={() => props.onHover(props.id)}
265
+ onMouseLeave={() => props.onHover(undefined)}
266
+ aria-expanded={props.open}
267
+ title={props.open ? "Collapse" : "Expand"}
268
+ style={{
269
+ display: "flex",
270
+ "justify-content": "space-between",
271
+ "align-items": "center",
272
+ gap: space.md,
273
+ width: "100%",
274
+ background: props.hovered ? theme.surface : "transparent",
275
+ border: "none",
276
+ "border-radius": radius.sm,
277
+ padding: "5px 7px",
278
+ margin: "-5px -7px",
279
+ color: "inherit",
280
+ font: "inherit",
281
+ cursor: "pointer",
282
+ "text-align": "left",
283
+ transition: "background 100ms ease",
284
+ }}
285
+ >
286
+ <span style={{ display: "flex", gap: "7px", "align-items": "center", "min-width": 0 }}>
287
+ <span
288
+ aria-hidden="true"
289
+ style={{
290
+ display: "inline-block",
291
+ width: "9px",
292
+ color: props.hovered ? theme.text : theme.faint,
293
+ "font-size": "9px",
294
+ "flex-shrink": 0,
295
+ transform: props.open ? "rotate(90deg)" : "none",
296
+ transition: "transform 120ms ease",
297
+ }}
298
+ >
299
+ ▸
300
+ </span>
301
+ <span
302
+ style={
303
+ section
304
+ ? {
305
+ color: theme.faint,
306
+ "font-size": type.label,
307
+ "text-transform": "uppercase",
308
+ "letter-spacing": "0.06em",
309
+ "font-weight": 600,
310
+ }
311
+ : { color: theme.text, "font-size": type.title, "font-weight": 550 }
312
+ }
313
+ >
314
+ {props.children as never}
315
+ </span>
316
+ </span>
317
+ <span style={{ display: "flex", gap: space.md, "align-items": "baseline", "flex-shrink": 0 }}>
318
+ {props.aside as never}
319
+ </span>
320
+ </button>
321
+ )
322
+ }
323
+
324
+ /** The brand mark, from assets/logo.svg: one input forking into three routes. */
325
+ function BrandMark(props: { size?: number }) {
326
+ const size = props.size ?? 22
327
+ return (
328
+ <svg
329
+ width={size}
330
+ height={size}
331
+ viewBox="0 0 60 60"
332
+ aria-hidden="true"
333
+ style={{ "flex-shrink": 0, display: "block" }}
334
+ >
335
+ <defs>
336
+ <linearGradient id="sr-brand" x1="0" y1="0" x2="1" y2="1">
337
+ <stop offset="0%" stop-color={theme.brand} />
338
+ <stop offset="100%" stop-color={theme.brandTo} />
339
+ </linearGradient>
340
+ </defs>
341
+ <g fill="none" stroke="url(#sr-brand)" stroke-width="4" stroke-linecap="round">
342
+ <path d="M8 30h12" />
343
+ <path d="M20 30c7 0 8-14 15-14h8" />
344
+ <path d="M20 30c7 0 8 14 15 14h8" />
345
+ <path d="M20 30h23" />
346
+ </g>
347
+ <circle cx="5" cy="30" r="4.5" fill="url(#sr-brand)" />
348
+ <g fill="url(#sr-brand)">
349
+ <rect x="45" y="10" width="9" height="9" rx="2.5" />
350
+ <rect x="45" y="25.5" width="9" height="9" rx="2.5" />
351
+ <rect x="45" y="41" width="9" height="9" rx="2.5" />
352
+ </g>
353
+ </svg>
354
+ )
355
+ }
356
+
357
+ function BrandHeader(props: { runtime: string; mode: string }) {
358
+ const tone = props.runtime === "paused" ? "warn" : props.runtime === "manual" ? "neutral" : "brand"
359
+ return (
360
+ <div style={{ display: "flex", "justify-content": "space-between", "align-items": "center", gap: space.md }}>
361
+ <div style={{ display: "flex", gap: space.sm, "align-items": "center", "min-width": 0 }}>
362
+ <BrandMark />
363
+ <div style={{ display: "flex", "flex-direction": "column", gap: "0" }}>
364
+ <span style={{ color: theme.text, "font-size": type.title, "font-weight": 650, "letter-spacing": "-0.2px" }}>
365
+ Smart Router
366
+ </span>
367
+ <span style={{ color: theme.faint, "font-size": type.caption, "letter-spacing": "0.09em", "font-weight": 600 }}>
368
+ FOR OPENCODE
369
+ </span>
370
+ </div>
371
+ </div>
372
+ <div style={{ display: "flex", gap: space.xs, "align-items": "center" }}>
373
+ <Chip tone={tone}>{props.mode}</Chip>
374
+ </div>
375
+ </div>
376
+ )
377
+ }
378
+
379
+ // ---------------------------------------------------------------------------
380
+ // Feature components
381
+ // ---------------------------------------------------------------------------
382
+
383
+ interface RouterStatus {
384
+ mode?: string
385
+ runtime?: string
386
+ current?: { providerID: string; id: string }
387
+ quota?: ReadonlyArray<unknown>
388
+ lastDecision?: unknown
389
+ approvedModels?: ReadonlyArray<unknown>
390
+ models?: Parameters<typeof providerGroups>[0]["models"]
391
+ }
392
+
393
+ const relative = (at: number): string => {
394
+ const delta = Date.now() - at
395
+ if (delta < 5_000) return "just now"
396
+ if (delta < 60_000) return `${Math.round(delta / 1_000)}s ago`
397
+ if (delta < 3_600_000) return `${Math.round(delta / 60_000)}m ago`
398
+ return `${Math.round(delta / 3_600_000)}h ago`
399
+ }
400
+
401
+ /** Is the router choosing the model, or is the user? */
402
+ function AutoSwitch(props: { sessionID: string | undefined; auto: boolean; onChanged: () => void }) {
403
+ const ctx = usePlugin<typeof definition>()
404
+ const [busy, setBusy] = createSignal(false)
405
+ const [hovered, setHovered] = createSignal(false)
406
+
407
+ const toggle = async () => {
408
+ if (!props.sessionID || busy()) return
409
+ setBusy(true)
410
+ try {
411
+ await ctx.client.rpc(SmartRouterRpc).setSessionAuto({ sessionID: props.sessionID, auto: !props.auto })
412
+ props.onChanged()
413
+ } finally {
414
+ setBusy(false)
415
+ }
416
+ }
417
+
418
+ return (
419
+ <button
420
+ type="button"
421
+ onClick={() => void toggle()}
422
+ onMouseEnter={() => setHovered(true)}
423
+ onMouseLeave={() => setHovered(false)}
424
+ disabled={busy() || !props.sessionID}
425
+ aria-pressed={props.auto}
426
+ title={props.auto ? "Click to keep your current model" : "Click to let the router choose"}
427
+ style={{
428
+ display: "flex",
429
+ "align-items": "center",
430
+ "justify-content": "space-between",
431
+ gap: space.md,
432
+ width: "100%",
433
+ padding: "9px 11px",
434
+ "border-radius": radius.md,
435
+ border: `1px solid ${props.auto ? "transparent" : theme.border}`,
436
+ background: props.auto ? "rgba(8,145,178,0.10)" : hovered() ? theme.surface : "transparent",
437
+ color: theme.text,
438
+ cursor: busy() || !props.sessionID ? "default" : "pointer",
439
+ font: "inherit",
440
+ "text-align": "left",
441
+ transition: "background 120ms ease",
442
+ }}
443
+ >
444
+ <span style={{ display: "flex", "flex-direction": "column", gap: "1px" }}>
445
+ <span style={{ "font-size": type.body, "font-weight": 600 }}>Automatic routing</span>
446
+ <span style={{ color: props.auto ? theme.brandTo : theme.faint, "font-size": type.caption }}>
447
+ {props.auto ? "The router picks the model for you" : "You keep your model; the router only observes"}
448
+ </span>
449
+ </span>
450
+ <span
451
+ aria-hidden="true"
452
+ style={{
453
+ width: "30px",
454
+ height: "16px",
455
+ "border-radius": "8px",
456
+ background: props.auto ? brandGradient : "rgba(255,255,255,0.14)",
457
+ position: "relative",
458
+ "flex-shrink": 0,
459
+ transition: "background 140ms ease",
460
+ }}
461
+ >
462
+ <span
463
+ style={{
464
+ position: "absolute",
465
+ top: "2px",
466
+ left: props.auto ? "16px" : "2px",
467
+ width: "12px",
468
+ height: "12px",
469
+ "border-radius": "50%",
470
+ background: "#fff",
471
+ transition: "left 140ms ease",
472
+ }}
473
+ />
474
+ </span>
475
+ </button>
476
+ )
477
+ }
478
+
479
+
480
+ function DecisionInspector(props: { view: DecisionView | undefined; hovered: string | undefined; onHover: (id: string | undefined) => void; expanded: boolean; onToggle: () => void }) {
481
+ return (
482
+ <Section
483
+ label="Last decision"
484
+ aside={
485
+ <Show when={props.view}>
486
+ <span style={{ color: theme.faint, "font-size": type.caption }}>
487
+ {props.view!.eligibleCount} eligible · {props.view!.blockedCount} blocked
488
+ </span>
489
+ </Show>
490
+ }
491
+ >
492
+ <Show
493
+ when={props.view}
494
+ fallback={
495
+ <Empty
496
+ title="No decision yet"
497
+ body="Send a prompt and the router will explain which model it chose, and why it beat the others."
498
+ />
499
+ }
500
+ >
501
+ <Card accent>
502
+ <div style={{ display: "flex", "justify-content": "space-between", gap: space.md, "align-items": "baseline" }}>
503
+ <span
504
+ style={{
505
+ "font-family": mono,
506
+ "font-size": type.body,
507
+ color: theme.text,
508
+ "font-weight": 600,
509
+ overflow: "hidden",
510
+ "text-overflow": "ellipsis",
511
+ "white-space": "nowrap",
512
+ }}
513
+ >
514
+ {props.view!.chosen ?? "nothing eligible"}
515
+ </span>
516
+ <Chip tone={props.view!.chosen ? "good" : "bad"}>{props.view!.chosen ? "chosen" : "blocked"}</Chip>
517
+ </div>
518
+
519
+ <div style={{ display: "flex", gap: space.xl, "flex-wrap": "wrap" }}>
520
+ <Stat label="est. cost" value={formatUsd(props.view!.estimatedUsd)} />
521
+ <Stat label="task" value={`${props.view!.taskKind} · T${props.view!.taskTier}`} />
522
+ <Stat label="context" value={`${Math.round(props.view!.contextTokens / 1000)}k`} />
523
+ </div>
524
+
525
+ <Show when={props.view!.reasons.length > 0}>
526
+ <div style={{ display: "flex", "flex-direction": "column", gap: "3px", "padding-top": space.xs }}>
527
+ <span style={{ color: theme.faint, "font-size": type.caption, "text-transform": "uppercase", "letter-spacing": "0.05em" }}>
528
+ Why
529
+ </span>
530
+ <For each={props.view!.reasons}>
531
+ {(reason) => (
532
+ <div style={{ display: "flex", gap: space.sm, "align-items": "baseline" }}>
533
+ <span style={{ color: theme.brandTo, "font-size": type.caption, "line-height": "1.6" }}>›</span>
534
+ <span style={{ color: theme.muted, "font-size": type.caption, "line-height": 1.5 }}>{reason}</span>
535
+ </div>
536
+ )}
537
+ </For>
538
+ </div>
539
+ </Show>
540
+
541
+ <Show when={props.view!.selfheal}>
542
+ <div style={{ display: "flex", gap: space.sm, "align-items": "baseline", "padding-top": space.xs }}>
543
+ <span style={{ color: theme.brandTo, "font-size": type.caption, "line-height": "1.6" }}>›</span>
544
+ <span style={{ color: theme.text, "font-size": type.caption, "line-height": 1.5 }}>
545
+ Self-healed: {props.view!.selfheal}
546
+ </span>
547
+ </div>
548
+ </Show>
549
+ </Card>
550
+
551
+ <Show when={props.view!.rows.length > 0}>
552
+ <Disclosure
553
+ id="candidates"
554
+ size="row"
555
+ open={props.expanded}
556
+ onToggle={props.onToggle}
557
+ hovered={props.hovered === "candidates"}
558
+ onHover={props.onHover}
559
+ aside={
560
+ <span style={{ color: theme.faint, "font-size": type.caption }}>
561
+ {props.view!.rows.length} considered
562
+ </span>
563
+ }
564
+ >
565
+ <span style={{ "font-size": type.caption, "font-weight": 500, color: theme.muted }}>Candidates</span>
566
+ </Disclosure>
567
+
568
+ <Show when={props.expanded}>
569
+ <div style={{ display: "flex", "flex-direction": "column", gap: "1px" }}>
570
+ <For each={props.view!.rows}>
571
+ {(row) => (
572
+ <div
573
+ style={{
574
+ display: "flex",
575
+ "flex-direction": "column",
576
+ gap: "2px",
577
+ padding: "6px 8px",
578
+ "border-radius": radius.sm,
579
+ background: row.verdict === "chosen" ? "rgba(8,145,178,0.08)" : "transparent",
580
+ }}
581
+ >
582
+ <div style={{ display: "flex", "justify-content": "space-between", gap: space.md, "align-items": "baseline" }}>
583
+ <span
584
+ style={{
585
+ "font-family": mono,
586
+ "font-size": type.caption,
587
+ color: row.verdict === "blocked" ? theme.faint : theme.text,
588
+ overflow: "hidden",
589
+ "text-overflow": "ellipsis",
590
+ "white-space": "nowrap",
591
+ }}
592
+ >
593
+ {row.key}
594
+ </span>
595
+ <span style={{ display: "flex", gap: space.md, "flex-shrink": 0 }}>
596
+ <span style={{ color: theme.muted, "font-size": type.caption, "font-variant-numeric": "tabular-nums" }}>
597
+ {formatUsd(row.effectiveCost)}
598
+ </span>
599
+ <span style={{ color: theme.faint, "font-size": type.caption, "font-variant-numeric": "tabular-nums" }}>
600
+ {formatPercent(row.successProbability * 100)}
601
+ </span>
602
+ </span>
603
+ </div>
604
+ <span style={{ color: theme.faint, "font-size": type.caption, "line-height": 1.45 }}>
605
+ {row.reason}
606
+ {row.adjustments.length > 0 ? ` · ${row.adjustments.join(" · ")}` : ""}
607
+ </span>
608
+ </div>
609
+ )}
610
+ </For>
611
+ </div>
612
+ </Show>
613
+ </Show>
614
+ </Show>
615
+ </Section>
616
+ )
617
+ }
618
+
619
+ /** Providers, not their catalogs. See the note in providerGroups(). */
620
+ function ProviderList(props: { status: RouterStatus | undefined }) {
621
+ const [open, setOpen] = createSignal(true)
622
+ const [notConfiguredOpen, setNotConfiguredOpen] = createSignal(false)
623
+ const [hovered, setHovered] = createSignal<string | undefined>(undefined)
624
+
625
+ const groups = () => {
626
+ const status = props.status
627
+ if (!status?.models) return []
628
+ const active = ((status.quota ?? []) as Array<{ providerID?: string; error?: string }>)
629
+ .filter((snapshot) => !snapshot.error)
630
+ .map((snapshot) => snapshot.providerID ?? "")
631
+ return providerGroups({
632
+ models: status.models,
633
+ quota: (status.quota ?? []) as Array<{ providerID: string; label?: string }>,
634
+ activeProviders: active,
635
+ })
636
+ }
637
+
638
+ const activeGroups = () => groups().filter((group) => group.state === "active")
639
+ const inactiveGroups = () => groups().filter((group) => group.state !== "active")
640
+
641
+ const Row = (rowProps: { group: ProviderGroupView }) => (
642
+ <div
643
+ style={{
644
+ display: "flex",
645
+ "justify-content": "space-between",
646
+ gap: space.md,
647
+ "align-items": "center",
648
+ padding: "7px 9px",
649
+ "border-radius": radius.sm,
650
+ border: `1px solid ${theme.border}`,
651
+ background: rowProps.group.state === "active" ? theme.surface : "transparent",
652
+ }}
653
+ >
654
+ <span style={{ display: "flex", "flex-direction": "column", gap: "2px", "min-width": 0 }}>
655
+ <span style={{ "font-size": type.body, "font-weight": 550, color: theme.text }}>{rowProps.group.label}</span>
656
+ <Show when={rowProps.group.note}>
657
+ <span style={{ color: theme.faint, "font-size": type.caption, "line-height": 1.4 }}>{rowProps.group.note}</span>
658
+ </Show>
659
+ </span>
660
+ <span style={{ display: "flex", gap: space.md, "align-items": "center", "flex-shrink": 0 }}>
661
+ <span style={{ color: theme.faint, "font-size": type.caption }}>
662
+ {rowProps.group.approvedCount}/{rowProps.group.totalCount}
663
+ </span>
664
+ <Chip tone={rowProps.group.state === "active" ? "brand" : "neutral"}>
665
+ {rowProps.group.state === "active" ? "active" : "off"}
666
+ </Chip>
667
+ </span>
668
+ </div>
669
+ )
670
+
671
+ return (
672
+ <Section
673
+ label="Providers"
674
+ aside={
675
+ <span style={{ color: theme.faint, "font-size": type.caption }}>
676
+ {activeGroups().length} active
677
+ {inactiveGroups().length > 0 ? ` · ${inactiveGroups().length} off` : ""}
678
+ </span>
679
+ }
680
+ >
681
+ <Disclosure
682
+ id="providers"
683
+ open={open()}
684
+ onToggle={() => setOpen((value) => !value)}
685
+ hovered={hovered() === "providers"}
686
+ onHover={setHovered}
687
+ aside={
688
+ <span style={{ color: theme.faint, "font-size": type.caption }}>
689
+ {open() ? "hide" : "show"}
690
+ </span>
691
+ }
692
+ >
693
+ <span style={{ "font-size": type.caption, "font-weight": 500, color: theme.muted }}>
694
+ {open() ? "Configured" : "Show configured"}
695
+ </span>
696
+ </Disclosure>
697
+
698
+ <Show when={open()}>
699
+ <For each={activeGroups()}>{(group) => <Row group={group} />}</For>
700
+
701
+ <Show when={inactiveGroups().length > 0}>
702
+ <Disclosure
703
+ id="not-configured"
704
+ open={notConfiguredOpen()}
705
+ onToggle={() => setNotConfiguredOpen((value) => !value)}
706
+ hovered={hovered() === "not-configured"}
707
+ onHover={setHovered}
708
+ aside={<span style={{ color: theme.faint, "font-size": type.caption }}>{inactiveGroups().length}</span>}
709
+ >
710
+ <span style={{ "font-size": type.caption, "font-weight": 500, color: theme.muted }}>Not configured</span>
711
+ </Disclosure>
712
+ <Show when={notConfiguredOpen()}>
713
+ <For each={inactiveGroups()}>{(group) => <Row group={group} />}</For>
714
+ </Show>
715
+ </Show>
716
+ </Show>
717
+ </Section>
718
+ )
719
+ }
720
+
721
+ /** Allowances, one card per provider, with every window it reports. */
722
+ function Allowances(props: { providers: ReadonlyArray<ProviderRow> }) {
723
+ return (
724
+ <Section
725
+ label="Allowances"
726
+ aside={
727
+ <span style={{ color: theme.faint, "font-size": type.caption }}>
728
+ {props.providers.length} provider{props.providers.length === 1 ? "" : "s"}
729
+ </span>
730
+ }
731
+ >
732
+ <Show
733
+ when={props.providers.length > 0}
734
+ fallback={<Empty title="No quota data" body="No provider has reported an allowance yet." />}
735
+ >
736
+ <For each={props.providers}>
737
+ {(provider) => (
738
+ <Card>
739
+ <div style={{ display: "flex", "justify-content": "space-between", gap: space.md, "align-items": "center" }}>
740
+ <span style={{ "font-size": type.body, "font-weight": 550, color: provider.error ? theme.bad : theme.text }}>
741
+ {provider.label}
742
+ </span>
743
+ <span style={{ display: "flex", gap: space.xs, "align-items": "center" }}>
744
+ <Show when={provider.stale}>
745
+ <Chip tone="warn">stale</Chip>
746
+ </Show>
747
+ <Show when={provider.exhausted}>
748
+ <Chip tone="bad">{provider.exhaustedWindow} spent</Chip>
749
+ </Show>
750
+ <Show when={!provider.exhausted && !provider.stale}>
751
+ <Chip tone={provider.source === "provider" ? "good" : "neutral"}>
752
+ {provider.source === "provider" ? "verified" : "estimated"}
753
+ </Chip>
754
+ </Show>
755
+ </span>
756
+ </div>
757
+
758
+ <div style={{ display: "flex", "flex-direction": "column", gap: "4px" }}>
759
+ <For each={provider.windows}>
760
+ {(window) => (
761
+ <div style={{ display: "flex", "align-items": "center", gap: space.md }}>
762
+ <span
763
+ style={{
764
+ color: theme.faint,
765
+ "font-size": type.caption,
766
+ "min-width": "42px",
767
+ "font-variant-numeric": "tabular-nums",
768
+ }}
769
+ >
770
+ {window.label}
771
+ </span>
772
+ <Show when={window.reported} fallback={<span style={{ flex: 1 }} />}>
773
+ <Meter usedPct={window.usedPct} exhausted={window.exceeded} />
774
+ </Show>
775
+ <span
776
+ style={{
777
+ color: window.exceeded ? theme.bad : window.reported ? theme.muted : theme.faint,
778
+ "font-size": type.caption,
779
+ "min-width": "82px",
780
+ "text-align": "right",
781
+ "font-variant-numeric": "tabular-nums",
782
+ "flex-shrink": 0,
783
+ }}
784
+ >
785
+ {window.reported ? formatWindowUsed(window) : "—"}
786
+ {window.resetLabel ? ` · ${window.resetLabel}` : ""}
787
+ </span>
788
+ </div>
789
+ )}
790
+ </For>
791
+ </div>
792
+
793
+ <Show when={provider.pools.length > 0}>
794
+ <div style={{ display: "flex", "flex-direction": "column", gap: "2px", "padding-top": space.xs }}>
795
+ <For each={provider.pools}>
796
+ {(pool) => (
797
+ <div style={{ display: "flex", "justify-content": "space-between", gap: space.md }}>
798
+ <span style={{ color: theme.faint, "font-size": type.caption }}>{pool.label}</span>
799
+ <span style={{ color: theme.muted, "font-size": type.caption, "font-variant-numeric": "tabular-nums" }}>
800
+ {formatUsd(pool.remainingUsd)} of {formatUsd(pool.monthlyUsd)}
801
+ </span>
802
+ </div>
803
+ )}
804
+ </For>
805
+ </div>
806
+ </Show>
807
+
808
+ <Show when={provider.caveat}>
809
+ <span style={{ color: theme.warn, "font-size": type.caption, "line-height": 1.45 }}>{provider.caveat}</span>
810
+ </Show>
811
+ <Show when={provider.error}>
812
+ <span style={{ color: theme.bad, "font-size": type.caption }}>{provider.error}</span>
813
+ </Show>
814
+ </Card>
815
+ )}
816
+ </For>
817
+ </Show>
818
+ </Section>
819
+ )
820
+ }
821
+
822
+ /**
823
+ * Paste any console page, once.
824
+ *
825
+ * Every console page is accepted and the type is detected, because making the
826
+ * user choose the right page first is a step that exists only because the
827
+ * software was not paying attention.
828
+ */
829
+ function AccountPaste(props: { onChanged: () => void }) {
830
+ const ctx = usePlugin<typeof definition>()
831
+ const [page, setPage] = createSignal("")
832
+ const [preview, setPreview] = createSignal<any | undefined>(undefined)
833
+ const [status, setStatus] = createSignal<{ ok: boolean; text: string } | undefined>(undefined)
834
+ const [busy, setBusy] = createSignal(false)
835
+ const [focused, setFocused] = createSignal(false)
836
+ const [hovered, setHovered] = createSignal(false)
837
+
838
+ const reset = () => {
839
+ setPage("")
840
+ setPreview(undefined)
841
+ }
842
+
843
+ const read = async (confirm: boolean) => {
844
+ if (!page().trim() || busy()) return
845
+ setBusy(true)
846
+ try {
847
+ const result: any = await ctx.client.rpc(SmartRouterRpc).recordPlanPage({ page: page(), confirm })
848
+ setStatus({ ok: result?.ok !== false, text: result?.message ?? "" })
849
+ if (result?.ok && !confirm) setPreview(result.preview)
850
+ else if (result?.ok && confirm) {
851
+ reset()
852
+ props.onChanged()
853
+ }
854
+ } catch (cause) {
855
+ setStatus({ ok: false, text: cause instanceof Error ? cause.message : String(cause) })
856
+ } finally {
857
+ setBusy(false)
858
+ }
859
+ }
860
+
861
+ const button = (label: string, enabled: boolean, onClick: () => void) => (
862
+ <button
863
+ type="button"
864
+ onClick={onClick}
865
+ disabled={!enabled}
866
+ style={{
867
+ padding: "5px 11px",
868
+ "border-radius": radius.sm,
869
+ border: `1px solid ${enabled ? theme.borderStrong : theme.border}`,
870
+ background: enabled ? theme.raised : "transparent",
871
+ color: enabled ? theme.text : theme.faint,
872
+ "font-size": type.caption,
873
+ font: enabled ? "inherit" : `${type.caption} inherit`,
874
+ "font-weight": 500,
875
+ cursor: enabled ? "pointer" : "default",
876
+ }}
877
+ >
878
+ {label}
879
+ </button>
880
+ )
881
+
882
+ return (
883
+ <Section label="Account reading">
884
+ <span style={{ color: theme.faint, "font-size": type.caption, "line-height": 1.5 }}>
885
+ OpenDesign exposes no usage API, so the router can only count what it routed itself. Paste your{" "}
886
+ <strong style={{ color: theme.text }}>Plan &amp; balance</strong> page to replace the estimate with your
887
+ real position — it carries the percentage, the window it belongs to, and when it resets.
888
+ </span>
889
+
890
+ <textarea
891
+ value={page()}
892
+ onInput={(event) => {
893
+ setPage((event.currentTarget as HTMLTextAreaElement).value)
894
+ setPreview(undefined)
895
+ }}
896
+ onFocus={() => setFocused(true)}
897
+ onBlur={() => setFocused(false)}
898
+ placeholder="Paste your Plan & balance page — navigation and all is fine."
899
+ aria-label="Paste your OpenDesign Plan and balance page"
900
+ rows={3}
901
+ style={{
902
+ width: "100%",
903
+ resize: "vertical",
904
+ padding: "8px 9px",
905
+ "border-radius": radius.sm,
906
+ border: `1px solid ${focused() ? theme.brandTo : theme.border}`,
907
+ background: "transparent",
908
+ color: theme.text,
909
+ "font-size": type.caption,
910
+ "font-family": "inherit",
911
+ "line-height": 1.5,
912
+ "box-sizing": "border-box",
913
+ outline: "none",
914
+ transition: "border-color 120ms ease",
915
+ }}
916
+ />
917
+
918
+ <Show when={preview()}>
919
+ <Card>
920
+ <span style={{ color: theme.text, "font-size": type.caption, "line-height": 1.5 }}>{preview().summary}</span>
921
+ <Show when={preview().missing?.length > 0}>
922
+ <span style={{ color: theme.warn, "font-size": type.caption }}>
923
+ Not on the page: {preview().missing.join(", ")}
924
+ </span>
925
+ </Show>
926
+ <Show when={preview().reconciliation}>
927
+ <span
928
+ style={{
929
+ color: preview().reconciliation.differ > 0 ? theme.warn : theme.good,
930
+ "font-size": type.caption,
931
+ "line-height": 1.45,
932
+ }}
933
+ >
934
+ {preview().reconciliation.summary}
935
+ </span>
936
+ <For each={preview().reconciliation.rows.filter((row: any) => row.verdict === "differs")}>
937
+ {(row: any) => (
938
+ <span style={{ color: theme.faint, "font-size": type.caption }}>
939
+ {row.modelName}: charged {row.chargedUsd.toFixed(2)}, implied {row.impliedMultiplier?.toFixed(2)}× vs
940
+ catalog {row.catalogMultiplier?.toFixed(2)}×
941
+ </span>
942
+ )}
943
+ </For>
944
+ </Show>
945
+ </Card>
946
+ </Show>
947
+
948
+ <div
949
+ style={{ display: "flex", gap: space.sm }}
950
+ onMouseEnter={() => setHovered(true)}
951
+ onMouseLeave={() => setHovered(false)}
952
+ >
953
+ <Show
954
+ when={preview()}
955
+ fallback={button(busy() ? "Reading…" : "Read page", Boolean(page().trim()) && !busy(), () => void read(false))}
956
+ >
957
+ <Show when={preview().kind === "allowance"}>
958
+ {button(busy() ? "Saving…" : "Use this", !busy(), () => void read(true))}
959
+ </Show>
960
+ {button(preview().kind === "usage" ? "Done" : "Cancel", !busy(), reset)}
961
+ </Show>
962
+ </div>
963
+
964
+ <Show when={status()}>
965
+ <span
966
+ style={{
967
+ color: status()!.ok ? theme.good : theme.bad,
968
+ "font-size": type.caption,
969
+ "line-height": 1.45,
970
+ }}
971
+ >
972
+ {status()!.text}
973
+ </span>
974
+ </Show>
975
+ </Section>
976
+ )
977
+ }
978
+
979
+ // ---------------------------------------------------------------------------
980
+ // The panel
981
+ // ---------------------------------------------------------------------------
982
+
983
+
984
+ /**
985
+ * First run.
986
+ *
987
+ * The rule: show evidence, ask for nothing, offer one way forward.
988
+ *
989
+ * A new user has just installed a router they do not yet trust. A questionnaire
990
+ * asks them to do the work of finding out what is on the machine; this does that
991
+ * and reports back. Most of the time they will read it and press one button,
992
+ * because detection already did the thinking.
993
+ *
994
+ * The screen is a sequence of facts about *their* machine — what was found, what
995
+ * was imported, what it will cost them — and only then a single action. That
996
+ * ordering is the whole design: trust is built by being right about small things
997
+ * before asking for a decision.
998
+ */
999
+
1000
+ /**
1001
+ * Open the console, as a button that admits it is working.
1002
+ *
1003
+ * Two problems with the link it replaces.
1004
+ *
1005
+ * It did not look clickable. `↗` on tinted text at 10px reads as a footnote, not
1006
+ * an action, so it needed hover, underline and a border to say so.
1007
+ *
1008
+ * It also hung. An `<a target="_blank">` inside a panel hands the URL to the host
1009
+ * and waits for it to decide, which can take seconds with no feedback — the
1010
+ * exact conditions under which a user concludes the app has frozen. Opening
1011
+ * through `ctx.system.openExternal` is direct, and because this is a button it
1012
+ * can report that something happened.
1013
+ *
1014
+ * The fallback matters more than the happy path: if the system call throws, the
1015
+ * URL is shown so the page can still be reached. An action that fails silently is
1016
+ * worse than one that never existed.
1017
+ */
1018
+ function OpenConsoleButton() {
1019
+ const ctx = usePlugin<typeof definition>()
1020
+ const [state, setState] = createSignal<"idle" | "opening" | "failed">("idle")
1021
+ const [hovered, setHovered] = createSignal(false)
1022
+ const url = "https://open-design.ai/cloud/dashboard"
1023
+
1024
+ const open = () => {
1025
+ if (state() === "opening") return
1026
+ setState("opening")
1027
+ try {
1028
+ ctx.system.openExternal(url)
1029
+ // There is no completion signal from the OS, so the spinner is cleared on
1030
+ // a timer rather than pretending to know when the browser appeared.
1031
+ setTimeout(() => setState("idle"), 900)
1032
+ } catch {
1033
+ setState("failed")
1034
+ }
1035
+ }
1036
+
1037
+ return (
1038
+ <div style={{ display: "flex", "flex-direction": "column", gap: space.xs }}>
1039
+ <button
1040
+ type="button"
1041
+ onClick={open}
1042
+ onMouseEnter={() => setHovered(true)}
1043
+ onMouseLeave={() => setHovered(false)}
1044
+ disabled={state() === "opening"}
1045
+ aria-busy={state() === "opening"}
1046
+ title={url}
1047
+ style={{
1048
+ display: "inline-flex",
1049
+ "align-items": "center",
1050
+ gap: "6px",
1051
+ "align-self": "flex-start",
1052
+ padding: "6px 10px",
1053
+ "border-radius": radius.sm,
1054
+ border: `1px solid ${hovered() ? theme.brandTo : theme.borderStrong}`,
1055
+ background: hovered() ? "rgba(8,145,178,0.12)" : "transparent",
1056
+ color: theme.brandTo,
1057
+ "font-size": type.caption,
1058
+ "font-weight": 550,
1059
+ "font-family": "inherit",
1060
+ cursor: state() === "opening" ? "default" : "pointer",
1061
+ "text-decoration": hovered() ? "underline" : "none",
1062
+ "text-underline-offset": "2px",
1063
+ transition: "background 120ms ease, border-color 120ms ease",
1064
+ }}
1065
+ >
1066
+ <span aria-hidden="true" style={{ "font-size": "11px" }}>
1067
+ {state() === "opening" ? "◌" : "↗"}
1068
+ </span>
1069
+ {state() === "opening" ? "Opening…" : "Open Plan & balance"}
1070
+ </button>
1071
+
1072
+ <Show when={state() === "opening"}>
1073
+ <span style={{ color: theme.faint, "font-size": type.caption }}>
1074
+ Handing off to your browser — the page opens in a new tab.
1075
+ </span>
1076
+ </Show>
1077
+
1078
+ <Show when={state() === "failed"}>
1079
+ <span style={{ color: theme.warn, "font-size": type.caption, "line-height": 1.45 }}>
1080
+ Could not open the browser automatically. Open this address yourself:{" "}
1081
+ <span style={{ "font-family": mono, color: theme.text }}>{url}</span>
1082
+ </span>
1083
+ </Show>
1084
+ </div>
1085
+ )
1086
+ }
1087
+
1088
+ function Onboarding(props: { state: any; onDone: () => void }) {
1089
+ const ctx = usePlugin<typeof definition>()
1090
+ const [busy, setBusy] = createSignal(false)
1091
+ const [hovered, setHovered] = createSignal<string | undefined>(undefined)
1092
+ const [reading, setReading] = createSignal("")
1093
+ const [readingDone, setReadingDone] = createSignal(false)
1094
+ const [readingBusy, setReadingBusy] = createSignal(false)
1095
+ const [readingFocused, setReadingFocused] = createSignal(false)
1096
+ const [readingStatus, setReadingStatus] = createSignal<{ ok: boolean; text: string } | undefined>(undefined)
1097
+
1098
+ /** Is OpenDesign actually configured on this machine? */
1099
+ const hasOpenDesign = () =>
1100
+ (props.state?.providers ?? []).some((provider: any) => provider.providerID === "opendesign" && provider.found)
1101
+
1102
+ /**
1103
+ * Record the pasted page, in place.
1104
+ *
1105
+ * Deliberately not a separate screen: the user is told *why* this matters and
1106
+ * asked for it in the same breath. Moving them somewhere else to do it breaks
1107
+ * the explanation away from the action.
1108
+ */
1109
+ const recordReading = async (confirm: boolean) => {
1110
+ if (!reading().trim() || readingBusy()) return
1111
+ setReadingBusy(true)
1112
+ try {
1113
+ const result: any = await ctx.client.rpc(SmartRouterRpc).recordPlanPage({
1114
+ page: reading(),
1115
+ confirm: true,
1116
+ })
1117
+ setReadingStatus({ ok: result?.ok !== false, text: result?.message ?? "" })
1118
+ if (result?.ok !== false) setReadingDone(true)
1119
+ } catch (cause) {
1120
+ setReadingStatus({ ok: false, text: cause instanceof Error ? cause.message : String(cause) })
1121
+ } finally {
1122
+ setReadingBusy(false)
1123
+ }
1124
+ }
1125
+
1126
+ const finish = async () => {
1127
+ if (busy()) return
1128
+ setBusy(true)
1129
+ try {
1130
+ await ctx.client.rpc(SmartRouterRpc).completeOnboarding({})
1131
+ props.onDone()
1132
+ } finally {
1133
+ setBusy(false)
1134
+ }
1135
+ }
1136
+
1137
+ const found = () => (props.state?.providers ?? []).filter((provider: any) => provider.found)
1138
+ const missing = () => (props.state?.providers ?? []).filter((provider: any) => !provider.found)
1139
+
1140
+ return (
1141
+ <div
1142
+ style={{
1143
+ display: "flex",
1144
+ "flex-direction": "column",
1145
+ gap: space.lg,
1146
+ padding: "18px 16px",
1147
+ color: theme.text,
1148
+ "font-size": type.body,
1149
+ height: "100%",
1150
+ "overflow-y": "auto",
1151
+ "box-sizing": "border-box",
1152
+ "font-family": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif",
1153
+ "-webkit-font-smoothing": "antialiased",
1154
+ }}
1155
+ >
1156
+ {/* Hero. The first screen sets the register: this is a product with a
1157
+ point of view, not a preferences dialog. */}
1158
+ <div
1159
+ style={{
1160
+ display: "flex",
1161
+ "flex-direction": "column",
1162
+ gap: space.sm,
1163
+ "align-items": "center",
1164
+ "text-align": "center",
1165
+ padding: "10px 0 4px",
1166
+ }}
1167
+ >
1168
+ <BrandMark size={44} />
1169
+ <span
1170
+ style={{
1171
+ "font-size": type.display,
1172
+ "font-weight": 700,
1173
+ "letter-spacing": "-0.4px",
1174
+ "line-height": 1.25,
1175
+ }}
1176
+ >
1177
+ Your router is ready
1178
+ </span>
1179
+ <span style={{ color: theme.muted, "font-size": type.caption, "line-height": 1.5, "max-width": "260px" }}>
1180
+ It found what is already on this machine and configured itself. Here is exactly what it did.
1181
+ </span>
1182
+ </div>
1183
+
1184
+ <Section label="Found on this machine" first>
1185
+ <Show
1186
+ when={found().length > 0}
1187
+ fallback={
1188
+ <Empty
1189
+ title="No provider credentials found"
1190
+ body="Add a key for OpenCode Go, Command Code or OpenDesign in OpenCode, then reopen this panel."
1191
+ />
1192
+ }
1193
+ >
1194
+ <For each={found()}>
1195
+ {(provider: any) => (
1196
+ <div
1197
+ style={{
1198
+ display: "flex",
1199
+ "justify-content": "space-between",
1200
+ gap: space.md,
1201
+ "align-items": "center",
1202
+ padding: "8px 9px",
1203
+ "border-radius": radius.sm,
1204
+ border: `1px solid ${theme.border}`,
1205
+ background: theme.surface,
1206
+ }}
1207
+ >
1208
+ <span style={{ display: "flex", "flex-direction": "column", gap: "1px", "min-width": 0 }}>
1209
+ <span style={{ "font-size": type.body, "font-weight": 550 }}>{provider.label}</span>
1210
+ <Show when={provider.source}>
1211
+ <span style={{ color: theme.faint, "font-size": type.caption }}>{provider.source}</span>
1212
+ </Show>
1213
+ </span>
1214
+ <Chip tone={provider.enabled ? "good" : "neutral"}>
1215
+ {provider.enabled ? "enabled" : "off"}
1216
+ </Chip>
1217
+ </div>
1218
+ )}
1219
+ </For>
1220
+ </Show>
1221
+ </Section>
1222
+
1223
+ {/* What it learned. This is the part that makes the product feel like it
1224
+ did work on the user's behalf rather than asking them to do it. */}
1225
+ <Section
1226
+ label="Learned from your history"
1227
+ aside={
1228
+ <span style={{ color: theme.faint, "font-size": type.caption }}>
1229
+ {props.state?.hasHistory ? "imported" : "none yet"}
1230
+ </span>
1231
+ }
1232
+ >
1233
+ <Show
1234
+ when={props.state?.hasHistory}
1235
+ fallback={
1236
+ <Empty
1237
+ title="Nothing to import yet"
1238
+ body="Once you have used OpenCode, the router reads that history so day one is not a guess."
1239
+ />
1240
+ }
1241
+ >
1242
+ <Card accent>
1243
+ <span style={{ "font-size": type.body }}>
1244
+ <strong style={{ "font-weight": 650 }}>{props.state.historyImported}</strong>{" "}
1245
+ <span style={{ color: theme.muted }}>
1246
+ request{props.state.historyImported === 1 ? "" : "s"} from OpenCode's own database
1247
+ </span>
1248
+ </span>
1249
+ <span style={{ color: theme.faint, "font-size": type.caption, "line-height": 1.5 }}>
1250
+ Each one carries its own timestamp and the model that served it, so the seven-day window starts
1251
+ accurate instead of empty.
1252
+ </span>
1253
+ </Card>
1254
+ </Show>
1255
+ </Section>
1256
+
1257
+ <Section label="What it will do">
1258
+ <div style={{ display: "flex", "flex-direction": "column", gap: "5px" }}>
1259
+ <For
1260
+ each={[
1261
+ "Pick the cheapest model that can actually do the job, and say why.",
1262
+ "Stop before an allowance runs out, not after a request fails.",
1263
+ "Count what you spend here, and never guess silently.",
1264
+ ]}
1265
+ >
1266
+ {(line) => (
1267
+ <div style={{ display: "flex", gap: space.sm, "align-items": "baseline" }}>
1268
+ <span style={{ color: theme.brandTo, "font-size": type.caption }}>›</span>
1269
+ <span style={{ color: theme.muted, "font-size": type.caption, "line-height": 1.5 }}>{line}</span>
1270
+ </div>
1271
+ )}
1272
+ </For>
1273
+ </div>
1274
+ </Section>
1275
+
1276
+ {/* The one thing detection cannot know.
1277
+ OpenDesign publishes no usage API, so the router cannot see the account
1278
+ — only its own traffic. Rather than leave that as a footnote, the step
1279
+ is asked for here, once, with the reason stated plainly. Getting this
1280
+ wrong is what made a 42%-used account look 5% used. */}
1281
+ <Show when={hasOpenDesign()}>
1282
+ <Section
1283
+ label="One thing it cannot see"
1284
+ aside={<Chip tone="warn">needed for accuracy</Chip>}
1285
+ >
1286
+ <Card>
1287
+ <span style={{ "font-size": type.body, "font-weight": 550 }}>
1288
+ OpenDesign does not expose usage to an API key
1289
+ </span>
1290
+ <span style={{ color: theme.muted, "font-size": type.caption, "line-height": 1.55 }}>
1291
+ So the router can only count what <em>it</em> has routed. Anything you spent before installing
1292
+ it — in the desktop app, or with another tool — is invisible, which makes its estimate far too
1293
+ optimistic.
1294
+ </span>
1295
+ <span style={{ color: theme.muted, "font-size": type.caption, "line-height": 1.55 }}>
1296
+ Paste your <strong style={{ color: theme.text }}>Plan &amp; balance</strong> page once and it
1297
+ knows your real position. This is the only page worth pasting: it carries the percentage, which
1298
+ window it belongs to, and when it resets.
1299
+ </span>
1300
+
1301
+ <Show when={!readingDone()}>
1302
+ <OpenConsoleButton />
1303
+
1304
+ <textarea
1305
+ value={reading()}
1306
+ onInput={(event) => setReading((event.currentTarget as HTMLTextAreaElement).value)}
1307
+ placeholder="Paste the whole page here — the navigation and all is fine."
1308
+ aria-label="Paste your OpenDesign Plan and balance page"
1309
+ rows={3}
1310
+ style={{
1311
+ width: "100%",
1312
+ resize: "vertical",
1313
+ padding: "8px 9px",
1314
+ "border-radius": radius.sm,
1315
+ border: `1px solid ${readingFocused() ? theme.brandTo : theme.border}`,
1316
+ background: "transparent",
1317
+ color: theme.text,
1318
+ "font-size": type.caption,
1319
+ "font-family": "inherit",
1320
+ "line-height": 1.5,
1321
+ "box-sizing": "border-box",
1322
+ outline: "none",
1323
+ }}
1324
+ />
1325
+ <div style={{ display: "flex", gap: space.sm, "align-items": "center" }}>
1326
+ <button
1327
+ type="button"
1328
+ onClick={() => void recordReading(false)}
1329
+ disabled={!reading().trim() || readingBusy()}
1330
+ style={{
1331
+ padding: "5px 11px",
1332
+ "border-radius": radius.sm,
1333
+ border: `1px solid ${theme.borderStrong}`,
1334
+ background: reading().trim() ? theme.raised : "transparent",
1335
+ color: reading().trim() ? theme.text : theme.faint,
1336
+ "font-size": type.caption,
1337
+ "font-family": "inherit",
1338
+ cursor: reading().trim() ? "pointer" : "default",
1339
+ }}
1340
+ >
1341
+ {readingBusy() ? "Reading…" : "Check it"}
1342
+ </button>
1343
+ <span style={{ color: theme.faint, "font-size": type.caption }}>
1344
+ Optional — you can skip and paste later.
1345
+ </span>
1346
+ </div>
1347
+ <Show when={readingStatus()}>
1348
+ {(current) => (
1349
+ <span
1350
+ style={{
1351
+ color: current().ok ? theme.good : theme.bad,
1352
+ "font-size": type.caption,
1353
+ "line-height": 1.45,
1354
+ }}
1355
+ >
1356
+ {current().text}
1357
+ </span>
1358
+ )}
1359
+ </Show>
1360
+ </Show>
1361
+
1362
+ <Show when={readingDone()}>
1363
+ <span style={{ color: theme.good, "font-size": type.caption }}>
1364
+ Recorded. Your allowance now starts from what the account actually shows.
1365
+ </span>
1366
+ </Show>
1367
+ </Card>
1368
+ </Section>
1369
+ </Show>
1370
+
1371
+ <Show when={missing().length > 0 && !hasOpenDesign()}>
1372
+ <Section label="Not needed yet">
1373
+ <span style={{ color: theme.faint, "font-size": type.caption, "line-height": 1.5 }}>
1374
+ {missing().length} provider{missing().length === 1 ? "" : "s"} had no credential and stayed off.
1375
+ They are listed under Providers whenever you want them.
1376
+ </span>
1377
+ </Section>
1378
+ </Show>
1379
+
1380
+ {/* One action. A first-run screen with a maze of choices is a settings
1381
+ page pretending to be a welcome. */}
1382
+ <button
1383
+ type="button"
1384
+ onClick={() => void finish()}
1385
+ onMouseEnter={() => setHovered("done")}
1386
+ onMouseLeave={() => setHovered(undefined)}
1387
+ disabled={busy()}
1388
+ style={{
1389
+ "margin-top": "auto",
1390
+ padding: "10px 12px",
1391
+ "border-radius": radius.md,
1392
+ border: "none",
1393
+ background: brandGradient,
1394
+ color: "#fff",
1395
+ "font-size": type.body,
1396
+ "font-weight": 600,
1397
+ font: "inherit",
1398
+ cursor: busy() ? "default" : "pointer",
1399
+ opacity: busy() ? 0.7 : hovered() === "done" ? 0.92 : 1,
1400
+ transition: "opacity 120ms ease",
1401
+ }}
1402
+ >
1403
+ {busy() ? "Starting…" : "Start routing"}
1404
+ </button>
1405
+ </div>
1406
+ )
1407
+ }
1408
+
1409
+ function SmartRouterPanel(props: { sessionID: string | undefined }) {
1410
+ const ctx = usePlugin<typeof definition>()
1411
+ const [status, setStatus] = createSignal<RouterStatus | undefined>(undefined)
1412
+ const [error, setError] = createSignal<string | undefined>(undefined)
1413
+ const [at, setAt] = createSignal<number | undefined>(undefined)
1414
+ const [expandedCandidates, setExpandedCandidates] = createSignal(false)
1415
+ const [hovered, setHovered] = createSignal<string | undefined>(undefined)
1416
+ const [onboarding, setOnboarding] = createSignal<any | undefined>(undefined)
1417
+
1418
+ const load = async () => {
1419
+ const sessionID = props.sessionID
1420
+ if (!sessionID) return
1421
+ try {
1422
+ const result = (await ctx.client.rpc(SmartRouterRpc).status({ sessionID })) as RouterStatus
1423
+ setStatus(result)
1424
+ setAt(Date.now())
1425
+ setError(undefined)
1426
+ } catch (cause) {
1427
+ setError(cause instanceof Error ? cause.message : String(cause))
1428
+ }
1429
+ }
1430
+
1431
+ onMount(() => {
1432
+ void load()
1433
+ /**
1434
+ * Ask about first run, but never wait for the answer.
1435
+ *
1436
+ * This used to be a gate: nothing rendered until the call settled. When the
1437
+ * method did not settle — an older server build that does not know it — the
1438
+ * panel rendered as an empty box forever. An optional check must never be
1439
+ * able to blank the whole surface.
1440
+ *
1441
+ * So the dashboard paints immediately and onboarding replaces it if it
1442
+ * arrives and says this is a fresh install. On a fresh install that swap
1443
+ * happens within a frame or two; everywhere else the answer is "no" and
1444
+ * nothing changes.
1445
+ */
1446
+ void (async () => {
1447
+ try {
1448
+ const state: any = await Promise.race([
1449
+ ctx.client.rpc(SmartRouterRpc).onboarding({}),
1450
+ // A stuck call must not hold the panel open on a blank state.
1451
+ new Promise((resolve) => setTimeout(() => resolve(undefined), 1_500)),
1452
+ ])
1453
+ if (state?.firstRun) setOnboarding(state)
1454
+ } catch {
1455
+ // A router that cannot describe itself should still show the panel.
1456
+ }
1457
+ })()
1458
+ const timer = setInterval(() => void load(), 3_000)
1459
+ onCleanup(() => clearInterval(timer))
1460
+ })
1461
+
1462
+ const view = (): DecisionView | undefined => summariseDecision(status()?.lastDecision as never)
1463
+ const providers = (): ProviderRow[] =>
1464
+ ((status()?.quota ?? []) as never[]).map((snapshot) => toProviderRow(snapshot))
1465
+ const needsReading = () => providers().some((provider) => provider.source === "estimate")
1466
+
1467
+ return (
1468
+ <Show
1469
+ when={!onboarding()}
1470
+ fallback={
1471
+ <Onboarding
1472
+ state={onboarding()}
1473
+ onDone={() => {
1474
+ setOnboarding(undefined)
1475
+ void load()
1476
+ }}
1477
+ />
1478
+ }
1479
+ >
1480
+ <div
1481
+ style={{
1482
+ display: "flex",
1483
+ "flex-direction": "column",
1484
+ gap: space.lg,
1485
+ padding: "14px 14px 18px",
1486
+ color: theme.text,
1487
+ "font-size": type.body,
1488
+ height: "100%",
1489
+ "overflow-y": "auto",
1490
+ "box-sizing": "border-box",
1491
+ "font-family": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif",
1492
+ "-webkit-font-smoothing": "antialiased",
1493
+ }}
1494
+ >
1495
+ <div style={{ display: "flex", "flex-direction": "column", gap: "3px" }}>
1496
+ <BrandHeader runtime={status()?.runtime ?? "connecting"} mode={status()?.mode ?? "—"} />
1497
+ <div style={{ display: "flex", "justify-content": "space-between", gap: space.md, "align-items": "baseline" }}>
1498
+ <span
1499
+ style={{
1500
+ "font-family": mono,
1501
+ "font-size": type.caption,
1502
+ color: status()?.current ? theme.muted : theme.faint,
1503
+ overflow: "hidden",
1504
+ "text-overflow": "ellipsis",
1505
+ "white-space": "nowrap",
1506
+ }}
1507
+ >
1508
+ {status()?.current ? `${status()!.current!.providerID}/${status()!.current!.id}` : "no model yet"}
1509
+ </span>
1510
+ <span style={{ color: theme.faint, "font-size": type.caption, "flex-shrink": 0 }}>
1511
+ {at() ? relative(at()!) : "connecting…"}
1512
+ </span>
1513
+ </div>
1514
+ </div>
1515
+
1516
+ <Show when={error()}>
1517
+ <Card>
1518
+ <span style={{ color: theme.bad, "font-size": type.caption }}>Router unreachable: {error()}</span>
1519
+ <span style={{ color: theme.faint, "font-size": type.caption }}>
1520
+ The plugin may have failed to load. Check Settings → Extensions → Plugins.
1521
+ </span>
1522
+ </Card>
1523
+ </Show>
1524
+
1525
+ <AutoSwitch
1526
+ sessionID={props.sessionID}
1527
+ auto={(status()?.runtime ?? "automatic") === "automatic"}
1528
+ onChanged={() => void load()}
1529
+ />
1530
+
1531
+ <DecisionInspector
1532
+ view={view()}
1533
+ expanded={expandedCandidates()}
1534
+ onToggle={() => setExpandedCandidates((value) => !value)}
1535
+ hovered={hovered()}
1536
+ onHover={setHovered}
1537
+ />
1538
+
1539
+ <Allowances providers={providers()} />
1540
+
1541
+ <Show when={needsReading()}>
1542
+ <AccountPaste onChanged={() => void load()} />
1543
+ </Show>
1544
+
1545
+ <ProviderList status={status()} />
1546
+ </div>
1547
+ </Show>
1548
+ )
1549
+ }
1550
+
1551
+ const tab: PanelTab = {
1552
+ id: "router",
1553
+ get title() {
1554
+ return "Router"
1555
+ },
1556
+ closable: "hover",
1557
+ }
1558
+
1559
+ const definition = Plugin.define({
1560
+ i18n: {
1561
+ en: {
1562
+ "command.open": "Open Smart Router",
1563
+ "panel.title": "Router",
1564
+ },
1565
+ },
1566
+ })
1567
+
1568
+ /**
1569
+ * The tab id and the id the command toggles must be the same string:
1570
+ * `ctx.layout.toggle()` keys on `${plugin}:${tab id}` and `list()` receives the
1571
+ * stored *tab* ids. They disagreed once and the side region opened empty.
1572
+ */
1573
+ export default Plugin.define({
1574
+ ...definition,
1575
+ setup(ctx) {
1576
+ ctx.add(Command, () => ({
1577
+ id: "panel",
1578
+ title: ctx.t("command.open"),
1579
+ group: "Smart Router",
1580
+ bind: "mod+shift+r",
1581
+ slash: { name: "router-gui" },
1582
+ run: () => {
1583
+ const session = ctx.sessions.current()
1584
+ if (session) ctx.layout.toggle(`${ctx.id}:router`, session)
1585
+ },
1586
+ }))
1587
+
1588
+ ctx.add(Panel, {
1589
+ id: "router",
1590
+ region: "side",
1591
+ list: (input) => (input.open.includes("router") ? [tab] : []),
1592
+ render: (props: PanelProps) => <SmartRouterPanel sessionID={props.session.id} />,
1593
+ })
1594
+
1595
+ /**
1596
+ * Open the panel by itself, once, when a session first becomes available.
1597
+ *
1598
+ * Two constraints shape this.
1599
+ *
1600
+ * A side panel belongs to a session — `sessions.current()` is undefined on
1601
+ * Home and on a draft — so there is nothing to attach to until the user opens
1602
+ * a conversation. That is why this cannot fire at launch.
1603
+ *
1604
+ * And it must not fight the user. Closing the panel is a decision, so this
1605
+ * runs at most once per window and only when the tab is genuinely closed. The
1606
+ * `layout.ready()` guard matters: before the layout loads, every read reports
1607
+ * an empty layout, so acting earlier would reopen a tab somebody had
1608
+ * deliberately closed in a previous session.
1609
+ */
1610
+ const panelKey = `${ctx.id}:router`
1611
+ /**
1612
+ * Open once per session, not once per window.
1613
+ *
1614
+ * The first version wrote a single `autoOpened` flag and gave up after thirty
1615
+ * seconds. Both were wrong for the ordinary sequence — launch the app, look
1616
+ * around on Home, then open a session — because no session exists for the
1617
+ * first thirty seconds, and the flag could never be reset for the next one.
1618
+ *
1619
+ * Keying on the session means each conversation gets the panel once, and
1620
+ * closing it stays closed for that conversation.
1621
+ */
1622
+ const openedFor = new Set<string>()
1623
+ /**
1624
+ * Every step is wrapped: this runs inside `setup`, and a GUI whose setup throws
1625
+ * does not lose its panel, it loses the plugin.
1626
+ */
1627
+ const tryAutoOpen = () => {
1628
+ try {
1629
+ if (!ctx.layout.ready()) return
1630
+ const session = ctx.sessions.current()
1631
+ if (!session) return
1632
+ if (openedFor.has(session.key)) return
1633
+ // Before the stored layout loads every read reports an empty layout, so
1634
+ // this must not act until `ready()` is true or it reopens a tab the user
1635
+ // deliberately closed.
1636
+ if (ctx.layout.state(panelKey, session) !== "closed") {
1637
+ openedFor.add(session.key)
1638
+ return
1639
+ }
1640
+ openedFor.add(session.key)
1641
+ ctx.layout.open(panelKey, session)
1642
+ } catch {
1643
+ // Auto-open is best effort; never surface as a plugin failure.
1644
+ }
1645
+ }
1646
+ /**
1647
+ * A slow poll rather than an event, because there is no session-routed event
1648
+ * to subscribe to and 800ms costs nothing. It runs for the life of the window
1649
+ * instead of stopping: the window is what owns the panel, and the user may
1650
+ * open their first session minutes after launch.
1651
+ */
1652
+ const autoOpenTimer = setInterval(tryAutoOpen, 800)
1653
+
1654
+ ctx.add(Slot, {
1655
+ at: "window.bottom",
1656
+ render: () => (
1657
+ <span style={{ display: "inline-flex", "align-items": "center", gap: "5px" }}>
1658
+ <BrandMark size={12} />
1659
+ <span style={{ "font-size": type.caption, color: theme.faint }}>Smart Router</span>
1660
+ </span>
1661
+ ),
1662
+ })
1663
+
1664
+ // Cleanup: the interval belongs to this plugin's lifetime. Placed after every
1665
+ // registration, because a return before `ctx.add` would skip that contribution.
1666
+ return () => {
1667
+ clearInterval(autoOpenTimer)
1668
+ }
1669
+ },
1670
+ })