@cortexkit/opencode-anthropic-auth 2.2.7 → 2.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.
Files changed (39) hide show
  1. package/dist/cli.js +43 -42
  2. package/dist/index-2jsr8wvj.js +43 -0
  3. package/dist/index-46e2gk3c.js +2 -0
  4. package/dist/{index-62x1p34p.js → index-71temqvq.js} +1 -1
  5. package/dist/{index-ppdrzs41.js → index-bcp23wwb.js} +1 -1
  6. package/dist/{index-hazrk5c3.js → index-c8ehrd0p.js} +1 -1
  7. package/dist/index-dpnhamzw.js +2 -0
  8. package/dist/{index-0kxy8k2c.js → index-m4dazzyf.js} +1 -1
  9. package/dist/{index-b1dvkp15.js → index-naff9h33.js} +1 -1
  10. package/dist/{index-6chd6jnr.js → index-sg0knfec.js} +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/oauth-destination.d.ts +35 -0
  13. package/dist/plugin-test-handles.d.ts +26 -0
  14. package/dist/rpc/port-file.js +1 -1
  15. package/dist/rpc/protocol.js +1 -1
  16. package/dist/rpc/rpc-client.js +1 -1
  17. package/dist/rpc/rpc-dir.js +1 -1
  18. package/dist/sidebar-state.d.ts +6 -3
  19. package/dist/sidebar-state.js +1 -1
  20. package/dist/tui/claude-sidebar.d.ts +58 -0
  21. package/dist/tui/menu-status.d.ts +70 -0
  22. package/dist/tui-preferences.js +2 -2
  23. package/dist/v2/adapter.d.ts +13 -8
  24. package/dist/v2/server.js +10 -10
  25. package/dist/v2/tui.d.ts +12 -0
  26. package/dist/v2/tui.js +4 -5
  27. package/package.json +2 -2
  28. package/src/sidebar-state.ts +55 -46
  29. package/src/tui/claude-sidebar.tsx +155 -15
  30. package/src/tui/entry.mjs +13 -0
  31. package/src/tui/menu-status.tsx +300 -0
  32. package/src/tui-compiled/sidebar-state.ts +55 -46
  33. package/src/tui-compiled/tui/claude-sidebar.tsx +112 -12
  34. package/src/tui-compiled/tui/menu-status.tsx +388 -0
  35. package/src/tui-compiled/tui-preferences.ts +32 -3
  36. package/src/tui-preferences.ts +32 -3
  37. package/dist/index-20d6rmjj.js +0 -2
  38. package/dist/index-cjjjgewq.js +0 -43
  39. package/dist/index-jvhs8sxv.js +0 -2
