opencode-plugin-kit 1.0.0-alpha.1

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.
@@ -0,0 +1,32 @@
1
+ // Resolves the model a session is actually using from its last assistant
2
+ // message. Both the usage-quota tracker and model recommender need this;
3
+ // the defensive walk is delegated to the kit's shared walker.
4
+ import { walkMessages } from "./messages.ts"
5
+ import type { KitContext } from "./host.ts"
6
+
7
+ export interface CurrentModel {
8
+ providerID: string
9
+ modelID: string
10
+ }
11
+
12
+ /**
13
+ * Resolve the current model from a session's most recent assistant message.
14
+ * Returns undefined when the session has no assistant messages or the
15
+ * shape is unreadable — callers fall back to the workspace default.
16
+ */
17
+ export function resolveCurrentModel(context: KitContext, sessionID?: string): CurrentModel | undefined {
18
+ let found: CurrentModel | undefined
19
+ walkMessages(
20
+ context,
21
+ sessionID,
22
+ (m) => {
23
+ if (found) return
24
+ const providerID = String(m?.model?.providerID ?? m?.providerID ?? "")
25
+ const modelID = String(m?.model?.id ?? m?.modelID ?? m?.id ?? "")
26
+ if (providerID && modelID) found = { providerID, modelID }
27
+ },
28
+ {},
29
+ undefined,
30
+ )
31
+ return found
32
+ }
package/src/format.ts ADDED
@@ -0,0 +1,25 @@
1
+ // Shared number/date formatting for sidebar widgets.
2
+
3
+ /** Locale integer with a safe fallback for non-finite values. */
4
+ export function fmt(n: unknown): string {
5
+ return typeof n === "number" && Number.isFinite(n) ? n.toLocaleString("en-US") : "0"
6
+ }
7
+
8
+ /** Dollar cost, fixed to `precision` decimals (default 4); strings pass through untouched. */
9
+ export function fmtCost(v: unknown, precision = 4): string {
10
+ if (typeof v === "number" && Number.isFinite(v)) return `$${v.toFixed(precision)}`
11
+ if (typeof v === "string" && v.trim() !== "") return v
12
+ return `$${(0).toFixed(precision)}`
13
+ }
14
+
15
+ /** ISO timestamp -> compact countdown ("2h 5m", "3d 4h"); "" when absent. */
16
+ export function until(iso: string | undefined): string {
17
+ if (!iso) return ""
18
+ const ms = Date.parse(iso)
19
+ if (!Number.isFinite(ms)) return ""
20
+ const mins = Math.max(0, Math.round((ms - Date.now()) / 60000))
21
+ if (mins < 60) return `${mins}m`
22
+ const h = Math.floor(mins / 60)
23
+ if (h < 48) return `${h}h ${mins % 60}m`
24
+ return `${Math.floor(h / 24)}d ${h % 24}h`
25
+ }
package/src/host.ts ADDED
@@ -0,0 +1,148 @@
1
+ // Narrow host-contract types for opencode-plugin-kit.
2
+ //
3
+ // `@opencode-ai/plugin` is beta: its types ARE the spec. Kit consumes only a
4
+ // small slice of the host context, so instead of accepting `any` everywhere
5
+ // (which silently survives host upgrades and breaks at runtime), this module
6
+ // defines the *structural minimum* kit needs and re-exports the exact host
7
+ // types it hands back. Consumers get compile-time breakage when the host
8
+ // shape drifts, not runtime surprises.
9
+ //
10
+ // Every interface here is structural: a real host context satisfies it
11
+ // as long as the fields exist.
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Host types kit re-exports (single import point for consumers)
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** Toast payload — mirrored from the host's TuiToast; host drift here breaks CI. */
18
+ export type ToastInput = {
19
+ message: string
20
+ variant?: "success" | "error" | "warning" | "info"
21
+ }
22
+
23
+ /** One selectable row in the picker dialog (only the fields kit populates). */
24
+ export type SelectOption<Value = string> = {
25
+ title: string
26
+ value: Value
27
+ description?: string
28
+ disabled?: boolean
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Structural minimum of the host context kit touches
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /** `context.storage.store(key, { initial })` — the older host returns
36
+ * `[value, ref]` (mutating `ref` persists); the newer floating `beta` host
37
+ * types it as `readonly [T, (mutation: (draft: T) => void) => Promise<void>]`.
38
+ * Both shapes satisfy this union; kit code reads `[0]` and mutates it, which
39
+ * both hosts persist. */
40
+ export interface KitStorage {
41
+ store<T extends object>(
42
+ key: string,
43
+ opts: { initial: T },
44
+ ): [T, T] | readonly [T, ((mutation: (draft: T) => void) => void | Promise<void>) | T]
45
+ }
46
+
47
+ /** Slot registration. Only `append`/`after` + a synchronous `render` are
48
+ * needed; the host may accept more options. */
49
+ export interface KitSlotOptions {
50
+ readonly append?: string
51
+ readonly after?: string
52
+ render: () => unknown
53
+ }
54
+
55
+ export interface KitUI {
56
+ readonly toast: {
57
+ show(input: ToastInput): void
58
+ }
59
+ readonly dialog: {
60
+ alert(input: { title: string; message: string }): Promise<unknown>
61
+ select(input: {
62
+ title: string
63
+ message: string
64
+ current?: string
65
+ options: readonly SelectOption[]
66
+ }): Promise<unknown>
67
+ }
68
+ slot(options: KitSlotOptions): unknown
69
+ }
70
+
71
+ /** Command entry handed to `keymap.layer()`. Structural subset of the
72
+ * host command shape; only what the view picker registers. */
73
+ export interface KitCommandEntry {
74
+ readonly id: string
75
+ readonly title: string
76
+ readonly description: string
77
+ readonly group: string
78
+ readonly palette?: boolean
79
+ readonly suggested?: boolean
80
+ readonly slash?: { name: string; aliases?: string[]; arguments?: boolean }
81
+ run: (input?: string) => void
82
+ }
83
+
84
+ export interface KitKeymap {
85
+ layer(register: () => { mode: string; priority: number; commands: KitCommandEntry[] }): void
86
+ }
87
+
88
+ /** `context.data.session.message.list(sessionID)` — messages may arrive as
89
+ * raw objects or `{ info }` envelopes (kit's `unwrap()` handles both). */
90
+ export interface KitSessionData {
91
+ readonly session: {
92
+ readonly message: {
93
+ list(sessionID: string): readonly unknown[] | undefined
94
+ }
95
+ }
96
+ }
97
+
98
+ export interface KitClient {
99
+ readonly integration: {
100
+ list(): Promise<unknown>
101
+ }
102
+ }
103
+
104
+ /** The structural minimum context for every kit factory. Deliberately
105
+ * narrower than the host `TuiPluginApi`: kit only reads these surfaces, so
106
+ * consumers can pass their real context directly. */
107
+ export interface KitContext {
108
+ readonly storage: KitStorage
109
+ readonly ui: KitUI
110
+ readonly keymap: KitKeymap
111
+ readonly data: KitSessionData
112
+ readonly client: KitClient
113
+ readonly options?: Record<string, unknown>
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Structural message/model shapes the defensive readers walk
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /** A message in either of the two beta-API shapes: a discriminated
121
+ * `type`-tagged object or `{ info: Message }` envelope. Kit's readers
122
+ * (`unwrap`, `isAssistant`, `providerId`, `modelId`) accept both. */
123
+ export interface KitMessageShape {
124
+ readonly type?: string
125
+ readonly role?: string
126
+ readonly providerID?: string
127
+ readonly modelID?: string
128
+ readonly id?: string
129
+ readonly name?: string
130
+ /** Message parts (tool calls, errors ride along as parts in some shapes). */
131
+ readonly parts?: ReadonlyArray<unknown>
132
+ readonly model?: { readonly providerID?: string; readonly modelID?: string; readonly id?: string }
133
+ readonly cost?: number
134
+ readonly time?: { readonly created?: number }
135
+ readonly timeCreated?: string | number
136
+ readonly createdAt?: string
137
+ readonly tokens?: {
138
+ readonly input?: number
139
+ readonly output?: number
140
+ readonly reasoning?: number
141
+ readonly cache?: {
142
+ /** v1 shapes a number; some beta payloads nest it as `{ input }`. */
143
+ readonly read?: number | { readonly input?: number }
144
+ readonly write?: number | { readonly input?: number }
145
+ }
146
+ }
147
+ readonly info?: KitMessageShape
148
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ export * from "./providers.ts"
2
+ export * from "./rows.ts"
3
+ export * from "./format.ts"
4
+ export * from "./cache.ts"
5
+ export * from "./schemas.ts"
6
+ export * from "./viewPicker.ts"
7
+ export * from "./currentModel.ts"
8
+ export * from "./cachedResource.ts"
9
+ export * from "./messages.ts"
10
+ export * from "./pollingFetcher.ts"
11
+ export * from "./workspace.ts"
12
+ export * from "./toast.ts"
13
+ export * from "./commands.ts"
14
+ export * from "./collapsible.tsx"
15
+ export * from "./host.ts"
@@ -0,0 +1,118 @@
1
+ // Defensive message traversal for sidebar widgets.
2
+ //
3
+ // Both the usage-quota tracker and model recommender walk session messages
4
+ // to sum tokens/costs or find the active model. The traversal is always
5
+ // defensive (beta API shapes change) and always scoped to assistant
6
+ // messages from one provider. Centralize the fold so the try/catch +
7
+ // unwrap + filter boilerplate lives in one place.
8
+ import { isAssistant, unwrap } from "./providers.ts"
9
+ import type { KitContext, KitMessageShape } from "./host.ts"
10
+
11
+ export interface WalkOptions {
12
+ /** Only visit assistant messages (default true). */
13
+ readonly assistantOnly?: boolean
14
+ /** Only visit messages from this provider (providerID field). */
15
+ readonly provider?: string
16
+ /** Skip messages older than this epoch ms (for windowed walks). */
17
+ readonly since?: number
18
+ }
19
+
20
+ /**
21
+ * Fold over a session's messages defensively. Starts from the most recent
22
+ * message and walks backwards. The reducer receives each message (already
23
+ * unwrapped from `{ info: ... }` envelope). Returning a value replaces the
24
+ * accumulator; returning void keeps it. Never throws — unreadable shapes
25
+ * are skipped.
26
+ *
27
+ * @example
28
+ * // Sum input tokens for the "opencode" provider in the last hour.
29
+ * const totals = walkMessages(context, sessionID, (m, acc) => {
30
+ * acc.tokens += m?.tokens?.input ?? 0
31
+ * return acc
32
+ * }, { provider: "opencode", since: Date.now() - 3600_000 }, { tokens: 0 })
33
+ */
34
+ export function walkMessages<T>(
35
+ context: KitContext,
36
+ sessionID: string | undefined,
37
+ reducer: (message: any, acc: T) => T | void,
38
+ options: WalkOptions = {},
39
+ initial: T,
40
+ ): T {
41
+ if (!sessionID) return initial
42
+ const { assistantOnly = true, provider, since } = options
43
+
44
+ try {
45
+ const messages = context.data.session.message.list(sessionID) ?? []
46
+ let acc = initial
47
+
48
+ for (let i = messages.length - 1; i >= 0; i--) {
49
+ const m = unwrap(messages[i] as KitMessageShape)
50
+
51
+ // Skip non-assistant messages when filtering.
52
+ if (assistantOnly && !isAssistant(m)) continue
53
+
54
+ // Skip messages from other providers when scoped.
55
+ if (provider) {
56
+ const mProvider = m?.model?.providerID ?? m?.providerID ?? ""
57
+ if (mProvider !== provider) continue
58
+ }
59
+
60
+ // Skip messages before the window start.
61
+ if (since) {
62
+ const created = m?.time?.created ?? m?.timeCreated ?? m?.createdAt
63
+ const ts = typeof created === "string" ? Date.parse(created) : typeof created === "number" ? created : NaN
64
+ if (!Number.isFinite(ts) || ts < since) continue
65
+ }
66
+
67
+ const result = reducer(m, acc)
68
+ acc = result === undefined ? acc : result
69
+ }
70
+
71
+ return acc
72
+ } catch {
73
+ return initial
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Sum token counts and cost for a provider's assistant messages.
79
+ * Convenience wrapper around walkMessages for the common case.
80
+ */
81
+ export interface TokenTotals {
82
+ input: number
83
+ output: number
84
+ reasoning: number
85
+ cacheRead: number
86
+ cost: number
87
+ }
88
+
89
+ /** Cache-read tokens: v1 shapes are `cache.read` (number); some beta
90
+ * payloads nest it as `cache.read.input`. Read defensively, typed in
91
+ * `KitMessageShape`. */
92
+ export function cacheReadInput(tokens: KitMessageShape["tokens"]): number | { input?: number } | undefined {
93
+ return tokens?.cache?.read
94
+ }
95
+
96
+ export function sumProviderTokens(
97
+ context: KitContext,
98
+ sessionID: string | undefined,
99
+ provider: string,
100
+ since?: number,
101
+ ): TokenTotals {
102
+ return walkMessages<TokenTotals>(
103
+ context,
104
+ sessionID,
105
+ (m, acc) => {
106
+ const tokens = m?.tokens ?? {}
107
+ acc.input += Number(tokens?.input ?? 0) || 0
108
+ acc.output += Number(tokens?.output ?? 0) || 0
109
+ acc.reasoning += Number(tokens?.reasoning ?? 0) || 0
110
+ const read = cacheReadInput(tokens)
111
+ acc.cacheRead += Number(typeof read === "object" ? (read?.input ?? 0) : (read ?? 0)) || 0
112
+ acc.cost += Number(m?.cost ?? 0) || 0
113
+ return acc
114
+ },
115
+ { provider, since },
116
+ { input: 0, output: 0, reasoning: 0, cacheRead: 0, cost: 0 },
117
+ )
118
+ }
@@ -0,0 +1,81 @@
1
+ // Polling fetcher with throttling, in-flight guard, and last-known-good
2
+ // retention. The usage-quota tracker's fetchUsage/refreshUsage pattern,
3
+ // generalized: any plugin polling an authenticated endpoint needs exactly
4
+ // this — fetch on an interval, don't refetch while one is in flight, keep
5
+ // the last success on failure.
6
+
7
+ export interface PollingFetcherOptions<T> {
8
+ /** The async fetch function. Returns null on failure (keeps last value). */
9
+ readonly fetch: () => Promise<T | null>
10
+ /** Polling interval in ms. */
11
+ readonly intervalMs: number
12
+ /** Minimum gap between fetches in ms (throttle). Defaults to intervalMs. */
13
+ readonly throttleMs?: number
14
+ /** Called with the fresh value on successful fetch. */
15
+ readonly onResult?: (value: T) => void
16
+ /** Called on fetch error (after all retries). */
17
+ readonly onError?: (err: unknown) => void
18
+ }
19
+
20
+ export interface PollingFetcher {
21
+ /** Trigger a fetch immediately (respects throttle + in-flight). */
22
+ readonly refresh: () => void
23
+ /** Stop polling (call from cleanup). */
24
+ readonly stop: () => void
25
+ /** Whether a fetch is currently in flight. */
26
+ readonly inFlight: () => boolean
27
+ }
28
+
29
+ /**
30
+ * Create a polling fetcher. Fetches on an interval, guards against
31
+ * concurrent fetches, and throttles to avoid hammering the endpoint.
32
+ */
33
+ export function createPollingFetcher<T>(options: PollingFetcherOptions<T>): PollingFetcher {
34
+ const { fetch, intervalMs, throttleMs = intervalMs, onResult, onError } = options
35
+
36
+ let lastFetch = 0
37
+ let inFlightFlag = false
38
+ let timer: ReturnType<typeof setInterval> | null = null
39
+
40
+ async function doFetch(): Promise<void> {
41
+ if (inFlightFlag) return
42
+ inFlightFlag = true
43
+ try {
44
+ const result = await fetch()
45
+ if (result !== null) {
46
+ onResult?.(result)
47
+ }
48
+ } catch (err) {
49
+ onError?.(err)
50
+ } finally {
51
+ inFlightFlag = false
52
+ }
53
+ }
54
+
55
+ function refresh(): void {
56
+ const now = Date.now()
57
+ if (now - lastFetch < throttleMs) return
58
+ lastFetch = now
59
+ void doFetch()
60
+ }
61
+
62
+ function stop(): void {
63
+ if (timer !== null) {
64
+ clearInterval(timer)
65
+ timer = null
66
+ }
67
+ }
68
+
69
+ // Kick off the first fetch immediately. Going through refresh() records
70
+ // the timestamp, so the initial fetch counts toward the throttle window.
71
+ refresh()
72
+
73
+ // Start the polling timer.
74
+ timer = setInterval(refresh, intervalMs)
75
+
76
+ return {
77
+ refresh,
78
+ stop,
79
+ inFlight: () => inFlightFlag,
80
+ }
81
+ }
@@ -0,0 +1,200 @@
1
+ // Shared provider vocabulary for OpenCode sidebar widgets.
2
+ // Provider IDs are the OpenCode workspace provider ids ("opencode" = Zen,
3
+ // "opencode-go" = Go).
4
+ import { readFileSync } from "node:fs"
5
+ import { homedir } from "node:os"
6
+ import { join } from "node:path"
7
+ import { createSignal } from "solid-js"
8
+ import { connectedProviderIds } from "./schemas.ts"
9
+ import type { KitContext, KitMessageShape } from "./host.ts"
10
+
11
+ export const ZEN_PROVIDER = "opencode"
12
+ export const GO_PROVIDER = "opencode-go"
13
+ export const DEFAULT_PROVIDERS = [ZEN_PROVIDER, GO_PROVIDER]
14
+
15
+ /** Short human label for the provider column, e.g. "zen" / "go". */
16
+ export function providerLabel(pid: string): string {
17
+ if (pid === ZEN_PROVIDER) return "zen"
18
+ if (pid === GO_PROVIDER) return "go"
19
+ if (pid === "google") return "google"
20
+ if (pid === "zai-coding-plan") return "zai"
21
+ if (pid === "huggingface") return "hf"
22
+ return pid
23
+ }
24
+
25
+ /** Full display title for a provider — picker dialogs, view headers. */
26
+ export function providerTitle(pid: string): string {
27
+ if (pid === ZEN_PROVIDER) return "Zen"
28
+ if (pid === GO_PROVIDER) return "Go"
29
+ if (pid === "google") return "Google"
30
+ if (pid === "zai-coding-plan") return "Z.AI"
31
+ if (pid === "huggingface") return "Hugging Face"
32
+ return pid
33
+ }
34
+
35
+ /** Unwrap a beta-API message entry: `{ info: {...} }` -> the inner object. */
36
+ export function unwrap(entry: KitMessageShape): KitMessageShape {
37
+ return entry?.info ?? entry
38
+ }
39
+
40
+ /**
41
+ * True for assistant messages. Beta schema messages discriminate by `type`
42
+ * tags ("assistant", "user", ...), not a `role` field — tolerate both so the
43
+ * filter keeps working if a client exposes `role` instead.
44
+ */
45
+ export function isAssistant(m: KitMessageShape): boolean {
46
+ return (m?.type ?? m?.role) === "assistant"
47
+ }
48
+
49
+ /** Normalize `{ data: [...] }` API responses (or a bare array) to an array. */
50
+ export function asArray<T = any>(out: unknown): T[] {
51
+ return Array.isArray(out) ? (out as T[]) : ((out as any)?.data ?? [])
52
+ }
53
+
54
+ /** Model id from either a model-list object or a message/message-part shape. */
55
+ export function modelId(m: KitMessageShape): string {
56
+ return m?.model?.modelID ?? m?.modelID ?? m?.model?.id ?? m?.id ?? ""
57
+ }
58
+
59
+ /** Provider id from either a model-list object or a message/message-part shape. */
60
+ export function providerId(m: KitMessageShape): string {
61
+ return m?.model?.providerID ?? m?.providerID ?? ""
62
+ }
63
+
64
+ /** Display name, falling back to the model id when the API omits `name`. */
65
+ export function modelName(m: KitMessageShape): string {
66
+ return m?.name ?? modelId(m)
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Dynamic provider discovery
71
+ // ---------------------------------------------------------------------------
72
+
73
+ const AUTH_PATH = () => join(homedir(), ".local/share/opencode/auth.json")
74
+
75
+ /** Single defensive read of auth.json, keyed by provider id.
76
+ * Every consumer shares this parse so the path and shape handling can't drift. */
77
+ export function readAuth(): Record<string, string> {
78
+ try {
79
+ const auth = JSON.parse(readFileSync(AUTH_PATH(), "utf8"))
80
+ const out: Record<string, string> = {}
81
+ for (const [id, cfg] of Object.entries(auth)) {
82
+ const key = (cfg as any)?.key
83
+ if (typeof key === "string" && key.trim()) out[id] = key.trim()
84
+ }
85
+ return out
86
+ } catch {
87
+ return {}
88
+ }
89
+ }
90
+
91
+ /** The API key for one provider, or "" when absent. */
92
+ export function authKey(providerID: string): string {
93
+ return readAuth()[providerID] ?? ""
94
+ }
95
+
96
+ /** Whether the provider has a stored key (fallback when the integration
97
+ * list is unavailable, e.g. before the first client fetch). */
98
+ export function hasKey(providerID: string): boolean {
99
+ return authKey(providerID) !== ""
100
+ }
101
+
102
+ /** Workspace keys in a preferred order, skipping missing ones.
103
+ * `fallbacks` are returned (still filtered) when none of `preferred` exist —
104
+ * used by the usage endpoint, which accepts any workspace key. */
105
+ export function authKeys(preferred: string[], ...fallbacks: string[]): string[] {
106
+ const auth = readAuth()
107
+ const pick = (ids: string[]) => ids.map((id) => auth[id] ?? "").filter(Boolean)
108
+ const keys = pick(preferred)
109
+ return keys.length > 0 ? keys : pick(fallbacks)
110
+ }
111
+
112
+ const FALLBACK_PROVIDERS = [ZEN_PROVIDER, GO_PROVIDER] as const
113
+
114
+ let _providers: string[] | null = null
115
+
116
+ /**
117
+ * All providers that have credentials in this workspace — read from
118
+ * `~/.local/share/opencode/auth.json` (API keys) plus `HF_TOKEN` env
119
+ * (HuggingFace). Result is memoized; at most one file read per process.
120
+ * Falls back to Zen + Go if auth.json is unreadable. This is the sync
121
+ * core of provider discovery; reactive widgets use createConnectedProviders.
122
+ */
123
+ export function availableProviders(): string[] {
124
+ if (_providers) return _providers
125
+
126
+ const ids = Object.keys(readAuth())
127
+
128
+ if (ids.length === 0) ids.push(...FALLBACK_PROVIDERS)
129
+ if (process.env.HF_TOKEN && !ids.includes("huggingface")) {
130
+ ids.push("huggingface")
131
+ }
132
+
133
+ _providers = ids
134
+ return _providers
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Reactive connected-provider tracking
139
+ // ---------------------------------------------------------------------------
140
+
141
+ export interface ConnectedProvidersOptions {
142
+ /** Polling interval in ms. Defaults to 30_000. Set to 0 to disable. */
143
+ readonly pollMs?: number
144
+ }
145
+
146
+ export interface ConnectedProviders {
147
+ /** Reactive set of connected provider ids. */
148
+ readonly ids: () => Set<string>
149
+ /** Whether a specific provider is connected. */
150
+ readonly has: (providerID: string) => boolean
151
+ /** Force an immediate refresh. */
152
+ readonly refresh: () => Promise<void>
153
+ /** Stop polling (call from cleanup). */
154
+ readonly stop: () => void
155
+ }
156
+
157
+ /**
158
+ * Track which providers are connected, reactively — for widgets that gate
159
+ * views on "is this provider connected?". Polls the integration list (the
160
+ * source /connect writes) and unions in env-only providers (HF_TOKEN).
161
+ * When the client is unavailable, falls back to the sync auth.json-based
162
+ * `availableProviders()` discovery.
163
+ */
164
+ export function createConnectedProviders(
165
+ context: KitContext,
166
+ options: ConnectedProvidersOptions = {},
167
+ ): ConnectedProviders {
168
+ const [ids, setIds] = createSignal<Set<string>>(new Set())
169
+
170
+ async function refresh(): Promise<void> {
171
+ try {
172
+ const next = connectedProviderIds(await context.client.integration.list())
173
+ if (process.env.HF_TOKEN) next.add("huggingface")
174
+ setIds(next)
175
+ } catch {
176
+ setIds(new Set(availableProviders()))
177
+ }
178
+ }
179
+
180
+ // Initial refresh.
181
+ void refresh()
182
+
183
+ const pollMs = options.pollMs ?? 30_000
184
+ let timer: ReturnType<typeof setInterval> | null = null
185
+ if (pollMs > 0) {
186
+ timer = setInterval(() => void refresh(), pollMs)
187
+ }
188
+
189
+ return {
190
+ ids,
191
+ has: (providerID: string) => ids().has(providerID),
192
+ refresh,
193
+ stop: () => {
194
+ if (timer !== null) {
195
+ clearInterval(timer)
196
+ timer = null
197
+ }
198
+ },
199
+ }
200
+ }
package/src/rows.ts ADDED
@@ -0,0 +1,22 @@
1
+ // Shared sidebar row builders — pure text formatting, no I/O.
2
+ import { providerLabel } from "./providers.ts"
3
+
4
+ /** Truncate a long id so a sidebar row stays on one line. */
5
+ export function short(id: string, max = 24): string {
6
+ return id.length > max ? id.slice(0, max - 1) + "…" : id
7
+ }
8
+
9
+ /** A padded label/value row with a (provider) tag, e.g. `sess$ gpt-5… (zen) $0.12`.
10
+ * Pass `width` to size the id column for the rows being shown. */
11
+ export function line(label: string, id: string, providerID: string, value?: string, width = 25): string {
12
+ if (!id) return ""
13
+ const v = value ? ` ${value}` : ""
14
+ const tag = `(${providerLabel(providerID)})`
15
+ return `${label.padEnd(6)} ${short(id, width).padEnd(width)} ${tag}${v}`
16
+ }
17
+
18
+ /** Lean inline progress bar: [━━──] — half-height line glyphs keep the row slim. */
19
+ export function bar(percent: number, cells = 10): string {
20
+ const filled = Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
21
+ return `[${"━".repeat(filled)}${"─".repeat(cells - filled)}]`
22
+ }