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.
@@ -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"