@@ -0,0 +1,300 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+
3
+ // The status view of the OpenCode 2 `/claude` menu. Core writes a section's
4
+ // status as plain lines, some of them Markdown: the quota summary has `##`
5
+ // headings and indented `-` bullets. This view draws headings and bullets as
6
+ // styled text instead of showing their markers. For the Quota section it draws
7
+ // the account blocks of OpenCode 1's quota view, read from the same server
8
+ // state as the sidebar, and falls back to the text when that read fails.
9
+
10
+ import { TextAttributes } from '@opentui/core'
11
+ import { createSignal, For, type JSX, Show } from 'solid-js'
12
+ import type { SidebarState } from '../sidebar-state.js'
13
+ import {
14
+ type AnthropicAuthTuiPrefs,
15
+ readTuiPreferencesFile,
16
+ resolveAnthropicAuthPrefs,
17
+ } from '../tui-preferences.js'
18
+ import {
19
+ AccountBlock,
20
+ type HostTheme,
21
+ type SidebarTheme,
22
+ sidebarThemeFromHost,
23
+ } from './claude-sidebar.js'
24
+
25
+ /** One status line, classified for drawing. */
26
+ export type StatusLine =
27
+ | { readonly kind: 'heading'; readonly level: number; readonly text: string }
28
+ | { readonly kind: 'bullet'; readonly indent: number; readonly text: string }
29
+ | { readonly kind: 'text'; readonly text: string }
30
+ | { readonly kind: 'blank' }
31
+
32
+ // The widest indent kept for a bullet; deeper nesting is drawn at this indent.
33
+ const MAX_BULLET_INDENT = 8
34
+
35
+ // The width of OpenCode 1's quota view (`QuotaDialogContent` in `tui.tsx`).
36
+ const QUOTA_VIEW_WIDTH = 58
37
+
38
+ /**
39
+ * Splits a section's status lines (each may hold several lines) and classifies
40
+ * each one. Only line-leading Markdown is read: `#` headings and `-`, `*` or
41
+ * `+` bullets. Everything else, including any `*` inside a line, stays as
42
+ * written. Runs of blank lines collapse to one, and leading or trailing blank
43
+ * lines are dropped.
44
+ */
45
+ export function parseStatusLines(lines: readonly string[]): StatusLine[] {
46
+ const parsed: StatusLine[] = []
47
+ for (const raw of lines.join('\n').split(/\r?\n/)) {
48
+ const line = raw.trimEnd()
49
+ if (line.trim() === '') {
50
+ if (parsed.length && parsed.at(-1)?.kind !== 'blank')
51
+ parsed.push({ kind: 'blank' })
52
+ continue
53
+ }
54
+ const heading = /^\s{0,3}(#{1,6})\s+(.+?)(?:\s+#+)?$/.exec(line)
55
+ if (heading?.[1] && heading[2]) {
56
+ parsed.push({
57
+ kind: 'heading',
58
+ level: heading[1].length,
59
+ text: heading[2],
60
+ })
61
+ continue
62
+ }
63
+ const bullet = /^(\s*)[-*+]\s+(.+)$/.exec(line)
64
+ if (bullet?.[2]) {
65
+ parsed.push({
66
+ kind: 'bullet',
67
+ indent: Math.min(bullet[1]?.length ?? 0, MAX_BULLET_INDENT),
68
+ text: bullet[2],
69
+ })
70
+ continue
71
+ }
72
+ parsed.push({ kind: 'text', text: line })
73
+ }
74
+ if (parsed.at(-1)?.kind === 'blank') parsed.pop()
75
+ return parsed
76
+ }
77
+
78
+ /** Draws one status line; headings and bullets lose their Markdown markers. */
79
+ function StatusRow(props: {
80
+ readonly line: StatusLine
81
+ readonly theme: SidebarTheme
82
+ }): JSX.Element {
83
+ const line = props.line
84
+ switch (line.kind) {
85
+ case 'heading':
86
+ return (
87
+ <text
88
+ // Top-level headings take the accent color; lower levels, such as
89
+ // one account in the quota summary, stay in the text color.
90
+ fg={line.level <= 2 ? props.theme.accent : props.theme.text}
91
+ attributes={TextAttributes.BOLD}
92
+ >
93
+ {line.text}
94
+ </text>
95
+ )
96
+ case 'bullet':
97
+ return (
98
+ // The text sits in its own column, so a long bullet wraps under its
99
+ // first word instead of under the bullet mark.
100
+ <box flexDirection='row' paddingLeft={line.indent}>
101
+ <text fg={props.theme.textMuted} flexShrink={0}>
102
+ {'• '}
103
+ </text>
104
+ <text fg={props.theme.text} flexGrow={1} flexShrink={1}>
105
+ {line.text}
106
+ </text>
107
+ </box>
108
+ )
109
+ case 'text':
110
+ return <text fg={props.theme.text}>{line.text}</text>
111
+ default:
112
+ return <box height={1} />
113
+ }
114
+ }
115
+
116
+ /** Draws status lines with headings and bullets styled, not as Markdown. */
117
+ export function StatusText(props: {
118
+ readonly lines: readonly string[]
119
+ readonly theme: SidebarTheme
120
+ }): JSX.Element {
121
+ return (
122
+ <box flexDirection='column' width='100%'>
123
+ <For each={parseStatusLines(props.lines)}>
124
+ {(line) => <StatusRow line={line} theme={props.theme} />}
125
+ </For>
126
+ </box>
127
+ )
128
+ }
129
+
130
+ /**
131
+ * OpenCode 1's quota view: one account block for main and for each enabled or
132
+ * vault-held fallback, as the sidebar draws them.
133
+ */
134
+ export function QuotaStatus(props: {
135
+ readonly state: SidebarState
136
+ readonly theme: SidebarTheme
137
+ readonly prefs: AnthropicAuthTuiPrefs
138
+ readonly now: number
139
+ }): JSX.Element {
140
+ const fallbacks = () =>
141
+ (props.state.fallbacks ?? []).filter(
142
+ (fallback) =>
143
+ fallback.enabled ||
144
+ (fallback.custodyState && fallback.custodyState !== 'off'),
145
+ )
146
+ return (
147
+ // As wide as OpenCode 1's quota view: wider, the reset times drift far
148
+ // from the bars they belong to.
149
+ <box flexDirection='column' width='100%' maxWidth={QUOTA_VIEW_WIDTH}>
150
+ <AccountBlock
151
+ theme={props.theme}
152
+ appearance={props.prefs.appearance}
153
+ name='main'
154
+ quota={props.state.main?.quota}
155
+ active={props.state.activeId === 'main'}
156
+ pacingEnabled={props.prefs.sections.pacing}
157
+ tierLabel={props.state.main?.tierLabel}
158
+ stateLine={props.state.main?.stateLine}
159
+ quotaDated={props.state.main?.quotaDated}
160
+ now={props.now}
161
+ />
162
+ <Show when={props.prefs.sections.fallbackAccounts}>
163
+ <For each={fallbacks()}>
164
+ {(fallback) => (
165
+ <AccountBlock
166
+ theme={props.theme}
167
+ appearance={props.prefs.appearance}
168
+ name={fallback.label ?? fallback.id}
169
+ quota={fallback.quota}
170
+ active={props.state.activeId === fallback.id}
171
+ pacingEnabled={props.prefs.sections.pacing}
172
+ tierLabel={fallback.tierLabel}
173
+ needsReauth={fallback.needsReauth}
174
+ vaultReauth={fallback.vaultReauth}
175
+ stateLine={fallback.stateLine}
176
+ quotaDated={fallback.quotaDated}
177
+ marginTop={1}
178
+ now={props.now}
179
+ />
180
+ )}
181
+ </For>
182
+ </Show>
183
+ </box>
184
+ )
185
+ }
186
+
187
+ // --- OpenCode 2 -------------------------------------------------------------
188
+
189
+ /** One section's status, as the `/claude` menu hands it over. */
190
+ export interface MenuStatusView {
191
+ readonly title: string
192
+ readonly lines: readonly string[]
193
+ /**
194
+ * Reads the sidebar's quota state; given for the Quota section only, whose
195
+ * view then draws account blocks instead of the text.
196
+ */
197
+ readonly readQuota?: (signal: AbortSignal) => Promise<SidebarState>
198
+ }
199
+
200
+ /** The OpenCode 2 TUI context members the status dialog uses. */
201
+ export interface MenuStatusHost {
202
+ readonly theme: HostTheme
203
+ readonly ui: {
204
+ readonly dialog: {
205
+ show(render: () => JSX.Element, onClose?: () => void): void
206
+ set(options: { readonly size?: 'medium' | 'large' | 'xlarge' }): void
207
+ }
208
+ }
209
+ }
210
+
211
+ type QuotaView =
212
+ | { readonly kind: 'loading' }
213
+ | {
214
+ readonly kind: 'ready'
215
+ readonly state: SidebarState
216
+ readonly prefs: AnthropicAuthTuiPrefs
217
+ }
218
+ | { readonly kind: 'failed' }
219
+
220
+ type ReadyQuota = Extract<QuotaView, { readonly kind: 'ready' }>
221
+
222
+ /**
223
+ * Shows a section's status in a wide OpenCode 2 dialog. The returned promise
224
+ * settles when the person closes the dialog, or when `signal` aborts.
225
+ */
226
+ export function showMenuStatusDialog(
227
+ host: MenuStatusHost,
228
+ view: MenuStatusView,
229
+ signal?: AbortSignal,
230
+ ): Promise<void> {
231
+ return new Promise((resolve) => {
232
+ if (signal?.aborted) return resolve()
233
+ // Stops the quota read once the dialog closes or the menu goes away.
234
+ const reading = new AbortController()
235
+ const close = () => {
236
+ reading.abort()
237
+ resolve()
238
+ }
239
+ signal?.addEventListener('abort', close, { once: true })
240
+ const [quota, setQuota] = createSignal<QuotaView>({ kind: 'loading' })
241
+ const readQuota = view.readQuota
242
+ if (readQuota)
243
+ void Promise.all([
244
+ readQuota(reading.signal),
245
+ readTuiPreferencesFile().then(resolveAnthropicAuthPrefs),
246
+ ]).then(
247
+ ([state, prefs]) => {
248
+ if (!reading.signal.aborted) setQuota({ kind: 'ready', state, prefs })
249
+ },
250
+ () => {
251
+ // An older server without the sidebar method, or a failed read:
252
+ // the status text still says everything the blocks would.
253
+ if (!reading.signal.aborted) setQuota({ kind: 'failed' })
254
+ },
255
+ )
256
+ const ready = () => {
257
+ const current = quota()
258
+ return current.kind === 'ready' ? current : undefined
259
+ }
260
+ const theme = () => sidebarThemeFromHost(host.theme)
261
+ host.ui.dialog.show(
262
+ () => (
263
+ <box flexDirection='column' paddingLeft={2} paddingRight={2}>
264
+ <box paddingBottom={1}>
265
+ <text fg={theme().text} attributes={TextAttributes.BOLD}>
266
+ {view.title}
267
+ </text>
268
+ </box>
269
+ <Show
270
+ when={readQuota && quota().kind !== 'failed'}
271
+ fallback={<StatusText lines={view.lines} theme={theme()} />}
272
+ >
273
+ <Show
274
+ when={ready()}
275
+ fallback={
276
+ <text fg={theme().textMuted}>{'Reading quota\u2026'}</text>
277
+ }
278
+ >
279
+ {(ready: () => ReadyQuota) => (
280
+ <QuotaStatus
281
+ state={ready().state}
282
+ prefs={ready().prefs}
283
+ theme={theme()}
284
+ now={Date.now()}
285
+ />
286
+ )}
287
+ </Show>
288
+ </Show>
289
+ <box paddingTop={1}>
290
+ <text fg={theme().textMuted}>Press Esc to close.</text>
291
+ </box>
292
+ </box>
293
+ ),
294
+ close,
295
+ )
296
+ // The widest dialog gives the account blocks their full width and wraps
297
+ // status text as little as it can.
298
+ host.ui.dialog.set({ size: 'xlarge' })
299
+ })
300
+ }
@@ -30,7 +30,9 @@ export interface AccountQuota {
30
30
 
31
31
  export interface SidebarAccountState {
32
32
  id: string
33
- label: string | undefined
33
+ // Absent, rather than present and `undefined`, when the account has no
34
+ // label: the OpenCode 2 RPC boundary refuses a value carrying `undefined`.
35
+ label?: string
34
36
  quota: AccountQuota | null
35
37
  enabled: boolean
36
38
  stateLine?: string
@@ -91,7 +93,9 @@ export interface SidebarState {
91
93
  refreshBackoffUntil?: number
92
94
  }
93
95
  fallbacks: SidebarAccountState[]
94
- activeId: string | undefined
96
+ // Absent rather than `undefined`: `normalizeSidebarState` never writes the
97
+ // key, and the OpenCode 2 RPC boundary refuses a value carrying `undefined`.
98
+ activeId?: string
95
99
  route: string
96
100
  relay: { enabled: boolean; transport: string } | null
97
101
  fastMode: boolean
@@ -146,7 +150,6 @@ export function getSidebarStateFile(): string {
146
150
  export const DEFAULT_SIDEBAR_STATE: SidebarState = {
147
151
  main: { quota: null },
148
152
  fallbacks: [],
149
- activeId: undefined,
150
153
  route: 'main',
151
154
  relay: null,
152
155
  fastMode: false,
@@ -350,40 +353,46 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
350
353
  }
351
354
  if (typeof m.quotaBackedOff === 'boolean')
352
355
  main.quotaBackedOff = m.quotaBackedOff
353
- if (typeof m.quotaBackoffUntil === 'number')
356
+ // Only finite numbers are display data: the OpenCode 2 RPC boundary
357
+ // refuses NaN and Infinity, so they are dropped like any wrong type.
358
+ if (isFiniteNumber(m.quotaBackoffUntil)) {
354
359
  main.quotaBackoffUntil = m.quotaBackoffUntil
360
+ }
355
361
  if (typeof m.refreshBackedOff === 'boolean')
356
362
  main.refreshBackedOff = m.refreshBackedOff
357
- if (typeof m.refreshBackoffUntil === 'number')
363
+ if (isFiniteNumber(m.refreshBackoffUntil)) {
358
364
  main.refreshBackoffUntil = m.refreshBackoffUntil
365
+ }
359
366
  }
360
367
 
361
368
  const fallbacks: SidebarAccountState[] = Array.isArray(raw.fallbacks)
362
369
  ? raw.fallbacks
363
370
  .filter(isRecord)
364
371
  .filter((entry) => typeof entry.id === 'string')
365
- .map((entry) => ({
366
- id: entry.id as string,
367
- label: typeof entry.label === 'string' ? entry.label : undefined,
368
- quota: normalizeAccountQuota(entry.quota),
369
- enabled: typeof entry.enabled === 'boolean' ? entry.enabled : false,
370
- ...(typeof entry.stateLine === 'string' && {
371
- stateLine: entry.stateLine,
372
- }),
373
- ...(entry.quotaDated === true && { quotaDated: true }),
374
- needsReauth:
375
- typeof entry.needsReauth === 'boolean' ? entry.needsReauth : false,
376
- ...(entry.vaultReauth === true && { vaultReauth: true }),
377
- ...(entry.vaultServed === true && { vaultServed: true }),
378
- ...(typeof entry.custodyState === 'string' && {
379
- custodyState:
380
- entry.custodyState as SidebarAccountState['custodyState'],
372
+ .map(
373
+ (entry): SidebarAccountState => ({
374
+ id: entry.id as string,
375
+ ...(typeof entry.label === 'string' && { label: entry.label }),
376
+ quota: normalizeAccountQuota(entry.quota),
377
+ enabled: typeof entry.enabled === 'boolean' ? entry.enabled : false,
378
+ ...(typeof entry.stateLine === 'string' && {
379
+ stateLine: entry.stateLine,
380
+ }),
381
+ ...(entry.quotaDated === true && { quotaDated: true }),
382
+ needsReauth:
383
+ typeof entry.needsReauth === 'boolean'
384
+ ? entry.needsReauth
385
+ : false,
386
+ ...(entry.vaultReauth === true && { vaultReauth: true }),
387
+ ...(entry.vaultServed === true && { vaultServed: true }),
388
+ ...(typeof entry.custodyState === 'string' && {
389
+ custodyState:
390
+ entry.custodyState as SidebarAccountState['custodyState'],
391
+ }),
392
+ ...(typeof entry.tierLabel === 'string' &&
393
+ entry.tierLabel.trim() && { tierLabel: entry.tierLabel.trim() }),
381
394
  }),
382
- tierLabel:
383
- typeof entry.tierLabel === 'string' && entry.tierLabel.trim()
384
- ? entry.tierLabel.trim()
385
- : undefined,
386
- }))
395
+ )
387
396
  : []
388
397
 
389
398
  let relay: SidebarState['relay'] = null
@@ -400,11 +409,10 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
400
409
  if (typeof ck.enabled === 'boolean') {
401
410
  cacheKeep = {
402
411
  enabled: ck.enabled,
403
- window: typeof ck.window === 'string' ? ck.window : undefined,
404
- trackedSessions:
405
- typeof ck.trackedSessions === 'number'
406
- ? ck.trackedSessions
407
- : undefined,
412
+ ...(typeof ck.window === 'string' && { window: ck.window }),
413
+ ...(isFiniteNumber(ck.trackedSessions) && {
414
+ trackedSessions: ck.trackedSessions,
415
+ }),
408
416
  }
409
417
  }
410
418
  }
@@ -432,23 +440,23 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
432
440
  mode: recovery.mode,
433
441
  remaining: Math.max(0, Math.floor(recovery.remaining)),
434
442
  changedAt: recovery.changedAt,
435
- requestedModelId:
436
- typeof recovery.requestedModelId === 'string'
437
- ? recovery.requestedModelId
438
- : undefined,
439
- targetModelId:
440
- typeof recovery.targetModelId === 'string'
441
- ? recovery.targetModelId
442
- : undefined,
443
+ ...(typeof recovery.requestedModelId === 'string' && {
444
+ requestedModelId: recovery.requestedModelId,
445
+ }),
446
+ ...(typeof recovery.targetModelId === 'string' && {
447
+ targetModelId: recovery.targetModelId,
448
+ }),
443
449
  },
444
450
  ]
445
451
  })
