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 +14 -5
- package/package.json +1 -1
- package/src/providers/kimi.ts +94 -38
- package/src/tui.tsx +245 -70
- package/src/v2-tui.ts +395 -0
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.
|
|
75
|
-
> `{ id, setup }`
|
|
76
|
-
>
|
|
77
|
-
>
|
|
78
|
-
>
|
|
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
package/src/providers/kimi.ts
CHANGED
|
@@ -65,12 +65,17 @@ function findLimitDetail(limits: unknown, durationMinutes: number): unknown {
|
|
|
65
65
|
return undefined;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
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:
|
|
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.
|
|
100
|
-
// ratios are
|
|
101
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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 =
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
<
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|