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
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Zod schemas for the untrusted shapes crossing the OpenCode boundary.
|
|
2
|
+
//
|
|
3
|
+
// These are the shapes that silently change under us: the service's
|
|
4
|
+
// integration list (connected-provider gating) and the usage endpoint's
|
|
5
|
+
// quota windows. Parse, degrade to last-known-good on failure — never cast.
|
|
6
|
+
// See docs/opencode2-api.md for where each shape comes from.
|
|
7
|
+
import { z } from "zod"
|
|
8
|
+
|
|
9
|
+
/** One window of plan quota: server-reported percent + reset time. */
|
|
10
|
+
export const windowSchema = z.object({
|
|
11
|
+
status: z.string().optional(),
|
|
12
|
+
percent: z.number().optional(),
|
|
13
|
+
resetsAt: z.string().optional(),
|
|
14
|
+
})
|
|
15
|
+
export type Window = z.infer<typeof windowSchema>
|
|
16
|
+
|
|
17
|
+
/** The usage endpoint (`https://opencode.ai/zen/go/v1/usage`): workspace
|
|
18
|
+
* plan quota over rolling 5h / weekly / monthly windows. */
|
|
19
|
+
export const usageResponseSchema = z.object({
|
|
20
|
+
usage: z
|
|
21
|
+
.object({
|
|
22
|
+
rolling: windowSchema.optional(),
|
|
23
|
+
weekly: windowSchema.optional(),
|
|
24
|
+
monthly: windowSchema.optional(),
|
|
25
|
+
})
|
|
26
|
+
.optional(),
|
|
27
|
+
})
|
|
28
|
+
export type UsageResponse = z.infer<typeof usageResponseSchema>
|
|
29
|
+
|
|
30
|
+
/** One credential added via /connect (key or OAuth). */
|
|
31
|
+
export const connectionSchema = z.object({
|
|
32
|
+
type: z.string().optional(),
|
|
33
|
+
id: z.string().optional(),
|
|
34
|
+
label: z.string().optional(),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/** An entry of `GET /api/integration` (same source /connect reads). An
|
|
38
|
+
* entry is "connected" iff its `connections` array is non-empty. */
|
|
39
|
+
export const integrationSchema = z.object({
|
|
40
|
+
id: z.string(),
|
|
41
|
+
name: z.string().optional(),
|
|
42
|
+
connections: z.array(connectionSchema).optional(),
|
|
43
|
+
})
|
|
44
|
+
export type Integration = z.infer<typeof integrationSchema>
|
|
45
|
+
|
|
46
|
+
/** Parse a usage-endpoint payload. Returns null instead of throwing when
|
|
47
|
+
* the shape is unrecognized — callers fall back to their cache. */
|
|
48
|
+
export function parseUsage(raw: unknown): UsageResponse | null {
|
|
49
|
+
const result = usageResponseSchema.safeParse(raw)
|
|
50
|
+
return result.success ? result.data : null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Parse the integration list. Accepts both the wrapped `{ data: [...] }`
|
|
54
|
+
* and bare-array responses. */
|
|
55
|
+
export function parseIntegrationList(raw: unknown): Integration[] | null {
|
|
56
|
+
const result = z.union([z.object({ data: z.array(integrationSchema) }), z.array(integrationSchema)]).safeParse(raw)
|
|
57
|
+
if (!result.success) return null
|
|
58
|
+
return "data" in result.data ? result.data.data : result.data
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Provider ids with at least one added connection — the eligibility set
|
|
62
|
+
* for provider-gated views. */
|
|
63
|
+
export function connectedProviderIds(raw: unknown): Set<string> {
|
|
64
|
+
const list = parseIntegrationList(raw)
|
|
65
|
+
const ids = new Set<string>()
|
|
66
|
+
for (const item of list ?? []) {
|
|
67
|
+
if ((item.connections?.length ?? 0) > 0 && item.id) ids.add(item.id)
|
|
68
|
+
}
|
|
69
|
+
return ids
|
|
70
|
+
}
|
package/src/toast.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Fire-and-forget toast helper.
|
|
2
|
+
//
|
|
3
|
+
// The host's toast UI is optional at the edges (beta API): every plugin
|
|
4
|
+
// hand-rolled the same try/catch fallback around `ui.toast.show`.
|
|
5
|
+
import type { KitContext, KitUI, ToastInput } from "./host.ts"
|
|
6
|
+
|
|
7
|
+
/** Show a toast; silently no-ops when the host has no toast UI or it throws.
|
|
8
|
+
* Takes a minimal structural slice — the full host context's `ui.slot` is
|
|
9
|
+
* over-loaded in newer betas and would not assign to KitContext here. */
|
|
10
|
+
export function showToast(
|
|
11
|
+
context: { readonly ui?: { readonly toast?: { readonly show?: (input: ToastInput) => void } } },
|
|
12
|
+
message: string,
|
|
13
|
+
variant: ToastInput["variant"] = "success",
|
|
14
|
+
): void {
|
|
15
|
+
try {
|
|
16
|
+
const toast = (context.ui as Partial<KitUI>).toast
|
|
17
|
+
toast?.show?.({ message, variant })
|
|
18
|
+
} catch {
|
|
19
|
+
// Toast unavailable; the caller's inline UI still reflects the change.
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Persisted view/filter picker for OpenCode sidebar widgets.
|
|
2
|
+
//
|
|
3
|
+
// The pattern shared by the usage-quota tracker (`/usage-view`) and the model
|
|
4
|
+
// recommender (`/model-view`): a registry of views, a durable selection, a
|
|
5
|
+
// slash/palette command registered through a keymap layer (which only works
|
|
6
|
+
// from a rendered `app` slot), a dialog picker, and a confirmation toast.
|
|
7
|
+
import type { KitContext } from "./host.ts"
|
|
8
|
+
import { registerKeymapCommand } from "./commands.ts"
|
|
9
|
+
import { persistedCell } from "./cache.ts"
|
|
10
|
+
import { createSignal } from "solid-js"
|
|
11
|
+
|
|
12
|
+
export interface PickerOption {
|
|
13
|
+
readonly id: string
|
|
14
|
+
readonly title: string
|
|
15
|
+
readonly description?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PickerConfig<T extends PickerOption> {
|
|
19
|
+
/** The view registry — the single extension point. */
|
|
20
|
+
readonly registry: readonly T[]
|
|
21
|
+
/** Durable storage key (plugin-scoped) for the selection. */
|
|
22
|
+
readonly storageKey: string
|
|
23
|
+
/** Slash/palette command wiring. `title` may ignore its argument to show a
|
|
24
|
+
* session-derived effective view (the usage plugin does this). */
|
|
25
|
+
readonly command: {
|
|
26
|
+
readonly id: string
|
|
27
|
+
readonly group: string
|
|
28
|
+
/** Slash name, e.g. "usage-view". */
|
|
29
|
+
readonly name: string
|
|
30
|
+
readonly aliases?: string[]
|
|
31
|
+
readonly title: (current: T) => string
|
|
32
|
+
readonly description: string
|
|
33
|
+
}
|
|
34
|
+
/** Dialog texts for the interactive picker. */
|
|
35
|
+
readonly dialog: { readonly title: string; readonly message: string }
|
|
36
|
+
/** Toast message on switch; defaults to `${prefix}: ${entry.title} view`. */
|
|
37
|
+
readonly toastPrefix?: string
|
|
38
|
+
/** Only these entries are selectable (e.g. provider-key gating). Defaults
|
|
39
|
+
* to everything. Read at pick time, so closures see fresh state. */
|
|
40
|
+
readonly selectable?: (entry: T) => boolean
|
|
41
|
+
/** Message when an explicitly requested entry isn't selectable. */
|
|
42
|
+
readonly unavailableMessage?: (entry: T) => string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ViewPicker<T extends PickerOption> {
|
|
46
|
+
/** Currently selected entry (persisted pick; auto-pick logic stays
|
|
47
|
+
* plugin-local and can layer on top). */
|
|
48
|
+
readonly current: () => T
|
|
49
|
+
readonly currentID: () => string
|
|
50
|
+
/** Switch without UI: persist + toast. No-op when already selected. */
|
|
51
|
+
readonly apply: (entry: T) => void
|
|
52
|
+
/** Open the picker, or select directly from an argument (`/x zen`). */
|
|
53
|
+
readonly pick: (arg?: string) => Promise<void>
|
|
54
|
+
/** Register the slash/palette command (call once from setup). */
|
|
55
|
+
readonly registerCommand: () => void
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createViewPicker<T extends PickerOption>(context: KitContext, config: PickerConfig<T>): ViewPicker<T> {
|
|
59
|
+
const registry = config.registry
|
|
60
|
+
const first = registry[0]
|
|
61
|
+
if (!first) throw new Error("createViewPicker: empty registry")
|
|
62
|
+
|
|
63
|
+
const [currentID, setCurrentID] = createSignal<string>(first.id)
|
|
64
|
+
|
|
65
|
+
type StoredView = { id?: string }
|
|
66
|
+
const cell = persistedCell<StoredView>(context, config.storageKey, { id: first.id })
|
|
67
|
+
|
|
68
|
+
// Load the persisted pick, if any.
|
|
69
|
+
try {
|
|
70
|
+
const persisted = cell.read()
|
|
71
|
+
if (persisted?.id && registry.some((e) => e.id === persisted.id)) setCurrentID(persisted.id)
|
|
72
|
+
} catch {
|
|
73
|
+
// In-memory only.
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const persist = (id: string) => {
|
|
77
|
+
cell.persist((s) => {
|
|
78
|
+
s.id = id
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const current = () => registry.find((e) => e.id === currentID()) ?? first
|
|
83
|
+
|
|
84
|
+
const apply = (entry: T) => {
|
|
85
|
+
if (entry.id === currentID()) return
|
|
86
|
+
setCurrentID(entry.id)
|
|
87
|
+
persist(entry.id)
|
|
88
|
+
try {
|
|
89
|
+
context.ui.toast.show({
|
|
90
|
+
message: config.toastPrefix ? `${config.toastPrefix}: ${entry.title} view` : entry.title,
|
|
91
|
+
variant: "success",
|
|
92
|
+
})
|
|
93
|
+
} catch {
|
|
94
|
+
// Toast unavailable; reactivity still re-renders.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const selectable = config.selectable ?? (() => true)
|
|
99
|
+
|
|
100
|
+
const pick = async (arg?: string) => {
|
|
101
|
+
const wanted = arg?.trim().toLowerCase()
|
|
102
|
+
if (wanted) {
|
|
103
|
+
const match = registry.find((e) => e.id === wanted || e.title.toLowerCase() === wanted)
|
|
104
|
+
if (match) {
|
|
105
|
+
if (selectable(match)) return apply(match)
|
|
106
|
+
try {
|
|
107
|
+
await context.ui.dialog.alert({
|
|
108
|
+
title: config.dialog.title,
|
|
109
|
+
message: config.unavailableMessage?.(match) ?? `${match.title} is not available right now.`,
|
|
110
|
+
})
|
|
111
|
+
} catch {
|
|
112
|
+
// Dialog unavailable.
|
|
113
|
+
}
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
await context.ui.dialog.alert({
|
|
118
|
+
title: config.dialog.title,
|
|
119
|
+
message: `Unknown view "${arg}". Available: ${
|
|
120
|
+
registry
|
|
121
|
+
.filter(selectable)
|
|
122
|
+
.map((e) => e.id)
|
|
123
|
+
.join(", ") || "none"
|
|
124
|
+
}`,
|
|
125
|
+
})
|
|
126
|
+
} catch {
|
|
127
|
+
// Dialog unavailable.
|
|
128
|
+
}
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const selected = await context.ui.dialog.select({
|
|
133
|
+
title: config.dialog.title,
|
|
134
|
+
message: config.dialog.message,
|
|
135
|
+
current: currentID(),
|
|
136
|
+
options: registry.filter(selectable).map((e) => ({
|
|
137
|
+
title: e.title,
|
|
138
|
+
value: e.id,
|
|
139
|
+
description: e.description ?? "",
|
|
140
|
+
disabled: false,
|
|
141
|
+
})),
|
|
142
|
+
})
|
|
143
|
+
const match = registry.find((e) => e.id === selected)
|
|
144
|
+
if (match) apply(match)
|
|
145
|
+
} catch {
|
|
146
|
+
// Dialog unavailable.
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Keymap layers are owned by the calling component, so the command is
|
|
151
|
+
// registered from a rendered `app` slot (see registerKeymapCommand). The
|
|
152
|
+
// factory form keeps the title fresh across palette renders.
|
|
153
|
+
const registerCommand = () =>
|
|
154
|
+
registerKeymapCommand(context, () => ({
|
|
155
|
+
id: config.command.id,
|
|
156
|
+
title: config.command.title(current()),
|
|
157
|
+
description: config.command.description,
|
|
158
|
+
group: config.command.group,
|
|
159
|
+
palette: true,
|
|
160
|
+
slash: {
|
|
161
|
+
name: config.command.name,
|
|
162
|
+
aliases: config.command.aliases,
|
|
163
|
+
arguments: true,
|
|
164
|
+
},
|
|
165
|
+
suggested: true,
|
|
166
|
+
run: (input?: string) => {
|
|
167
|
+
void pick(input)
|
|
168
|
+
},
|
|
169
|
+
}))
|
|
170
|
+
|
|
171
|
+
return { current, currentID, apply, pick, registerCommand }
|
|
172
|
+
}
|
package/src/workspace.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Workspace location resolution shared by the TUI plugins.
|
|
2
|
+
//
|
|
3
|
+
// The client may expose the workspace location directly (`context.location`)
|
|
4
|
+
// or behind `data.location.default()`, and the directory may live on
|
|
5
|
+
// `.directory` or be the location value itself. The plugin-manager and the
|
|
6
|
+
// skill lister each hand-rolled this dance.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the workspace location object for a plugin context. Returns
|
|
10
|
+
* whatever the client exposes — callers that need a path should prefer
|
|
11
|
+
* `workspaceDirectory`.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveLocation(context: any): any {
|
|
14
|
+
return context.location ?? context.data.location.default()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Resolve the workspace directory as a string ("." when nothing resolves). */
|
|
18
|
+
export function workspaceDirectory(context: any): string {
|
|
19
|
+
const location = resolveLocation(context)
|
|
20
|
+
return String(location?.directory ?? location ?? ".")
|
|
21
|
+
}
|