@xtruder/opencode-claude-max-plugin 0.3.0 → 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.
package/src/tui.tsx CHANGED
@@ -132,6 +132,144 @@ function createUsageStore(pollInterval: number) {
132
132
  }
133
133
  }
134
134
 
135
+ // ─── Fallback store ──────────────────────────────────────────────────
136
+
137
+ /**
138
+ * Watches for server-side model fallback (Fable 5 safety refusal → Opus 4.8).
139
+ *
140
+ * The provider attaches a display-only `anthropic.servedBy` object to part
141
+ * metadata when a response was served by a fallback model (see stream.ts).
142
+ * OpenCode persists part metadata and forwards it here via
143
+ * `message.part.updated` — no side-channel store is needed.
144
+ *
145
+ * Sticky routing is keyed on the conversation prefix server-side, so the
146
+ * fallback state is PER SESSION — a refusal in one session says nothing
147
+ * about another.
148
+ *
149
+ * The sidebar state is DERIVED from the session's persisted messages (via
150
+ * api.state, OpenCode's synced replica) rather than accumulated from live
151
+ * events — so it is correct after a TUI restart or when opening an old
152
+ * session. The event subscription only drives the live toast, deduped with
153
+ * an in-memory Set (part events never replay across restarts).
154
+ */
155
+ type FallbackInfo = {
156
+ from: string
157
+ to: string
158
+ kind: "fallback" | "sticky"
159
+ at: number
160
+ }
161
+
162
+ const shortModel = (model: string) => model.replace(/^claude-/, "")
163
+
164
+ // Absolute time — stable without periodic re-rendering ("ago" phrasing
165
+ // would freeze between reactive updates).
166
+ const atTime = (at: number) =>
167
+ new Date(at).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
168
+
169
+ function createFallbackStore(api: Api) {
170
+ // Dedupe the repeated part-update events that fire as a part streams.
171
+ const seenParts = new Set<string>()
172
+
173
+ /** Extract the servedBy marker from one assistant message's parts. */
174
+ const markerOf = (msg: any): FallbackInfo | null => {
175
+ let latest: FallbackInfo | null = null
176
+ for (const part of api.state.part(msg.id)) {
177
+ const servedBy = (part as any)?.metadata?.anthropic?.servedBy
178
+ if (!servedBy?.to) continue
179
+ const time = (part as any)?.time
180
+ const at = time?.end ?? time?.start ?? msg.time?.created ?? 0
181
+ latest = {
182
+ from: String(servedBy.from ?? "unknown"),
183
+ to: String(servedBy.to),
184
+ kind: servedBy.kind === "sticky" ? "sticky" : "fallback",
185
+ at,
186
+ }
187
+ }
188
+ return latest
189
+ }
190
+
191
+ /**
192
+ * Walk a session's messages newest-first, yielding real assistant turns
193
+ * (skipping title-generation / compaction summaries, which never carry
194
+ * routing markers and would mask the real last turn).
195
+ */
196
+ function* assistantTurns(sessionID: string, beforeMessageID?: string) {
197
+ const messages = api.state.session.messages(sessionID)
198
+ let skipping = beforeMessageID !== undefined
199
+ for (let i = messages.length - 1; i >= 0; i--) {
200
+ const msg = messages[i] as any
201
+ if (skipping) {
202
+ if (msg?.id === beforeMessageID) skipping = false
203
+ continue
204
+ }
205
+ if (msg?.role !== "assistant") continue
206
+ if (msg.summary) continue
207
+ yield msg
208
+ }
209
+ }
210
+
211
+ // Live toast — only on the TRANSITION into fallback. A conversation that
212
+ // trips the classifier every turn would otherwise toast on every message;
213
+ // the sidebar line covers the continuing state. Sticky follow-ups never
214
+ // toast for the same reason.
215
+ const off = api.event.on("message.part.updated", (event) => {
216
+ const part = (event as any).properties?.part
217
+ const sessionID = (event as any).properties?.sessionID ?? part?.sessionID
218
+ const servedBy = part?.metadata?.anthropic?.servedBy
219
+ if (!servedBy?.to || !part?.id || !part?.messageID || !sessionID) return
220
+ if (seenParts.has(part.id)) return
221
+ seenParts.add(part.id)
222
+ if (servedBy.kind === "sticky") return
223
+
224
+ // Was the previous completed assistant turn already fallback-served?
225
+ for (const msg of assistantTurns(sessionID, part.messageID)) {
226
+ if (markerOf(msg)) return // continuation, not a transition
227
+ if (msg.time?.completed) break // last known state was the primary model
228
+ }
229
+
230
+ api.ui.toast({
231
+ variant: "warning",
232
+ title: "Model fallback",
233
+ message: `${shortModel(String(servedBy.from ?? "unknown"))} refused — answered by ${shortModel(String(servedBy.to))}`,
234
+ duration: 6000,
235
+ })
236
+ })
237
+
238
+ /**
239
+ * Fallback state for one session, derived from its message parts — a pure
240
+ * reactive derivation that re-evaluates only when session state changes.
241
+ *
242
+ * Only the MOST RECENT *settled* assistant turn is consulted: sticky
243
+ * routing re-emits a servedBy marker on every turn while active, and a
244
+ * turn that routed back to the primary model carries no marker. Looking
245
+ * further back would resurrect a stale fallback after the session has
246
+ * already returned to the requested model.
247
+ *
248
+ * A still-streaming turn without a marker is "unknown", not "no
249
+ * fallback" — the previous completed turn's state keeps showing until
250
+ * the new turn settles (otherwise the line flickers off on every send
251
+ * and back on when the refusal hits mid-stream).
252
+ *
253
+ * No time window: "the latest turn was served by X" is a fact that stays
254
+ * true until the next turn says otherwise. Whether the server's sticky
255
+ * routing is still active is unobservable — we don't guess.
256
+ */
257
+ const active = (sessionID: string | undefined): FallbackInfo | null => {
258
+ if (!sessionID) return null
259
+ for (const msg of assistantTurns(sessionID)) {
260
+ const marker = markerOf(msg)
261
+ if (marker) return marker
262
+ // No marker on a finished turn → currently served by the requested
263
+ // model. No marker on a streaming turn → not known yet, keep looking
264
+ // at the previous turn.
265
+ if (msg.time?.completed) return null
266
+ }
267
+ return null
268
+ }
269
+
270
+ return { active, dispose: off }
271
+ }
272
+
135
273
  // ─── Helpers ─────────────────────────────────────────────────────────