446
452
  : []
447
453
 
454
+ const prime = normalizePrimeSection(raw.prime)
455
+
448
456
  return {
449
457
  main,
450
458
  fallbacks,
451
- activeId: typeof raw.activeId === 'string' ? raw.activeId : undefined,
459
+ ...(typeof raw.activeId === 'string' && { activeId: raw.activeId }),
452
460
  route:
453
461
  typeof raw.route === 'string' ? raw.route : DEFAULT_SIDEBAR_STATE.route,
454
462
  relay,
@@ -457,9 +465,9 @@ export function normalizeSidebarState(raw: unknown): SidebarState {
457
465
  ? raw.fastMode
458
466
  : DEFAULT_SIDEBAR_STATE.fastMode,
459
467
  cacheKeep,
460
- prime: normalizePrimeSection(raw.prime),
461
- fableRecoveries: fableRecoveries.length > 0 ? fableRecoveries : undefined,
462
- lastUpdated: typeof raw.lastUpdated === 'number' ? raw.lastUpdated : 0,
468
+ ...(prime && { prime }),
469
+ ...(fableRecoveries.length > 0 && { fableRecoveries }),
470
+ lastUpdated: isFiniteNumber(raw.lastUpdated) ? raw.lastUpdated : 0,
463
471
  }
464
472
  }
