opencode-plugboard 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +458 -39
- package/package.json +36 -9
- package/rpc.ts +3 -0
- package/src/dashboard/app.css +209 -0
- package/src/dashboard/app.js +444 -0
- package/src/dashboard/assets.ts +72 -0
- package/src/dashboard/page.ts +87 -0
- package/src/discovery.ts +8 -2
- package/src/openai/route.ts +11 -2
- package/src/tui-format.ts +217 -0
- package/src/tui.tsx +166 -0
- package/tui.ts +4 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the `/plugboard` usage page (src/tui.tsx): the range presets, the rows and totals
|
|
3
|
+
* of a `UsageReport`, and the number formats. No OpenTUI, Solid or server imports, so `bun test`
|
|
4
|
+
* covers them directly. Types only from src/usage.ts (its runtime pulls in the server's logger).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { UsageErrors, UsageReport, UsageTokens, UsageTotals } from "./usage.js"
|
|
8
|
+
|
|
9
|
+
export type RangePreset = "today" | "week" | "month" | "quarter"
|
|
10
|
+
|
|
11
|
+
/** The page's range keys, in the order the header lists them. `days` counts the UTC days ending today. */
|
|
12
|
+
export const RANGE_PRESETS: ReadonlyArray<{ readonly key: string; readonly preset: RangePreset; readonly label: string; readonly days: number }> = [
|
|
13
|
+
{ key: "t", preset: "today", label: "today", days: 1 },
|
|
14
|
+
{ key: "w", preset: "week", label: "7 days", days: 7 },
|
|
15
|
+
{ key: "m", preset: "month", label: "30 days", days: 30 },
|
|
16
|
+
{ key: "q", preset: "quarter", label: "90 days", days: 90 },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
const DAY_MS = 86_400_000
|
|
20
|
+
|
|
21
|
+
/** The UTC calendar day of an epoch-ms instant, `YYYY-MM-DD`. */
|
|
22
|
+
export function utcDay(at: number): string {
|
|
23
|
+
return new Date(at).toISOString().slice(0, 10)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The inclusive UTC range of a preset ending today, the same window the server's default uses. */
|
|
27
|
+
export function presetRange(preset: RangePreset, at: number): { readonly from: string; readonly to: string } {
|
|
28
|
+
const days = RANGE_PRESETS.find((item) => item.preset === preset)?.days ?? 30
|
|
29
|
+
const to = utcDay(at)
|
|
30
|
+
return { from: utcDay(Date.parse(`${to}T00:00:00Z`) - (days - 1) * DAY_MS), to }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Counts in thousands and millions: 999, 1.2k, 45k, 3.4M. Inputs are whole numbers. */
|
|
34
|
+
export function formatCount(value: number): string {
|
|
35
|
+
for (const [size, suffix] of [
|
|
36
|
+
[1_000_000_000, "B"],
|
|
37
|
+
[1_000_000, "M"],
|
|
38
|
+
[1_000, "k"],
|
|
39
|
+
] as const) {
|
|
40
|
+
if (value >= size) return `${trimmed(value / size)}${suffix}`
|
|
41
|
+
}
|
|
42
|
+
return String(value)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function trimmed(value: number): string {
|
|
46
|
+
return value >= 100 ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/, "")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** USD: `$0.00`, `<$0.01` for a cost below a cent, `$12.34`, and `$1.2k` from a thousand up. */
|
|
50
|
+
export function formatCost(usd: number): string {
|
|
51
|
+
if (usd === 0) return "$0.00"
|
|
52
|
+
if (usd < 0.01) return "<$0.01"
|
|
53
|
+
if (usd < 1_000) return `$${usd.toFixed(2)}`
|
|
54
|
+
return `$${formatCount(usd)}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A latency in ms, or `-` without requests. The percentiles are bucket bounds, so they read as estimates. */
|
|
58
|
+
export function formatMs(ms: number | null): string {
|
|
59
|
+
if (ms === null) return "-"
|
|
60
|
+
if (ms < 1_000) return `${ms} ms`
|
|
61
|
+
return `${Number((ms / 1_000).toFixed(1))} s`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `part` of `whole` as a whole percent, or `-` when `whole` is zero. */
|
|
65
|
+
export function formatPercent(part: number, whole: number): string {
|
|
66
|
+
return whole === 0 ? "-" : `${Math.round((part / whole) * 100)}%`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** An ISO instant as `2026-10-11 14:02 UTC`, or `-` when absent. */
|
|
70
|
+
export function formatInstant(iso: string | undefined): string {
|
|
71
|
+
return iso === undefined ? "-" : `${iso.slice(0, 16).replace("T", " ")} UTC`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A text cell of exactly `width` columns; longer text is cut with an ellipsis. */
|
|
75
|
+
export function cell(text: string, width: number, align: "left" | "right"): string {
|
|
76
|
+
const clipped = text.length > width ? `${text.slice(0, width - 1)}…` : text
|
|
77
|
+
return align === "right" ? clipped.padStart(width) : clipped.padEnd(width)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface Column {
|
|
81
|
+
readonly title: string
|
|
82
|
+
readonly width: number
|
|
83
|
+
readonly align: "left" | "right"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const APP_COLUMNS: ReadonlyArray<Column> = [
|
|
87
|
+
{ title: "app", width: 18, align: "left" },
|
|
88
|
+
{ title: "requests", width: 9, align: "right" },
|
|
89
|
+
{ title: "tokens", width: 8, align: "right" },
|
|
90
|
+
{ title: "cost", width: 9, align: "right" },
|
|
91
|
+
{ title: "errors", width: 7, align: "right" },
|
|
92
|
+
{ title: "p95", width: 8, align: "right" },
|
|
93
|
+
{ title: "last request", width: 17, align: "left" },
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
export const MODEL_COLUMNS: ReadonlyArray<Column> = [
|
|
97
|
+
{ title: "model", width: 36, align: "left" },
|
|
98
|
+
{ title: "requests", width: 9, align: "right" },
|
|
99
|
+
{ title: "tokens", width: 8, align: "right" },
|
|
100
|
+
{ title: "cost", width: 9, align: "right" },
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
/** One line of a table: each value in its column, separated by one space. */
|
|
104
|
+
export function formatLine(values: ReadonlyArray<string>, columns: ReadonlyArray<Column>): string {
|
|
105
|
+
return columns.map((column, index) => cell(values[index] ?? "", column.width, column.align)).join(" ")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A bar of up to `width` blocks for a share in 0..1; any positive share shows at least one block. */
|
|
109
|
+
export function barCells(share: number, width: number): string {
|
|
110
|
+
if (!(share > 0)) return ""
|
|
111
|
+
return "█".repeat(Math.min(width, Math.max(1, Math.round(share * width))))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function tokenTotal(tokens: UsageTokens): number {
|
|
115
|
+
return tokens.input + tokens.cacheRead + tokens.cacheWrite + tokens.output + tokens.reasoning
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function errorTotal(errors: UsageErrors): number {
|
|
119
|
+
return errors.client + errors.server + errors.timeout + errors.disconnected
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface AppRow {
|
|
123
|
+
readonly app: string
|
|
124
|
+
readonly requests: number
|
|
125
|
+
readonly tokens: number
|
|
126
|
+
readonly costUsd: number
|
|
127
|
+
readonly errors: number
|
|
128
|
+
readonly p95Ms: number | null
|
|
129
|
+
readonly lastRequestAt?: string
|
|
130
|
+
/** Share of the range's cost, or of its requests when nothing was priced: 0..1, for the bar. */
|
|
131
|
+
readonly share: number
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** One row per app, most expensive first (then most requests, then name). */
|
|
135
|
+
export function appRows(report: UsageReport): AppRow[] {
|
|
136
|
+
const { totals } = report
|
|
137
|
+
return Object.entries(report.apps)
|
|
138
|
+
.map(([app, entry]): AppRow => {
|
|
139
|
+
const share =
|
|
140
|
+
totals.costUsd > 0 ? entry.totals.costUsd / totals.costUsd : totals.requests > 0 ? entry.totals.requests / totals.requests : 0
|
|
141
|
+
return {
|
|
142
|
+
app,
|
|
143
|
+
requests: entry.totals.requests,
|
|
144
|
+
tokens: tokenTotal(entry.totals.tokens),
|
|
145
|
+
costUsd: entry.totals.costUsd,
|
|
146
|
+
errors: errorTotal(entry.totals.errors),
|
|
147
|
+
p95Ms: entry.totals.latency.p95Ms,
|
|
148
|
+
...(entry.totals.lastRequestAt === undefined ? {} : { lastRequestAt: entry.totals.lastRequestAt }),
|
|
149
|
+
share,
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
.sort((a, b) => b.costUsd - a.costUsd || b.requests - a.requests || a.app.localeCompare(b.app))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function appValues(row: AppRow): string[] {
|
|
156
|
+
return [
|
|
157
|
+
row.app,
|
|
158
|
+
formatCount(row.requests),
|
|
159
|
+
formatCount(row.tokens),
|
|
160
|
+
formatCost(row.costUsd),
|
|
161
|
+
formatCount(row.errors),
|
|
162
|
+
formatMs(row.p95Ms),
|
|
163
|
+
formatInstant(row.lastRequestAt),
|
|
164
|
+
]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface ModelRow {
|
|
168
|
+
readonly model: string
|
|
169
|
+
readonly requests: number
|
|
170
|
+
readonly tokens: number
|
|
171
|
+
readonly costUsd: number
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The models of every app, summed over the range: most expensive first, at most `limit`. */
|
|
175
|
+
export function modelRows(report: UsageReport, limit: number): ModelRow[] {
|
|
176
|
+
const sums = new Map<string, { requests: number; tokens: number; costUsd: number }>()
|
|
177
|
+
for (const app of Object.values(report.apps)) {
|
|
178
|
+
for (const [model, usage] of Object.entries(app.models)) {
|
|
179
|
+
const sum = sums.get(model) ?? { requests: 0, tokens: 0, costUsd: 0 }
|
|
180
|
+
sums.set(model, { requests: sum.requests + usage.requests, tokens: sum.tokens + usage.tokens, costUsd: sum.costUsd + usage.costUsd })
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return [...sums]
|
|
184
|
+
.map(([model, sum]): ModelRow => ({ model, ...sum }))
|
|
185
|
+
.sort((a, b) => b.costUsd - a.costUsd || b.requests - a.requests || a.model.localeCompare(b.model))
|
|
186
|
+
.slice(0, limit)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function modelValues(row: ModelRow): string[] {
|
|
190
|
+
return [row.model, formatCount(row.requests), formatCount(row.tokens), formatCost(row.costUsd)]
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface TotalItem {
|
|
194
|
+
readonly label: string
|
|
195
|
+
readonly value: string
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The totals line, in the order the page shows it. */
|
|
199
|
+
export function totalItems(totals: UsageTotals): TotalItem[] {
|
|
200
|
+
const lookups = totals.cacheHits + totals.cacheMisses
|
|
201
|
+
return [
|
|
202
|
+
{ label: "requests", value: formatCount(totals.requests) },
|
|
203
|
+
{ label: "tokens", value: formatCount(tokenTotal(totals.tokens)) },
|
|
204
|
+
{ label: "est. cost", value: formatCost(totals.costUsd) },
|
|
205
|
+
{ label: "errors", value: formatCount(errorTotal(totals.errors)) },
|
|
206
|
+
{ label: "cache hit", value: formatPercent(totals.cacheHits, lookups) },
|
|
207
|
+
{ label: "fallbacks", value: formatCount(totals.fallbacks) },
|
|
208
|
+
{ label: "429s", value: formatCount(totals.limited) },
|
|
209
|
+
{ label: "p95", value: formatMs(totals.latency.p95Ms) },
|
|
210
|
+
]
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The message the page shows when the usage RPC cannot answer (plugboard not loaded there, or usage off). */
|
|
214
|
+
export function unavailableMessage(error: unknown): string {
|
|
215
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
216
|
+
return `Usage is unavailable on this server. Plugboard must be loaded there with usage enabled. (${reason})`
|
|
217
|
+
}
|
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
/**
|
|
3
|
+
* The `/plugboard` usage page in OpenCode's terminal UI (package entry `./tui`). OpenCode loads it
|
|
4
|
+
* for every server that runs plugboard with the TUI feature: it reads the usage report over the
|
|
5
|
+
* server's own RPC (`context.client.rpc(UsageRpc).get`, src/rpc.ts), so it sees every app there.
|
|
6
|
+
* Nothing is served over HTTP, and nothing here runs on the server.
|
|
7
|
+
*
|
|
8
|
+
* Page keys: t, w, m, q pick the range (today, 7, 30, 90 UTC days); r reloads; esc goes back.
|
|
9
|
+
* Setup only registers the page and the command (no awaits). Shaping and formats: src/tui-format.ts.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Plugin } from "@opencode/plugin/tui"
|
|
13
|
+
import { TextAttributes } from "@opentui/core"
|
|
14
|
+
import { createResource, createSignal, For, Show } from "solid-js"
|
|
15
|
+
import { UsageRpc } from "./rpc.js"
|
|
16
|
+
import type { UsageReport } from "./usage.js"
|
|
17
|
+
import {
|
|
18
|
+
APP_COLUMNS,
|
|
19
|
+
appRows,
|
|
20
|
+
appValues,
|
|
21
|
+
barCells,
|
|
22
|
+
formatLine,
|
|
23
|
+
MODEL_COLUMNS,
|
|
24
|
+
modelRows,
|
|
25
|
+
modelValues,
|
|
26
|
+
presetRange,
|
|
27
|
+
RANGE_PRESETS,
|
|
28
|
+
totalItems,
|
|
29
|
+
unavailableMessage,
|
|
30
|
+
type RangePreset,
|
|
31
|
+
} from "./tui-format.js"
|
|
32
|
+
|
|
33
|
+
type Theme = Plugin.Context["theme"]
|
|
34
|
+
|
|
35
|
+
interface Query {
|
|
36
|
+
readonly preset: RangePreset
|
|
37
|
+
/** Bumped by `r`; only here so that a reload is a new query. */
|
|
38
|
+
readonly reload: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const PRESET_LABEL = (preset: RangePreset) => RANGE_PRESETS.find((item) => item.preset === preset)?.label ?? preset
|
|
42
|
+
|
|
43
|
+
function UsagePage(props: { context: Plugin.Context; onClose: () => void }) {
|
|
44
|
+
const theme = props.context.theme
|
|
45
|
+
const [query, setQuery] = createSignal<Query>({ preset: "month", reload: 0 })
|
|
46
|
+
const [report] = createResource(query, async (current) => {
|
|
47
|
+
const range = presetRange(current.preset, Date.now())
|
|
48
|
+
try {
|
|
49
|
+
return (await props.context.client.rpc(UsageRpc).get({ from: range.from, to: range.to })) as UsageReport
|
|
50
|
+
} catch (error) {
|
|
51
|
+
props.context.ui.toast.show({ variant: "error", title: "Plugboard", message: unavailableMessage(error) })
|
|
52
|
+
throw error
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
props.context.keymap.layer(() => ({
|
|
57
|
+
commands: [
|
|
58
|
+
{ bind: "escape", title: "back", run: props.onClose },
|
|
59
|
+
...RANGE_PRESETS.map((item) => ({
|
|
60
|
+
bind: item.key,
|
|
61
|
+
title: `range ${item.label}`,
|
|
62
|
+
run: () => setQuery((current) => ({ preset: item.preset, reload: current.reload })),
|
|
63
|
+
})),
|
|
64
|
+
{ bind: "r", title: "refresh", run: () => setQuery((current) => ({ ...current, reload: current.reload + 1 })) },
|
|
65
|
+
],
|
|
66
|
+
}))
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<box width="100%" height="100%" backgroundColor={theme.background.base}>
|
|
70
|
+
<scrollbox flexGrow={1}>
|
|
71
|
+
<box flexDirection="column" gap={1} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
|
72
|
+
<text fg={theme.text.base} attributes={TextAttributes.BOLD}>
|
|
73
|
+
Plugboard usage
|
|
74
|
+
</text>
|
|
75
|
+
<text fg={theme.text.muted}>
|
|
76
|
+
{report() ? `${report()!.from} to ${report()!.to} (UTC)` : PRESET_LABEL(query().preset)}
|
|
77
|
+
{" · "}t today · w 7 days · m 30 days · q 90 days · r refresh · esc back
|
|
78
|
+
</text>
|
|
79
|
+
<Show
|
|
80
|
+
when={!report.error}
|
|
81
|
+
fallback={<text fg={theme.text.feedback.error.base}>{unavailableMessage(report.error)}</text>}
|
|
82
|
+
>
|
|
83
|
+
<Show when={report()} fallback={<text fg={theme.text.muted}>Loading usage…</text>}>
|
|
84
|
+
<UsageBody report={report()!} theme={theme} />
|
|
85
|
+
</Show>
|
|
86
|
+
</Show>
|
|
87
|
+
</box>
|
|
88
|
+
</scrollbox>
|
|
89
|
+
</box>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function UsageBody(props: { report: UsageReport; theme: Theme }) {
|
|
94
|
+
const apps = () => appRows(props.report)
|
|
95
|
+
const models = () => modelRows(props.report, 10)
|
|
96
|
+
return (
|
|
97
|
+
<box flexDirection="column" gap={1}>
|
|
98
|
+
<box flexDirection="row" flexWrap="wrap" gap={2}>
|
|
99
|
+
<For each={totalItems(props.report.totals)}>
|
|
100
|
+
{(item) => (
|
|
101
|
+
<box flexDirection="row" gap={1}>
|
|
102
|
+
<text fg={props.theme.text.muted}>{item.label}</text>
|
|
103
|
+
<text fg={props.theme.text.base} attributes={TextAttributes.BOLD}>
|
|
104
|
+
{item.value}
|
|
105
|
+
</text>
|
|
106
|
+
</box>
|
|
107
|
+
)}
|
|
108
|
+
</For>
|
|
109
|
+
</box>
|
|
110
|
+
<text fg={props.theme.text.muted} attributes={TextAttributes.BOLD}>
|
|
111
|
+
Apps
|
|
112
|
+
</text>
|
|
113
|
+
<Show when={apps().length > 0} fallback={<text fg={props.theme.text.muted}>No requests in this range.</text>}>
|
|
114
|
+
<text fg={props.theme.text.muted}>{formatLine(APP_COLUMNS.map((column) => column.title), APP_COLUMNS)}</text>
|
|
115
|
+
<For each={apps()}>
|
|
116
|
+
{(row) => (
|
|
117
|
+
<box flexDirection="row">
|
|
118
|
+
<text fg={props.theme.text.base}>{formatLine(appValues(row), APP_COLUMNS)}</text>
|
|
119
|
+
<text fg={props.theme.categorical[0][200]}>{` ${barCells(row.share, 24)}`}</text>
|
|
120
|
+
</box>
|
|
121
|
+
)}
|
|
122
|
+
</For>
|
|
123
|
+
</Show>
|
|
124
|
+
<text fg={props.theme.text.muted} attributes={TextAttributes.BOLD}>
|
|
125
|
+
Models
|
|
126
|
+
</text>
|
|
127
|
+
<Show when={models().length > 0} fallback={<text fg={props.theme.text.muted}>No model calls in this range.</text>}>
|
|
128
|
+
<text fg={props.theme.text.muted}>{formatLine(MODEL_COLUMNS.map((column) => column.title), MODEL_COLUMNS)}</text>
|
|
129
|
+
<For each={models()}>
|
|
130
|
+
{(row) => <text fg={props.theme.text.base}>{formatLine(modelValues(row), MODEL_COLUMNS)}</text>}
|
|
131
|
+
</For>
|
|
132
|
+
</Show>
|
|
133
|
+
</box>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export default Plugin.define({
|
|
138
|
+
id: "opencode-plugboard.tui",
|
|
139
|
+
setup(context) {
|
|
140
|
+
const [previous, setPrevious] = createSignal({ ...context.ui.router.current() })
|
|
141
|
+
context.ui.router.register({
|
|
142
|
+
name: "plugboard.usage",
|
|
143
|
+
render: () => <UsagePage context={context} onClose={() => context.ui.router.navigate(previous())} />,
|
|
144
|
+
})
|
|
145
|
+
context.keymap.layer(() => ({
|
|
146
|
+
mode: "global",
|
|
147
|
+
commands: [
|
|
148
|
+
{
|
|
149
|
+
id: "plugboard.usage.open",
|
|
150
|
+
title: "Plugboard usage",
|
|
151
|
+
group: "Plugboard",
|
|
152
|
+
slash: { name: "plugboard" },
|
|
153
|
+
palette: true,
|
|
154
|
+
run() {
|
|
155
|
+
const current = context.ui.router.current()
|
|
156
|
+
if (current.type === "plugin" && current.name === "plugboard.usage") return
|
|
157
|
+
// The router exposes a mutable store; keep the route before navigating.
|
|
158
|
+
setPrevious({ ...current })
|
|
159
|
+
context.ui.dialog.clear()
|
|
160
|
+
context.ui.router.navigate({ type: "plugin", name: "plugboard.usage" })
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
}))
|
|
165
|
+
},
|
|
166
|
+
})
|
package/tui.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// The `./tui` entrypoint. OpenCode resolves a package's TUI entry through `exports["./tui"]`, but a
|
|
2
|
+
// plugin configured by local path is resolved as `<directory>/tui` with no `exports` lookup
|
|
3
|
+
// (packages/plugin/src/host.ts in OpenCode 2.0.26), so the entry lives at the package root.
|
|
4
|
+
export { default } from "./src/tui.tsx"
|