opencode-usage-report 0.5.0 → 0.6.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/README.md CHANGED
@@ -71,11 +71,14 @@ Or add the plugin manually to each file and restart opencode:
71
71
  > `opencode plugin opencode-usage-report` prints `Detected server + tui targets`
72
72
  > when the resolution works.
73
73
 
74
- > **OpenCode 2.x (0.5.0).** The server entrypoint default-exports the V2
75
- > `{ id, setup }` definition and still carries the V1 `server` hook, so
76
- > OpenCode 1.18.29+ and 2.x both load it. Before 0.5.0 the module exported a
77
- > bare V1 function, which OpenCode 2 rejects. The TUI entrypoint is unchanged
78
- > in this release.
74
+ > **OpenCode 2.x (0.6.0).** Both entrypoints now default-export a dual
75
+ > definition. The server entrypoint exports `{ id, setup, server }` — V2 calls
76
+ > `setup`, V1 calls `server`. The TUI entrypoint exports `{ id, setup, tui }` —
77
+ > V2 calls `setup` (its `keymap.layer` runs from a headless `app`-slot
78
+ > component), V1 calls `tui`. OpenCode 2's TUI loader rejects a default export
79
+ > without a `setup` function (`Invalid V2 TUI plugin module`); before 0.6.0 the
80
+ > TUI module exported only the V1 `tui` shape and failed to load on 2.x.
81
+ > OpenCode 1.18.29+ and 2.x both load either entrypoint.
79
82
 
80
83
  Options can be passed in the tuple form, independently per entrypoint:
81
84
 
@@ -299,6 +302,12 @@ so a failing check blocks the stage.
299
302
 
300
303
  ## Changelog
301
304
 
305
+ - **0.6.1** — Kimi Code adapter tracks the 2026-09 quota-model API change:
306
+ usage windows now come from the `usages.limit_5h` / `limit_7d` /
307
+ `limit_month_total` ratios (with a `limit_month_code` breakdown), and Extra
308
+ Usage is read from the camelCase `boosterWallet` (fixed-point `BOOSTER`
309
+ balance). The pre-2026-09 `usage` / `limits[]` / `totalQuota` /
310
+ `booster_wallet` shapes remain as fallbacks.
302
311
  - **0.4.1** — internal: shared provider HTTP plumbing (`src/providers/http.ts`)
303
312
  replaces the copy-pasted fetch/retry/error code; context estimates simplified;
304
313
  reset times render as `Wed, Sep 16`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-report",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "opencode plugin: /usage command showing subscription quota windows for configured providers",
