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.
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/package.json +56 -0
- package/src/cache.ts +95 -0
- package/src/cachedResource.ts +58 -0
- package/src/collapsible.tsx +86 -0
- package/src/commands.ts +108 -0
- package/src/currentModel.ts +32 -0
- package/src/format.ts +25 -0
- package/src/host.ts +148 -0
- package/src/index.ts +15 -0
- package/src/messages.ts +118 -0
- package/src/pollingFetcher.ts +81 -0
- package/src/providers.ts +200 -0
- package/src/rows.ts +22 -0
- package/src/schemas.ts +70 -0
- package/src/toast.ts +21 -0
- package/src/viewPicker.ts +172 -0
- package/src/workspace.ts +21 -0
|
@@ -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"
|
package/src/messages.ts
ADDED
|
@@ -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
|
+
}
|
package/src/providers.ts
ADDED
|
@@ -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
|
+
}
|