opencode-usage-report 0.5.0 → 0.6.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 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
 
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.0",
4
4
  "description": "opencode plugin: /usage command showing subscription quota windows for configured providers",
5
5
  "keywords": [
6
6
  "kimi",
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
+ }