5
5
  "keywords": [
6
6
  "kimi",
@@ -65,12 +65,17 @@ function findLimitDetail(limits: unknown, durationMinutes: number): unknown {
65
65
  return undefined;
66
66
  }
67
67
 
68
- function ratioWindow(kind: UsageWindow["kind"], label: string, value: unknown): UsageWindow {
68
+ /** 0-100 percent from a quota entry's `used_ratio`, or null when it is absent/invalid. */
69
+ function ratioPercent(value: unknown): number | null {
69
70
  const ratio = toNumber(pick(value, "used_ratio", "usedRatio"));
71
+ return ratio === null ? null : ratioToPercent(ratio);
72
+ }
73
+
74
+ function ratioWindow(kind: UsageWindow["kind"], label: string, value: unknown): UsageWindow {
70
75
  return {
71
76
  kind,
72
77
  label,
73
- usedPercent: ratio !== null ? ratioToPercent(ratio) : null,
78
+ usedPercent: ratioPercent(value),
74
79
  used: null,
75
80
  limit: null,
76
81
  remaining: null,
@@ -88,6 +93,64 @@ function detailWindow(kind: UsageWindow["kind"], label: string, detail: unknown)
88
93
  });
89
94
  }
90
95
 
96
+ /**
97
+ * Booster-wallet amounts are fixed-point integers in micro-cents
98
+ * (1_000_000 → 1 cent), matching the official kimi-code parser.
99
+ */
100
+ const FIXED_POINT_CENTS = 1_000_000;
101
+
102
+ function fixedPointToCents(value: unknown): number | null {
103
+ const raw = toNumber(value);
104
+ if (raw === null) return null;
105
+ const cents = raw / FIXED_POINT_CENTS;
106
+ if (cents > 0 && cents < 1) return 1;
107
+ return Math.round(cents);
108
+ }
109
+
110
+ function formatMoney(cents: number, currency: string): string {
111
+ return `$${(cents / 100).toFixed(2)} ${currency}`;
112
+ }
113
+
114
+ /** Currency for the wallet, taken from whichever money field carries one. */
115
+ function walletCurrency(wallet: unknown): string {
116
+ for (const key of ["monthlyChargeLimit", "monthlyUsed"]) {
117
+ const currency = pick(pick(wallet, key), "currency");
118
+ if (typeof currency === "string" && currency !== "") return currency;
119
+ }
120
+ return "USD";
121
+ }
122
+
123
+ /**
124
+ * Renders the Extra Usage ("booster") wallet into `extras`. The post-2026-09
125
+ * quota model sends `boosterWallet.balance` as a `BOOSTER` descriptor with
126
+ * fixed-point micro-cents; the pre-2026-09 wire sent a scalar `booster_wallet.balance`.
127
+ */
128
+ function parseBoosterWallet(body: unknown, extras: Record<string, string>): void {
129
+ const wallet = pick(body, "boosterWallet", "booster_wallet");
130
+ if (wallet === undefined) return;
131
+
132
+ const balance = pick(wallet, "balance");
133
+ const totalCents =
134
+ pick(balance, "type") === "BOOSTER" ? fixedPointToCents(pick(balance, "amount")) : null;
135
+ if (totalCents !== null && totalCents > 0) {
136
+ const left = fixedPointToCents(pick(balance, "amountLeft")) ?? 0;
137
+ extras["Booster wallet"] = formatMoney(left, walletCurrency(wallet));
138
+ } else if (typeof balance === "string" || typeof balance === "number") {
139
+ extras["Booster wallet"] = String(balance);
140
+ }
141
+
142
+ const monthlyUsed = pick(wallet, "monthlyUsed", "monthly_used");
143
+ const currency = pick(monthlyUsed, "currency");
144
+ const priceInCents = toNumber(pick(monthlyUsed, "priceInCents", "price_in_cents"));
145
+ if (
146
+ (typeof currency === "string" || typeof currency === "number") &&
147
+ String(currency) !== "" &&
148
+ priceInCents !== null
149
+ ) {
150
+ extras["Booster used this month"] = formatMoney(priceInCents, String(currency));
151
+ }
152
+ }
153
+
91
154
  function parse(body: Record<string, unknown>, cred: Credential): AdapterResult {
92
155
  const windows: UsageWindow[] = [];
93
156
  const extras: Record<string, string> = {};
@@ -96,9 +159,9 @@ function parse(body: Record<string, unknown>, cred: Credential): AdapterResult {
96
159
  const usage = pick(body, "usage");
97
160
  const limits = pick(body, "limits");
98
161
 
99
- // Count-based windows are authoritative; the `usages.limit_5h/limit_7d`
100
- // ratios are legacy fields the live API returns as stale zeros even while
101
- // real usage accrues, so they are only a fallback when counts are missing.
162
+ // Count-based windows are authoritative when present; the `usages.limit_*`
163
+ // ratios are the primary wire on the post-2026-09 quota model, where the
164
+ // absolute `usage`/`limits[]` rows were removed.
102
165
  const detail5h = findLimitDetail(limits, 300);
103
166
  if (detail5h !== undefined) {
104
167
  windows.push(detailWindow("5h", "5-hour", detail5h));
@@ -114,43 +177,36 @@ function parse(body: Record<string, unknown>, cred: Credential): AdapterResult {
114
177
  if (limit7d !== undefined) windows.push(ratioWindow("weekly", "Weekly", limit7d));
115
178
  }
116
179
 
180
+ // The monthly total joined the quota model; `totalQuota` is the legacy shape.
181
+ // `limit_month_code` is the code-typed share the official client shows as a
182
+ // breakdown under the monthly row.
183
+ const monthTotal = pick(usages, "limit_month_total");
184
+ if (monthTotal !== undefined) {
185
+ windows.push(ratioWindow("monthly", "Monthly", monthTotal));
186
+ const codePercent = ratioPercent(pick(usages, "limit_month_code"));
187
+ if (codePercent !== null) extras["Monthly code"] = `${codePercent}%`;
188
+ } else {
189
+ const totalQuota = pick(body, "totalQuota", "total_quota");
190
+ const quotaUsed = toNumber(pick(totalQuota, "used"));
191
+ if (totalQuota !== undefined && quotaUsed !== null && quotaUsed > 0) {
192
+ windows.push({
193
+ kind: "monthly",
194
+ label: "Monthly",
195
+ usedPercent: null,
196
+ used: quotaUsed,
197
+ limit: toNumber(pick(totalQuota, "limit")),
198
+ remaining: toNumber(pick(totalQuota, "remaining")),
199
+ resetsAt: toISODate(pick(totalQuota, "resetTime", "reset_time", "resetAt", "resetsAt")),
200
+ status: "frozen",
201
+ });
202
+ }
203
+ }
204
+
117
205
  const level = pick(pick(body, "user"), "membership");
118
206
  const membershipLevel = pick(level, "level");
119
207
  if (membershipLevel !== undefined) extras["Plan"] = String(membershipLevel);
120
208
 
121
- const wallet = pick(body, "booster_wallet");
122
-
123
- // `balance` is a wallet descriptor object on the live API; only render scalar values.
124
- const balance = pick(wallet, "balance");
125
- if (typeof balance === "string" || typeof balance === "number") {
126
- extras["Booster wallet"] = String(balance);
127
- }
128
-
129
- const monthlyUsed = pick(wallet, "monthlyUsed", "monthly_used");
130
- const currency = pick(monthlyUsed, "currency");
131
- const priceInCents = toNumber(pick(monthlyUsed, "priceInCents", "price_in_cents"));
132
- if (
133
- (typeof currency === "string" || typeof currency === "number") &&
134
- String(currency) !== "" &&
135
- priceInCents !== null
136
- ) {
137
- extras["Booster used this month"] = `$${(priceInCents / 100).toFixed(2)} ${String(currency)}`;
138
- }
139
-
140
- const totalQuota = pick(body, "totalQuota", "total_quota");
141
- const quotaUsed = toNumber(pick(totalQuota, "used"));
142
- if (totalQuota !== undefined && quotaUsed !== null && quotaUsed > 0) {
143
- windows.push({
144
- kind: "monthly",
145
- label: "Monthly",
146
- usedPercent: null,
147
- used: quotaUsed,
148
- limit: toNumber(pick(totalQuota, "limit")),
149
- remaining: toNumber(pick(totalQuota, "remaining")),
150
- resetsAt: toISODate(pick(totalQuota, "resetTime", "reset_time", "resetAt", "resetsAt")),
151
- status: "frozen",
152
- });
153
- }
209
+ parseBoosterWallet(body, extras);
154
210
 
155
211
  if (windows.length === 0) {
156
212
  throw new AdapterError(
package/src/tui.tsx CHANGED
@@ -1,18 +1,16 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- import type {
3
- TuiPlugin,
4
- TuiPluginApi,
5
- TuiPluginModule,
6
- TuiSlotPlugin,
7
- } from "@opencode-ai/plugin/tui";
2
+ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui";
8
3
  import { For, createSignal } from "solid-js";
9
4
  import { DEFAULT_OPTIONS, collectReports } from "./report";
10
- import { coerceTuiOptions } from "./tui-format";
5
+ import { coerceTuiOptions, type ResolvedTuiOptions } from "./tui-format";
11
6
  import { buildLines } from "./quota-lines";
12
7
  import { UsageDialog } from "./context/dialog";
13
8
  import { readCapture } from "./context/system";
14
9
  import type { SystemCapture } from "./context/types";
15
10
  import type { ProviderReport } from "./types";
11
+ import { asV1Theme, createV2Api, v2ThemeColors, type V2Context } from "./v2-tui";
12
+
13
+ type Theme = TuiPluginApi["theme"]["current"];
16
14
 
17
15
  /** Resolves the session the `/usage` dialog should describe, if any. */
18
16
  function activeSessionID(api: TuiPluginApi): string | null {
@@ -22,9 +20,11 @@ function activeSessionID(api: TuiPluginApi): string | null {
22
20
  return typeof id === "string" ? id : null;
23
21
  }
24
22
 
25
- export const tui: TuiPlugin = async (api, rawOptions) => {
26
- const options = coerceTuiOptions(rawOptions);
27
-
23
+ /**
24
+ * Shared panel state: the report cache, the refresh timer and the system-prompt
25
+ * capture. Built once per plugin load and driven by both the V1 and V2 halves.
26
+ */
27
+ function createUsagePanel(getSessionID: () => string | null, options: ResolvedTuiOptions) {
28
28
  const [reports, setReports] = createSignal<ProviderReport[]>([]);
29
29
  const [inFlight, setInFlight] = createSignal(0);
30
30
  const [error, setError] = createSignal<string | null>(null);
@@ -32,10 +32,8 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
32
32
  const [capture, setCapture] = createSignal<SystemCapture | null>(null);
33
33
  const [scope, setScope] = createSignal(0);
34
34
  const [showJson, setShowJson] = createSignal(false);
35
-
36
- const busy = (): boolean => inFlight() > 0;
37
-
38
35
  let disposed = false;
36
+
39
37
  const load = async (refresh = false): Promise<void> => {
40
38
  if (disposed) return;
41
39
  setInFlight((n) => n + 1);
@@ -60,10 +58,9 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
60
58
  }
61
59
  };
62
60
 
63
- // Single timer: refreshes data, advances countdowns, reloads the capture.
64
61
  const refreshCapture = async (): Promise<void> => {
65
62
  if (disposed) return;
66
- const sessionID = activeSessionID(api);
63
+ const sessionID = getSessionID();
67
64
  if (sessionID === null) {
68
65
  setCapture(null);
69
66
  return;
@@ -71,39 +68,108 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
71
68
  setCapture(await readCapture(sessionID));
72
69
  };
73
70
 
74
- const intervalMs = Math.max(1, Math.floor(options.refreshIntervalSeconds)) * 1000;
75
71
  const refresh = (): void => {
76
72
  setTick(Date.now());
77
73
  void load();
78
74
  void refreshCapture();
79
75
  };
80
- const timer = setInterval(refresh, intervalMs);
81
76
 
82
- api.lifecycle.onDispose(() => {
83
- disposed = true;
84
- clearInterval(timer);
85
- });
77
+ const timer = setInterval(
78
+ refresh,
79
+ Math.max(1, Math.floor(options.refreshIntervalSeconds)) * 1000,
80
+ );
81
+
82
+ return {
83
+ reports,
84
+ error,
85
+ tick,
86
+ capture,
87
+ scope,
88
+ showJson,
89
+ setScope,
90
+ setShowJson,
91
+ load,
92
+ refreshCapture,
93
+ refresh,
94
+ busy: (): boolean => inFlight() > 0,
95
+ isDisposed: (): boolean => disposed,
96
+ dispose: (): void => {
97
+ disposed = true;
98
+ clearInterval(timer);
99
+ },
100
+ };
101
+ }
86
102
 
87
- const offIdle = api.event.on("session.idle", refresh);
88
- api.lifecycle.onDispose(offIdle);
103
+ type UsagePanel = ReturnType<typeof createUsagePanel>;
104
+
105
+ /** The sidebar quota panel; shared verbatim by the V1 and V2 halves. */
106
+ function UsageSidebar(props: {
107
+ theme: () => Theme;
108
+ reports: () => ProviderReport[];
109
+ busy: () => boolean;
110
+ error: () => string | null;
111
+ tick: () => number;
112
+ barWidth: number;
113
+ }) {
114
+ return (
115
+ <box flexDirection="column">
116
+ <text fg={props.theme().text}>
117
+ <b>Usage</b>
118
+ </text>
119
+ <For
120
+ each={buildLines(
121
+ props.theme(),
122
+ props.reports(),
123
+ props.busy(),
124
+ props.error(),
125
+ new Date(props.tick()),
126
+ props.barWidth,
127
+ )}
128
+ >
129
+ {(line) => (
130
+ <text>
131
+ {line.segments.map((seg) =>
132
+ seg.bold ? (
133
+ <b style={{ fg: seg.fg }}>{seg.text}</b>
134
+ ) : (
135
+ <span style={{ fg: seg.fg }}>{seg.text}</span>
136
+ ),
137
+ )}
138
+ </text>
139
+ )}
140
+ </For>
141
+ </box>
142
+ );
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // OpenCode 1.x (V1) TUI plugin API.
147
+ // ---------------------------------------------------------------------------
148
+
149
+ export const tui: TuiPlugin = async (api, rawOptions) => {
150
+ const options = coerceTuiOptions(rawOptions);
151
+ const panel = createUsagePanel(() => activeSessionID(api), options);
152
+
153
+ api.lifecycle.onDispose(panel.dispose);
154
+ api.lifecycle.onDispose(api.event.on("session.idle", panel.refresh));
89
155
 
90
156
  const openDialog = (): void => {
91
157
  const sessionID = activeSessionID(api);
92
- void refreshCapture().finally(() => {
93
- if (disposed) return;
94
- setScope(0);
95
- setShowJson(false);
158
+ void panel.refreshCapture().finally(() => {
159
+ if (panel.isDisposed()) return;
160
+ panel.setScope(0);
161
+ panel.setShowJson(false);
96
162
  api.ui.dialog.replace(() => (
97
163
  <UsageDialog
98
164
  api={api}
99
165
  sessionID={sessionID}
100
- reports={reports}
101
- busy={busy}
102
- error={error}
103
- now={() => new Date(tick())}
104
- system={capture}
105
- scope={scope}
106
- showJson={showJson}
166
+ reports={panel.reports}
167
+ busy={panel.busy}
168
+ error={panel.error}
169
+ now={() => new Date(panel.tick())}
170
+ system={panel.capture}
171
+ scope={panel.scope}
172
+ showJson={panel.showJson}
107
173
  barWidth={options.barWidth}
108
174
  />
109
175
  ));
@@ -121,7 +187,7 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
121
187
  category: "Plugin",
122
188
  namespace: "palette",
123
189
  run: () => {
124
- void load(true);
190
+ void panel.load(true);
125
191
  },
126
192
  },
127
193
  {
@@ -143,17 +209,17 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
143
209
  api.keymap.registerLayer({
144
210
  mode: "modal",
145
211
  commands: [
146
- { name: "usage.dialog.refresh", run: () => void load(true) },
212
+ { name: "usage.dialog.refresh", run: () => void panel.load(true) },
147
213
  {
148
214
  name: "usage.dialog.scope",
149
215
  run: () => {
150
- setScope((n) => (n + 1) % Math.max(1, reports().length + 1));
216
+ panel.setScope((n) => (n + 1) % Math.max(1, panel.reports().length + 1));
151
217
  },
152
218
  },
153
219
  {
154
220
  name: "usage.dialog.json",
155
221
  run: () => {
156
- setShowJson((v) => !v);
222
+ panel.setShowJson((v) => !v);
157
223
  },
158
224
  },
159
225
  ],
@@ -165,48 +231,157 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
165
231
  }),
166
232
  );
167
233
 
168
- const slot: TuiSlotPlugin = {
234
+ api.slots.register({
169
235
  order: 150,
170
236
  slots: {
171
237
  sidebar_content(ctx) {
172
- const theme = ctx.theme.current;
173
238
  return (
174
- <box flexDirection="column">
175
- <text fg={theme.text}>
176
- <b>Usage</b>
177
- </text>
178
- <For
179
- each={buildLines(
180
- theme,
181
- reports(),
182
- busy(),
183
- error(),
184
- new Date(tick()),
185
- options.barWidth,
186
- )}
187
- >
188
- {(line) => (
189
- <text>
190
- {line.segments.map((seg) =>
191
- seg.bold ? (
192
- <b style={{ fg: seg.fg }}>{seg.text}</b>
193
- ) : (
194
- <span style={{ fg: seg.fg }}>{seg.text}</span>
195
- ),
196
- )}
197
- </text>
198
- )}
199
- </For>
200
- </box>
239
+ <UsageSidebar
240
+ theme={() => ctx.theme.current}
241
+ reports={panel.reports}
242
+ busy={panel.busy}
243
+ error={panel.error}
244
+ tick={panel.tick}
245
+ barWidth={options.barWidth}
246
+ />
201
247
  );
202
248
  },
203
249
  },
250
+ });
251
+
252
+ void panel.load();
253
+ };
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // OpenCode 2.x (V2) TUI plugin API.
257
+ // ---------------------------------------------------------------------------
258
+
259
+ /**
260
+ * V2 command layer, mounted through a headless `app`-slot component because
261
+ * `keymap.layer` is a Solid context owned by the calling component: calling it
262
+ * from `setup` throws "Keymap.Provider is missing" and takes the whole plugin
263
+ * down. The layer is `global` so the palette/slash entry stays reachable.
264
+ */
265
+ function V2CommandLayer(props: {
266
+ ctx: V2Context;
267
+ open: () => void;
268
+ refresh: () => void;
269
+ panel: UsagePanel;
270
+ }) {
271
+ props.ctx.keymap.layer(() => ({
272
+ mode: "global",
273
+ commands: [
274
+ {
275
+ id: "usage.open",
276
+ title: "Usage: open report",
277
+ description: "Context usage and provider quota windows",
278
+ group: "Usage",
279
+ palette: true,
280
+ slash: { name: "usage" },
281
+ run: () => props.open(),
282
+ },
283
+ {
284
+ id: "usage.refresh",
285
+ title: "Usage: refresh now",
286
+ group: "Usage",
287
+ bind: "ctrl+shift+u",
288
+ run: () => props.refresh(),
289
+ },
290
+ ],
291
+ }));
292
+
293
+ // ponytail: best-effort in-dialog keys. V2 exposes no documented modal mode
294
+ // name, so r/tab/j bind only if the host names dialog input "modal".
295
+ props.ctx.keymap.layer(() => ({
296
+ mode: "modal",
297
+ commands: [
298
+ { id: "usage.dialog.refresh", bind: "r", run: () => props.refresh() },
299
+ {
300
+ id: "usage.dialog.scope",
301
+ bind: "tab",
302
+ run: () =>
303
+ props.panel.setScope((n) => (n + 1) % Math.max(1, props.panel.reports().length + 1)),
304
+ },
305
+ { id: "usage.dialog.json", bind: "j", run: () => props.panel.setShowJson((v) => !v) },
306
+ ],
307
+ }));
308
+
309
+ return null;
310
+ }
311
+
312
+ function setup(ctx: V2Context): () => void {
313
+ const options = coerceTuiOptions(ctx.options as Record<string, unknown> | undefined);
314
+ const api = createV2Api(ctx);
315
+ const panel = createUsagePanel(() => activeSessionID(api), options);
316
+
317
+ const offIdle = ctx.data.on("session.idle", () => panel.refresh());
318
+
319
+ const openDialog = (): void => {
320
+ const sessionID = activeSessionID(api);
321
+ void panel.refreshCapture().finally(() => {
322
+ if (panel.isDisposed()) return;
323
+ panel.setScope(0);
324
+ panel.setShowJson(false);
325
+ ctx.ui.dialog.show(() => (
326
+ <UsageDialog
327
+ api={api}
328
+ sessionID={sessionID}
329
+ reports={panel.reports}
330
+ busy={panel.busy}
331
+ error={panel.error}
332
+ now={() => new Date(panel.tick())}
333
+ system={panel.capture}
334
+ scope={panel.scope}
335
+ showJson={panel.showJson}
336
+ barWidth={options.barWidth}
337
+ />
338
+ ));
339
+ ctx.ui.dialog.set({ size: "xlarge" });
340
+ });
204
341
  };
205
342
 
206
- api.slots.register(slot);
343
+ const offSidebar = ctx.ui.slot({
344
+ append: "sidebar.content",
345
+ render: () => (
346
+ <UsageSidebar
347
+ theme={() => asV1Theme(v2ThemeColors(ctx.theme))}
348
+ reports={panel.reports}
349
+ busy={panel.busy}
350
+ error={panel.error}
351
+ tick={panel.tick}
352
+ barWidth={options.barWidth}
353
+ />
354
+ ),
355
+ });
356
+
357
+ const offCommands = ctx.ui.slot({
358
+ append: "app",
359
+ render: () => (
360
+ <V2CommandLayer
361
+ ctx={ctx}
362
+ open={openDialog}
363
+ refresh={() => void panel.load(true)}
364
+ panel={panel}
365
+ />
366
+ ),
367
+ });
368
+
369
+ void panel.load();
370
+
371
+ return () => {
372
+ panel.dispose();
373
+ offIdle();
374
+ offSidebar();
375
+ offCommands();
376
+ };
377
+ }
207
378
 
208
- void load();
379
+ // One default export serves both contracts: V1 reads `tui`, V2 reads `setup`
380
+ // and discards the rest (Effect Schema strips undeclared keys).
381
+ const plugin: TuiPluginModule & { id: string; setup: (ctx: V2Context) => () => void } = {
382
+ id: "usage-report",
383
+ tui,
384
+ setup,
209
385
  };
210
386
 
211
- const plugin: TuiPluginModule & { id: string } = { id: "usage-report", tui };
212
387
  export default plugin;
package/src/v2-tui.ts ADDED
@@ -0,0 +1,395 @@
1
+ /**
2
+ * OpenCode 2.x TUI plugin glue.
3
+ *
4
+ * OpenCode 2's TUI host loads the `./tui` entrypoint and validates its default
5
+ * export as `{ id, setup }` — a module whose default lacks `setup` is rejected
6
+ * with "Invalid V2 TUI plugin module". V2 also replaced the old `api.*` surface
7
+ * (snake_case slots, `api.state`, `api.route`, `api.ui.dialog.replace`) with a
8
+ * dot-separated slot tree (`ui.slot`), a client-local store (`data`), and a
9
+ * `ui.dialog` stack. The types here are mirrored structurally from
10
+ * `@opencode/plugin@2.0.20` (`dist/tui/context.d.ts` / `@opencode/theme`) —
11
+ * the host injects the context, so this package must not resolve either
12
+ * package at runtime.
13
+ *
14
+ * Rather than fork every renderer, `createV2Api` adapts the V2 context back to
15
+ * the V1 `TuiPluginApi` shape the existing sidebar/dialog already consume.
16
+ */
17
+ import type { Message, Part, Provider, Session } from "@opencode-ai/sdk/v2";
18
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
19
+ import type { RGBA } from "@opentui/core";
20
+ import { finiteOrNull, isRecord, safeString } from "./normalize";
21
+
22
+ type V1ThemeCurrent = TuiPluginApi["theme"]["current"];
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Structural mirror of the V2 TUI context (subset actually consumed).
26
+ // ---------------------------------------------------------------------------
27
+
28
+ export interface V2ThemeFeedbackColor {
29
+ readonly base?: RGBA;
30
+ readonly muted?: RGBA;
31
+ /** Pre-2.0.8 spelling. */
32
+ readonly default?: RGBA;
33
+ }
34
+
35
+ export interface V2ThemeText {
36
+ readonly base?: RGBA;
37
+ readonly muted?: RGBA;
38
+ /** Pre-2.0.8 spellings. */
39
+ readonly default?: RGBA;
40
+ readonly subdued?: RGBA;
41
+ readonly feedback?: {
42
+ readonly error?: V2ThemeFeedbackColor;
43
+ readonly warning?: V2ThemeFeedbackColor;
44
+ readonly success?: V2ThemeFeedbackColor;
45
+ readonly info?: V2ThemeFeedbackColor;
46
+ };
47
+ }
48
+
49
+ export interface V2Theme {
50
+ readonly text: V2ThemeText;
51
+ readonly border?: { readonly base?: RGBA };
52
+ readonly hue?: {
53
+ readonly accent?: Readonly<Record<number, RGBA>>;
54
+ readonly interactive?: Readonly<Record<number, RGBA>>;
55
+ };
56
+ }
57
+
58
+ export interface V2Route {
59
+ readonly type: string;
60
+ readonly sessionID?: string;
61
+ }
62
+
63
+ export interface V2ModelRef {
64
+ readonly providerID?: string;
65
+ readonly modelID?: string;
66
+ }
67
+
68
+ export interface V2TokenUsage {
69
+ readonly input?: number;
70
+ readonly output?: number;
71
+ readonly cache?: { readonly read?: number; readonly write?: number };
72
+ }
73
+
74
+ export interface V2ToolState {
75
+ readonly input?: unknown;
76
+ readonly output?: unknown;
77
+ readonly time?: { readonly compacted?: number };
78
+ }
79
+
80
+ export interface V2ContentPart {
81
+ readonly type?: string;
82
+ readonly text?: string;
83
+ readonly filename?: string;
84
+ readonly mime?: string;
85
+ readonly state?: V2ToolState;
86
+ }
87
+
88
+ export interface V2AssistantMessage {
89
+ readonly id?: string;
90
+ readonly type: "assistant";
91
+ readonly model?: V2ModelRef;
92
+ readonly content?: readonly V2ContentPart[];
93
+ readonly tokens?: V2TokenUsage;
94
+ readonly time?: { readonly created?: number };
95
+ }
96
+
97
+ export interface V2UserFile {
98
+ readonly filename?: string;
99
+ readonly name?: string;
100
+ readonly mime?: string;
101
+ }
102
+
103
+ export interface V2UserMessage {
104
+ readonly id?: string;
105
+ readonly type: "user";
106
+ readonly text?: string;
107
+ readonly files?: readonly V2UserFile[];
108
+ readonly time?: { readonly created?: number };
109
+ }
110
+
111
+ export interface V2ProviderInfo {
112
+ readonly id: string;
113
+ }
114
+
115
+ export interface V2ModelInfo {
116
+ readonly id: string;
117
+ readonly modelID: string;
118
+ readonly providerID: string;
119
+ readonly name: string;
120
+ readonly limit?: { readonly context?: number };
121
+ }
122
+
123
+ export interface V2Data {
124
+ on(type: string, handler: (event: unknown) => void): () => void;
125
+ readonly session: {
126
+ cost(sessionID: string): number;
127
+ readonly message: { list(sessionID: string): readonly unknown[] };
128
+ };
129
+ readonly location: {
130
+ readonly provider: { list(): readonly V2ProviderInfo[] | undefined };
131
+ readonly model: { list(): readonly V2ModelInfo[] | undefined };
132
+ };
133
+ }
134
+
135
+ export interface V2Dialog {
136
+ show(render: () => unknown, onClose?: () => void): void;
137
+ set(options: {
138
+ readonly size?: "medium" | "large" | "xlarge";
139
+ readonly centered?: boolean;
140
+ }): void;
141
+ clear(): void;
142
+ }
143
+
144
+ export interface V2KeymapCommand {
145
+ readonly id?: string;
146
+ readonly title?: string;
147
+ readonly description?: string;
148
+ readonly group?: string;
149
+ readonly palette?: true;
150
+ readonly bind?: false | string;
151
+ readonly slash?: { readonly name: string; readonly aliases?: readonly string[] };
152
+ readonly run: (input?: string, event?: unknown) => void | false | Promise<void>;
153
+ }
154
+
155
+ export interface V2KeymapLayer {
156
+ readonly mode?: string;
157
+ readonly commands?: readonly V2KeymapCommand[];
158
+ readonly bindings?: readonly string[];
159
+ }
160
+
161
+ export interface V2SlotClaim {
162
+ readonly render: (input: never) => unknown;
163
+ readonly append: string;
164
+ readonly prepend?: never;
165
+ readonly before?: never;
166
+ readonly after?: never;
167
+ readonly replace?: never;
168
+ }
169
+
170
+ export interface V2Context {
171
+ readonly options?: Readonly<Record<string, unknown>>;
172
+ readonly theme: V2Theme;
173
+ readonly data: V2Data;
174
+ readonly ui: {
175
+ readonly slot: (claim: V2SlotClaim) => () => void;
176
+ readonly dialog: V2Dialog;
177
+ readonly router: { current(): V2Route };
178
+ };
179
+ readonly keymap: { layer(input: () => V2KeymapLayer): void };
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Theme adapter (V2 tokens -> the V1 names the renderers read).
184
+ // ---------------------------------------------------------------------------
185
+
186
+ /** The V1 theme keys `buildLines` / `buildContextLines` resolve by name. */
187
+ export interface UsageThemeColors {
188
+ text: RGBA;
189
+ textMuted: RGBA;
190
+ accent: RGBA;
191
+ secondary: RGBA;
192
+ info: RGBA;
193
+ success: RGBA;
194
+ warning: RGBA;
195
+ error: RGBA;
196
+ borderSubtle: RGBA;
197
+ }
198
+
199
+ function feedbackBase(color: V2ThemeFeedbackColor | undefined, fallback: RGBA): RGBA {
200
+ return color?.base ?? color?.default ?? fallback;
201
+ }
202
+
203
+ /**
204
+ * Maps the V2 resolved theme onto the V1 color names. Reads both the 2.0.8+
205
+ * spelling (`base`/`muted`) and the earlier one (`default`/`subdued`), and
206
+ * falls back to the body text color so a rename upstream paints default
207
+ * foreground instead of nothing.
208
+ */
209
+ export function v2ThemeColors(theme: V2Theme): UsageThemeColors {
210
+ const text = theme?.text ?? {};
211
+ const base = (text.base ?? text.default) as RGBA;
212
+ const muted = (text.muted ?? text.subdued) as RGBA;
213
+ const feedback = text.feedback ?? {};
214
+ const accent = (theme?.hue?.accent?.[500] ?? base) as RGBA;
215
+ const secondary = (theme?.hue?.interactive?.[500] ?? muted) as RGBA;
216
+ return {
217
+ text: base,
218
+ textMuted: muted,
219
+ accent,
220
+ secondary,
221
+ info: feedbackBase(feedback.info, base),
222
+ success: feedbackBase(feedback.success, base),
223
+ warning: feedbackBase(feedback.warning, base),
224
+ error: feedbackBase(feedback.error, base),
225
+ borderSubtle: (theme?.border?.base ?? muted) as RGBA,
226
+ };
227
+ }
228
+
229
+ /** Casts the adapter result for the V1-typed renderers. */
230
+ export function asV1Theme(colors: UsageThemeColors): V1ThemeCurrent {
231
+ return colors as unknown as V1ThemeCurrent;
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Data adapter (V2 messages -> the V1 message/part shape `collectContext` reads).
236
+ // ---------------------------------------------------------------------------
237
+
238
+ const ZERO_TOKENS = { input: 0, output: 0, cache: { read: 0, write: 0 } };
239
+
240
+ function createdAt(message: { readonly time?: { readonly created?: number } }): {
241
+ created: number;
242
+ } {
243
+ return { created: finiteOrNull(message.time?.created) ?? 0 };
244
+ }
245
+
246
+ /**
247
+ * Translates one V2 message into a V1-shaped record plus its inline parts.
248
+ * V2 assistant content already carries `text`/`reasoning`/`tool` parts; V2 user
249
+ * messages carry a single `text` and an optional file list, so those are
250
+ * synthesized into the same part shape.
251
+ */
252
+ function translateMessage(raw: unknown): {
253
+ record: Record<string, unknown>;
254
+ parts: Array<Record<string, unknown>>;
255
+ } {
256
+ if (!isRecord(raw)) return { record: {}, parts: [] };
257
+ const id = typeof raw.id === "string" ? raw.id : "";
258
+ if (raw.type === "assistant") {
259
+ const message = raw as unknown as V2AssistantMessage;
260
+ const tokens = isRecord(message.tokens) ? message.tokens : {};
261
+ const cache = isRecord(tokens.cache) ? tokens.cache : {};
262
+ const model = isRecord(message.model) ? message.model : {};
263
+ const parts = Array.isArray(message.content) ? message.content.filter(isRecord) : [];
264
+ return {
265
+ record: {
266
+ id,
267
+ role: "assistant",
268
+ providerID: safeString(model.providerID),
269
+ modelID: safeString(model.modelID),
270
+ time: createdAt(message),
271
+ tokens: {
272
+ input: finiteOrNull(tokens.input) ?? 0,
273
+ output: finiteOrNull(tokens.output) ?? 0,
274
+ cache: { read: finiteOrNull(cache.read) ?? 0, write: finiteOrNull(cache.write) ?? 0 },
275
+ },
276
+ },
277
+ parts: parts as Array<Record<string, unknown>>,
278
+ };
279
+ }
280
+ if (raw.type === "user") {
281
+ const message = raw as unknown as V2UserMessage;
282
+ const parts: Array<Record<string, unknown>> = [];
283
+ if (typeof message.text === "string" && message.text !== "") {
284
+ parts.push({ type: "text", text: message.text });
285
+ }
286
+ if (Array.isArray(message.files)) {
287
+ for (const file of message.files) {
288
+ if (!isRecord(file)) continue;
289
+ parts.push({
290
+ type: "file",
291
+ filename: safeString(file.filename ?? file.name),
292
+ mime: safeString(file.mime),
293
+ });
294
+ }
295
+ }
296
+ return {
297
+ record: { id, role: "user", time: createdAt(message), tokens: { ...ZERO_TOKENS } },
298
+ parts,
299
+ };
300
+ }
301
+ return { record: {}, parts: [] };
302
+ }
303
+
304
+ /** Provider list with the `{ id, models }` shape `UsageDialog` looks up. */
305
+ export function v2ProviderModels(data: V2Data): Array<{
306
+ id: string;
307
+ models: Record<string, V2ModelInfo>;
308
+ }> {
309
+ const byProvider = new Map<string, Record<string, V2ModelInfo>>();
310
+ for (const model of data.location.model.list() ?? []) {
311
+ const bucket = byProvider.get(model.providerID) ?? {};
312
+ bucket[model.modelID] = model;
313
+ byProvider.set(model.providerID, bucket);
314
+ }
315
+ return (data.location.provider.list() ?? []).map((provider) => ({
316
+ id: provider.id,
317
+ models: byProvider.get(provider.id) ?? {},
318
+ }));
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // Context -> V1 API facade.
323
+ // ---------------------------------------------------------------------------
324
+
325
+ /**
326
+ * Presents the V2 context through the slice of `TuiPluginApi` the sidebar and
327
+ * `/usage` dialog consume (`theme.current`, `route.current`, `state.*`,
328
+ * `ui.dialog`), so the existing renderers run unchanged.
329
+ *
330
+ * `state.config` is intentionally absent: OpenCode 2 does not expose the
331
+ * resolved config to TUI plugins, so the dialog loses the compaction-reserve
332
+ * nuance only (the token rows are unaffected).
333
+ */
334
+ export function createV2Api(ctx: V2Context): TuiPluginApi {
335
+ const partsById = new Map<string, Array<Record<string, unknown>>>();
336
+
337
+ const api = {
338
+ theme: {
339
+ get current(): V1ThemeCurrent {
340
+ return asV1Theme(v2ThemeColors(ctx.theme));
341
+ },
342
+ },
343
+ route: {
344
+ get current(): { name: string; params?: Record<string, unknown> } {
345
+ const route = ctx.ui.router.current();
346
+ if (route.type === "session" && typeof route.sessionID === "string") {
347
+ return { name: "session", params: { sessionID: route.sessionID } };
348
+ }
349
+ return { name: route.type };
350
+ },
351
+ },
352
+ state: {
353
+ get provider(): ReadonlyArray<Provider> {
354
+ return v2ProviderModels(ctx.data) as unknown as ReadonlyArray<Provider>;
355
+ },
356
+ get config(): undefined {
357
+ return undefined;
358
+ },
359
+ session: {
360
+ messages(sessionID: string): ReadonlyArray<Message> {
361
+ const list = ctx.data.session.message.list(sessionID) ?? [];
362
+ const messages: Array<Record<string, unknown>> = [];
363
+ partsById.clear();
364
+ for (const raw of list) {
365
+ const { record, parts } = translateMessage(raw);
366
+ if (typeof record.id !== "string" || record.id === "") continue;
367
+ messages.push(record);
368
+ partsById.set(record.id, parts);
369
+ }
370
+ return messages as unknown as ReadonlyArray<Message>;
371
+ },
372
+ get(sessionID: string): Session {
373
+ return { cost: ctx.data.session.cost(sessionID) } as unknown as Session;
374
+ },
375
+ },
376
+ part(messageID: string): ReadonlyArray<Part> {
377
+ return (partsById.get(messageID) ?? []) as unknown as ReadonlyArray<Part>;
378
+ },
379
+ },
380
+ ui: {
381
+ dialog: {
382
+ replace(render: () => unknown, onClose?: () => void): void {
383
+ ctx.ui.dialog.show(render, onClose);
384
+ },
385
+ setSize(size: "medium" | "large" | "xlarge"): void {
386
+ ctx.ui.dialog.set({ size });
387
+ },
388
+ clear(): void {
389
+ ctx.ui.dialog.clear();
390
+ },
391
+ },
392
+ },
393
+ };
394
+ return api as unknown as TuiPluginApi;
395
+ }