465
473
 
@@ -692,9 +700,10 @@ export interface SidebarStateWriteOptions {
692
700
  })
693
701
  | undefined
694
702
  >
695
- onRoutingResolved?: (
696
- routing: Pick<SidebarState, 'activeId' | 'route'>,
697
- ) => void
703
+ onRoutingResolved?: (routing: {
704
+ activeId: string | undefined
705
+ route: string
706
+ }) => void
698
707
  }
699
708
 
700
709
  export async function setSidebarState(
@@ -698,7 +698,8 @@ export function createSidebarController(initialPrefs) {
698
698
  prefs,
699
699
  collapsed,
700
700
  toggleCollapsed,
701
- dispose
701
+ dispose,
702
+ retryMaxMs: 30_000
702
703
  };
703
704
  }
704
705
 
@@ -1239,38 +1240,137 @@ export function sidebarThemeFromHost(theme) {
1239
1240
 
1240
1241
  /** Reads one session's sidebar state from the location's server. */
1241
1242
 
1242
- function HostSidebar(props) {
1243
- const [state, setState] = createSignal(DEFAULT_SIDEBAR_STATE);
1243
+ /**
1244
+ * What the sidebar reads and draws with, independent of the host chrome.
1245
+ * The timer functions default to the global ones; a test passes its own so it
1246
+ * can drive the refresh schedule without touching the shared globals.
1247
+ */
1248
+
1249
+ /** The refresh loop's own timers, so a test can drive its schedule. */
1250
+
1251
+ /**
1252
+ * Delay before the next refresh after `failures` failed reads in a row, each
1253
+ * one longer than the last until the controller's limit. A successful read
1254
+ * resets the count, so a healthy server is polled at the configured rate. A
1255
+ * failed read still costs the server a full runtime read, so retrying at the
1256
+ * poll rate keeps that cost alive for as long as the failure lasts.
1257
+ */
1258
+ export function sidebarRetryDelay(pollMs, failures, retryMaxMs) {
1259
+ if (failures <= 0) return pollMs;
1260
+ return Math.min(pollMs * 2 ** failures, retryMaxMs);
1261
+ }
1262
+
1263
+ /** Starts and stops the refresh schedule the sidebar lives on. */
1264
+
1265
+ // One read on start, then a timer that backs off after failures and resets
1266
+ // after a success. Lives outside Solid: the component drives it from an
1267
+ // effect, and tests drive it with their own timers.
1268
+ export function createSidebarSchedule(options) {
1269
+ const {
1270
+ read,
1271
+ sessionId,
1272
+ pollRate,
1273
+ onState,
1274
+ setTimeoutImpl = setTimeout,
1275
+ clearTimeoutImpl = clearTimeout
1276
+ } = options;
1244
1277
  const abort = new AbortController();
1278
+ // The timer waiting to run. Every schedule replaces it, so stopping always
1279
+ // clears the one that is actually queued.
1280
+ let timer;
1281
+ let running = false;
1245
1282
  let inFlight = false;
1246
1283
  let shown = '';
1284
+ let failures = 0;
1285
+ // `undefined` means the previous read was still running, which is not a
1286
+ // failure: counting it as one would back off before the server answered.
1247
1287
  const refresh = async () => {
1248
- if (inFlight || abort.signal.aborted) return;
1288
+ if (inFlight || abort.signal.aborted) return undefined;
1249
1289
  inFlight = true;
1250
1290
  try {
1251
- const next = await props.read(props.sessionId, abort.signal);
1252
- // Every answer is stamped now; redraw only when its content changed.
1253
- // Countdowns and ages advance on the sidebar's own clock.
1291
+ const next = await read(sessionId, abort.signal);
1292
+ // Every answer is stamped by the server; redraw only when its content
1293
+ // changed. Countdowns and ages advance on the sidebar's own clock.
1254
1294
  const content = JSON.stringify({
1255
1295
  ...next,
1256
1296
  lastUpdated: 0
1257
1297
  });
1258
1298
  if (!abort.signal.aborted && content !== shown) {
1259
1299
  shown = content;
1260
- setState(next);
1300
+ onState(next);
1261
1301
  }
1302
+ return true;
1262
1303
  } catch {
1263
1304
  // Keep the last state; an older server without `sidebar` keeps waiting.
1305
+ return false;
1264
1306
  } finally {
1265
1307
  inFlight = false;
1266
1308
  }
1267
1309
  };
1310
+ const schedule = () => {
1311
+ if (!running) return;
1312
+ const {
1313
+ pollMs,
1314
+ retryMaxMs
1315
+ } = pollRate();
1316
+ const wait = sidebarRetryDelay(pollMs, failures, retryMaxMs);
1317
+ timer = setTimeoutImpl(async () => {
1318
+ timer = undefined;
1319
+ if (!running) return;
1320
+ const ok = await refresh();
1321
+ if (!running) return;
1322
+ if (ok === true) failures = 0;else if (ok === false) failures += 1;
1323
+ schedule();
1324
+ }, wait);
1325
+ };
1326
+ return {
1327
+ start() {
1328
+ if (running || abort.signal.aborted) return;
1329
+ running = true;
1330
+ // The first read happens here rather than in the timer, so a caller that
1331
+ // starts before any draw still has an answer waiting for it. The loop
1332
+ // then continues on the retry timer below.
1333
+ void refresh().then(ok => {
1334
+ if (running && ok === false) failures += 1;
1335
+ });
1336
+ schedule();
1337
+ },
1338
+ stop() {
1339
+ running = false;
1340
+ if (timer !== undefined) clearTimeoutImpl(timer);
1341
+ timer = undefined;
1342
+ abort.abort();
1343
+ }
1344
+ };
1345
+ }
1346
+
1347
+ // The OpenCode 2 sidebar's state: its refresh schedule, redrawn through a
1348
+ // signal the component renders from.
1349
+ export function useHostSidebarState(props) {
1350
+ const [state, setState] = createSignal(DEFAULT_SIDEBAR_STATE);
1268
1351
  createEffect(() => {
1269
- const timer = setInterval(refresh, props.controller.prefs().pollMs);
1270
- onCleanup(() => clearInterval(timer));
1352
+ // Reading the poll rate here re-runs this effect when it changes. A
1353
+ // stopped schedule never restarts, so each run builds a fresh one and the
1354
+ // cleanup stops the run it belongs to.
1355
+ const pollMs = props.controller.prefs().pollMs;
1356
+ const schedule = createSidebarSchedule({
1357
+ read: props.read,
1358
+ sessionId: props.sessionId,
1359
+ pollRate: () => ({
1360
+ pollMs,
1361
+ retryMaxMs: props.controller.retryMaxMs
1362
+ }),
1363
+ onState: setState,
1364
+ setTimeoutImpl: props.setTimeoutImpl,
1365
+ clearTimeoutImpl: props.clearTimeoutImpl
1366
+ });
1367
+ schedule.start();
1368
+ onCleanup(() => schedule.stop());
1271
1369
  });
1272
- void refresh();
1273
- onCleanup(() => abort.abort());
1370
+ return state;
1371
+ }
1372
+ function HostSidebar(props) {
1373
+ const state = useHostSidebarState(props);
1274
1374
  return _$createComponent(ClaudeSidebar, {
1275
1375
  state: state,
1276
1376
  theme: () => sidebarThemeFromHost(props.host.theme),