136
274
 
137
275
  const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
@@ -242,6 +380,34 @@ function UsageSidebar(props: {
242
380
  )
243
381
  }
244
382
 
383
+ // ─── Fallback Sidebar Component ──────────────────────────────────────
384
+
385
+ function FallbackSidebar(props: {
386
+ theme: TuiThemeCurrent
387
+ sessionID: string | undefined
388
+ active: (sessionID: string | undefined) => FallbackInfo | null
389
+ }) {
390
+ return (
391
+ <Show when={props.active(props.sessionID)}>
392
+ {(info: Accessor<FallbackInfo>) => (
393
+ <box width="100%" flexDirection="column">
394
+ <text fg={props.theme.warning}>
395
+ <b>Model Fallback</b>
396
+ </text>
397
+ <text fg={props.theme.text}>
398
+ {shortModel(info().from)} → {shortModel(info().to)}
399
+ </text>
400
+ <text fg={props.theme.textMuted}>
401
+ {info().kind === "sticky"
402
+ ? `sticky-routed at ${atTime(info().at)}`
403
+ : `refused at ${atTime(info().at)}`}
404
+ </text>
405
+ </box>
406
+ )}
407
+ </Show>
408
+ )
409
+ }
410
+
245
411
  // ─── Dialog Component ────────────────────────────────────────────────
246
412
 
247
413
  function UsageDialog(props: {
@@ -353,6 +519,10 @@ const tui: TuiPlugin = async (api, options) => {
353
519
  const store = createUsageStore(config.poll_interval)
354
520
  api.lifecycle.onDispose(store.dispose)
355
521
 
522
+ // Model fallback notifications (toast) + sidebar status line
523
+ const fallback = createFallbackStore(api)
524
+ api.lifecycle.onDispose(fallback.dispose)
525
+
356
526
  // Refresh when session goes idle (inference completed — server just wrote new headers)
357
527
  const offEvent = api.event.on("session.idle", () => {
358
528
  setTimeout(store.refresh, 1000)
@@ -360,31 +530,40 @@ const tui: TuiPlugin = async (api, options) => {
360
530
  api.lifecycle.onDispose(offEvent)
361
531
 
362
532
  // Register /usage command
363
- api.command?.register(() => [
364
- {
365
- title: "Claude subscription usage",
366
- value: "anthropic-sdk.usage",
367
- category: "Claude",
368
- slash: {
369
- name: "usage",
370
- },
371
- onSelect() {
372
- store.refresh()
373
- api.ui.dialog.replace(() => (
374
- <UsageDialog api={api} usage={store.usage} status={store.status} />
375
- ))
533
+ const offCommand = api.keymap.registerLayer({
534
+ commands: [
535
+ {
536
+ namespace: "palette",
537
+ name: "anthropic-sdk.usage",
538
+ title: "Claude subscription usage",
539
+ category: "Claude",
540
+ slashName: "usage",
541
+ run() {
542
+ store.refresh()
543
+ api.ui.dialog.replace(() => (
544
+ <UsageDialog api={api} usage={store.usage} status={store.status} />
545
+ ))
546
+ },
376
547
  },
377
- },
378
- ])
548
+ ],
549
+ })
550
+ api.lifecycle.onDispose(offCommand)
379
551
 
380
552
  // Register sidebar slot
381
553
  if (config.sidebar) {
382
554
  api.slots.register({
383
555
  order: 110,
384
556
  slots: {
385
- sidebar_content(ctx) {
557
+ sidebar_content(ctx, props) {
386
558
  return (
387
- <UsageSidebar theme={ctx.theme.current} usage={store.usage} status={store.status} />
559
+ <>
560
+ <UsageSidebar theme={ctx.theme.current} usage={store.usage} status={store.status} />
561
+ <FallbackSidebar
562
+ theme={ctx.theme.current}
563
+ sessionID={(props as { session_id?: string } | undefined)?.session_id}
564
+ active={fallback.active}
565
+ />
566
+ </>
388
567
  )
389
568
  },
390
569
  },