oc-go-usage-display 1.0.2 → 2.0.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 +249 -68
- package/bin/lib.js +433 -29
- package/bin/oc-go-usage-display-init.js +42 -13
- package/bin/oc-go-usage-display-remove.js +38 -0
- package/bin/oc-go-usage-display-show.js +47 -39
- package/bin/oc-go-usage-display-status.js +33 -42
- package/bin/oc-go-usage-display-update.js +27 -14
- package/dist/helpers.d.ts +116 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +568 -0
- package/dist/helpers.js.map +1 -0
- package/dist/index.d.ts +5 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +258 -278
- package/dist/index.js.map +1 -1
- package/dist/plugins/oc-go-usage-display.kilo.ts +678 -0
- package/dist/plugins/oc-go-usage-display.kilo.tsx +1272 -0
- package/dist/plugins/oc-go-usage-display.ts +678 -0
- package/dist/plugins/oc-go-usage-display.tsx +1018 -0
- package/dist/shared.d.ts +96 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +575 -0
- package/dist/shared.js.map +1 -0
- package/dist/tui-shared.d.ts +109 -0
- package/dist/tui-shared.d.ts.map +1 -0
- package/dist/tui-shared.js +372 -0
- package/dist/tui-shared.js.map +1 -0
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +226 -377
- package/dist/tui.js.map +1 -1
- package/dist/tui.kilo.d.ts +8 -0
- package/dist/tui.kilo.d.ts.map +1 -0
- package/dist/tui.kilo.js +502 -0
- package/dist/tui.kilo.js.map +1 -0
- package/package.json +47 -13
- package/src/helpers.ts +733 -0
- package/src/index.ts +331 -304
- package/src/shared.ts +696 -0
- package/src/tui-shared.tsx +705 -0
- package/src/tui.kilo.tsx +816 -0
- package/src/tui.tsx +356 -465
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
//
|
|
3
|
+
// The host-agnostic TUI layer, inlined into BOTH TUI bundles (esbuild bundles
|
|
4
|
+
// every relative import, so the deployed `dist/plugins/*` files stay
|
|
5
|
+
// self-contained and default-export-only).
|
|
6
|
+
//
|
|
7
|
+
// `src/tui.tsx` (opencode) and `src/tui.kilo.tsx` (Kilo) differ in three places
|
|
8
|
+
// and nowhere else:
|
|
9
|
+
//
|
|
10
|
+
// 1. the host they read — `OPENCODE_OC_GO_*` vs `KILO_OC_GO_*`, and each
|
|
11
|
+
// host's own auth store (see `hostEnv` / `readAuthJsonApiKey`);
|
|
12
|
+
// 2. the sidebar band they register in, and whether they retire a host panel
|
|
13
|
+
// to take it (only Kilo has a token-usage panel to retire);
|
|
14
|
+
// 3. where per-session model usage comes from — opencode's own message store
|
|
15
|
+
// vs Kilo's `client.kilocode.sessionModelUsage`.
|
|
16
|
+
//
|
|
17
|
+
// Everything else is shared verbatim from here: the plan meters, the row
|
|
18
|
+
// grammar, the foldable sections, the refresh policy, the settings ladder and
|
|
19
|
+
// every failure boundary. The two hosts therefore cannot drift on the parts a
|
|
20
|
+
// user actually reads, and a fix in one is the fix in both.
|
|
21
|
+
//
|
|
22
|
+
// HOST BOUNDARY
|
|
23
|
+
// -------------
|
|
24
|
+
// The types below are structural on purpose. `TuiTheme` is imported from
|
|
25
|
+
// `@opencode-ai/plugin/tui` even though this file is inlined into the Kilo
|
|
26
|
+
// bundle too: it is an `import type`, so it is erased at build time and the
|
|
27
|
+
// Kilo bundle never requires the opencode package, and both hosts' `TuiTheme`
|
|
28
|
+
// is the same declaration (both re-export `RGBA` from the same `@opentui/core`),
|
|
29
|
+
// so the assignment is structural. `UsagePanelApi` is the smallest surface the
|
|
30
|
+
// shared layer needs; a host `TuiPluginApi` satisfies it structurally, which is
|
|
31
|
+
// what lets one implementation serve both without a cast or an `any`.
|
|
32
|
+
//
|
|
33
|
+
// REACTIVITY
|
|
34
|
+
// ----------
|
|
35
|
+
// Reactivity in this layer is hand-rolled on purpose. The deployed bundle is
|
|
36
|
+
// compiled by esbuild's automatic JSX rather than Solid's compiler, so a child
|
|
37
|
+
// written as a plain value is an ordinary evaluated expression: the host is
|
|
38
|
+
// free to reuse the element it was handed instead of re-invoking this subtree
|
|
39
|
+
// when something changes. A FUNCTION child is the one form the renderer is
|
|
40
|
+
// documented to re-evaluate (`insertExpression` wraps it in a render effect),
|
|
41
|
+
// so every region whose contents can change after mount is written as a
|
|
42
|
+
// function child and every prop carrying a changing value is passed as an
|
|
43
|
+
// accessor. Wrapping a region that happens to be static costs nothing and
|
|
44
|
+
// removes the question.
|
|
45
|
+
//
|
|
46
|
+
// This was verified against the real host in both directions rather than
|
|
47
|
+
// assumed: the collapse toggle and an async snapshot arriving after mount both
|
|
48
|
+
// re-render correctly, which is the property this pattern exists to guarantee.
|
|
49
|
+
|
|
50
|
+
import type { JSX } from "@opentui/solid/jsx-runtime";
|
|
51
|
+
import type { TuiTheme } from "@opencode-ai/plugin/tui";
|
|
52
|
+
import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
|
|
53
|
+
import {
|
|
54
|
+
PLAN_LABEL_WIDTH,
|
|
55
|
+
buildPlanRows,
|
|
56
|
+
formatPercentCell,
|
|
57
|
+
formatStatusline,
|
|
58
|
+
isDisplayMode,
|
|
59
|
+
isSnapshotEmpty,
|
|
60
|
+
meterFillPercent,
|
|
61
|
+
meterSeverityForPercent,
|
|
62
|
+
parseBooleanFlag,
|
|
63
|
+
resolveProviderId,
|
|
64
|
+
surfaceSelectionFromDisplayMode,
|
|
65
|
+
} from "./helpers.js";
|
|
66
|
+
import type {
|
|
67
|
+
PlanRow,
|
|
68
|
+
ProviderSource,
|
|
69
|
+
ResetCountdown,
|
|
70
|
+
SurfaceSelection,
|
|
71
|
+
UsageMeterSeverity,
|
|
72
|
+
UsageRow,
|
|
73
|
+
} from "./helpers.js";
|
|
74
|
+
import {
|
|
75
|
+
extractSnapshotFromApiPayload,
|
|
76
|
+
hostEnv,
|
|
77
|
+
isRecord,
|
|
78
|
+
mockSnapshot,
|
|
79
|
+
readAuthJsonApiKey,
|
|
80
|
+
toNonEmptyString,
|
|
81
|
+
unavailableSnapshot,
|
|
82
|
+
GO_PLAN_HEADING,
|
|
83
|
+
GO_PROVIDER_ID,
|
|
84
|
+
INTEGRATED_GO_SHARE_LABEL,
|
|
85
|
+
SIDEBAR_COLLAPSED_GLYPH,
|
|
86
|
+
SIDEBAR_EXPANDED_GLYPH,
|
|
87
|
+
} from "./shared.js";
|
|
88
|
+
import type { UsageHost, UsageSnapshot } from "./shared.js";
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Constants
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
const API_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
|
|
95
|
+
|
|
96
|
+
// One refresh policy for both hosts, so the numbers on a host's sidebar and its
|
|
97
|
+
// statusline can never come from two different cadences: the background poll,
|
|
98
|
+
// the event TTL and the debounce below are the same numbers as the ones each
|
|
99
|
+
// entry passes to `refreshSafely`.
|
|
100
|
+
export const POLL_INTERVAL_MS = 60_000;
|
|
101
|
+
export const EVENT_TTL_MS = 15_000;
|
|
102
|
+
const DEBOUNCE_MS = 5_000;
|
|
103
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
104
|
+
const LOG_SERVICE = "oc-go-usage-display";
|
|
105
|
+
|
|
106
|
+
// Persisted `api.kv` keys. Each host keeps its own store, so the same names
|
|
107
|
+
// never collide across hosts.
|
|
108
|
+
export const KV_DISPLAY_KEY = "display";
|
|
109
|
+
export const KV_COLLAPSED_SIDEBAR_KEY = "collapsed_sidebar";
|
|
110
|
+
export const KV_COLLAPSED_STATUSLINE_KEY = "collapsed_statusline";
|
|
111
|
+
const KV_COLLAPSED_LEGACY_KEY = "collapsed";
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Host surface
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
// The smallest `TuiPluginApi` the shared layer touches. Declared with method
|
|
118
|
+
// syntax so TypeScript compares these members bivariantly: a host whose
|
|
119
|
+
// `client.app.log` types `level` as a union still satisfies it.
|
|
120
|
+
export type UsagePanelApi = {
|
|
121
|
+
kv: {
|
|
122
|
+
get<Value = unknown>(key: string, fallback?: Value): Value;
|
|
123
|
+
set(key: string, value: unknown): void;
|
|
124
|
+
};
|
|
125
|
+
client: {
|
|
126
|
+
app: {
|
|
127
|
+
log(entry: { service: string; level: "error"; message: string }): Promise<unknown>;
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export function reactiveChild(accessor: () => unknown): JSX.Element {
|
|
133
|
+
return accessor as unknown as JSX.Element;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function meterColor(theme: TuiTheme, severity: UsageMeterSeverity) {
|
|
137
|
+
if (severity === "error") return theme.current.error;
|
|
138
|
+
if (severity === "warning") return theme.current.warning;
|
|
139
|
+
return theme.current.textMuted;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Failure boundary (api.client.app.log, never console; secrets never logged)
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
export async function logUsageError(api: UsagePanelApi, message: string): Promise<void> {
|
|
147
|
+
try {
|
|
148
|
+
await api.client.app.log({ service: LOG_SERVICE, level: "error", message });
|
|
149
|
+
} catch {
|
|
150
|
+
// Logging is best-effort; the usage display must never break the host.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Data boundary (auth.json -> Bearer usage fetch; throws nothing)
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
async function fetchJsonWithTimeout(url: string, apiKey: string): Promise<unknown | null> {
|
|
159
|
+
const controller = new AbortController();
|
|
160
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
161
|
+
try {
|
|
162
|
+
const response = await fetch(url, {
|
|
163
|
+
signal: controller.signal,
|
|
164
|
+
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
165
|
+
});
|
|
166
|
+
if (response.status === 401 || response.status === 403) return { __rejected: true };
|
|
167
|
+
if (!response.ok) return null;
|
|
168
|
+
try {
|
|
169
|
+
return (await response.json()) as unknown;
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
} finally {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function loadUsageSnapshot(host: UsageHost): Promise<UsageSnapshot | null> {
|
|
181
|
+
if (hostEnv(host, "MOCK") === "1") return mockSnapshot();
|
|
182
|
+
|
|
183
|
+
const apiKey = toNonEmptyString(hostEnv(host, "API_KEY")) ?? readAuthJsonApiKey(host);
|
|
184
|
+
if (apiKey === null) return null;
|
|
185
|
+
|
|
186
|
+
const payload = await fetchJsonWithTimeout(API_USAGE_URL, apiKey);
|
|
187
|
+
if (payload === null) return null;
|
|
188
|
+
// Mirror the server: a rejected key is a distinct unavailable snapshot
|
|
189
|
+
// (surfaced via `apiError`) rather than a silent null.
|
|
190
|
+
if (isRecord(payload) && payload.__rejected === true) {
|
|
191
|
+
return unavailableSnapshot("API key rejected (401/403)");
|
|
192
|
+
}
|
|
193
|
+
return extractSnapshotFromApiPayload(payload);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Usage store: the one refresh policy both hosts run
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
export type UsageStore = {
|
|
201
|
+
snapshot: () => UsageSnapshot | null;
|
|
202
|
+
// Awaited once at the end of initialization so the first render has data.
|
|
203
|
+
refresh: () => Promise<void>;
|
|
204
|
+
// Fire-and-forget and cannot surface an unhandled rejection. The background
|
|
205
|
+
// poll (no argument) keeps the 60s TTL guard; `session.updated` passes 0 to
|
|
206
|
+
// bypass the TTL entirely (debounce only); `message.updated` passes
|
|
207
|
+
// EVENT_TTL_MS for a 15s effective window.
|
|
208
|
+
refreshSafely: (ttlOverride?: number) => void;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export function createUsageStore(api: UsagePanelApi, host: UsageHost): UsageStore {
|
|
212
|
+
const [snapshot, setSnapshot] = createSignal<UsageSnapshot | null>(null);
|
|
213
|
+
let cachedAt = 0;
|
|
214
|
+
let lastFetchAt = 0;
|
|
215
|
+
let refreshInFlight = false;
|
|
216
|
+
|
|
217
|
+
async function refresh(ttlOverride?: number): Promise<void> {
|
|
218
|
+
if (refreshInFlight) return;
|
|
219
|
+
const effectiveTtl = ttlOverride ?? POLL_INTERVAL_MS;
|
|
220
|
+
if (ttlOverride !== undefined) {
|
|
221
|
+
if (Date.now() - lastFetchAt < DEBOUNCE_MS) return;
|
|
222
|
+
if (Date.now() - cachedAt < effectiveTtl && snapshot() !== null) return;
|
|
223
|
+
} else if (Date.now() - cachedAt < POLL_INTERVAL_MS && snapshot() !== null) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
refreshInFlight = true;
|
|
227
|
+
try {
|
|
228
|
+
const next = await loadUsageSnapshot(host);
|
|
229
|
+
if (next === null) {
|
|
230
|
+
if (snapshot() === null) {
|
|
231
|
+
await logUsageError(api, "Go usage unavailable (not configured or request failed)");
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
cachedAt = Date.now();
|
|
236
|
+
setSnapshot(next);
|
|
237
|
+
} catch {
|
|
238
|
+
// Keep stale data; the panel simply shows the last known snapshot.
|
|
239
|
+
} finally {
|
|
240
|
+
lastFetchAt = Date.now();
|
|
241
|
+
refreshInFlight = false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
snapshot,
|
|
247
|
+
refresh: () => refresh(),
|
|
248
|
+
refreshSafely: (ttlOverride?: number) => {
|
|
249
|
+
void refresh(ttlOverride).catch(() => {
|
|
250
|
+
// refresh already swallows failures; this guards a regression.
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Settings boundary (new toggles > legacy display; tui.json options > env >
|
|
258
|
+
// api.kv > default both on)
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
// Structurally the host's `PluginOptions` (`Record<string, unknown>`) on both
|
|
262
|
+
// hosts; named so the shared ladder is not typed against either package.
|
|
263
|
+
export type DisplayOptions = {
|
|
264
|
+
sidebar?: unknown;
|
|
265
|
+
statusline?: unknown;
|
|
266
|
+
display?: unknown;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export function resolveSurfaceSelection(
|
|
270
|
+
options: DisplayOptions | undefined,
|
|
271
|
+
api: UsagePanelApi,
|
|
272
|
+
host: UsageHost,
|
|
273
|
+
): SurfaceSelection {
|
|
274
|
+
if (options !== undefined) {
|
|
275
|
+
const sidebarOption = parseBooleanFlag(options.sidebar);
|
|
276
|
+
const statuslineOption = parseBooleanFlag(options.statusline);
|
|
277
|
+
if (sidebarOption !== null || statuslineOption !== null) {
|
|
278
|
+
return { sidebar: sidebarOption ?? true, statusline: statuslineOption ?? true };
|
|
279
|
+
}
|
|
280
|
+
if (isDisplayMode(options.display)) return surfaceSelectionFromDisplayMode(options.display);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const sidebarEnv = parseBooleanFlag(hostEnv(host, "SIDEBAR"));
|
|
284
|
+
const statuslineEnv = parseBooleanFlag(hostEnv(host, "STATUSLINE"));
|
|
285
|
+
if (sidebarEnv !== null || statuslineEnv !== null) {
|
|
286
|
+
return { sidebar: sidebarEnv ?? true, statusline: statuslineEnv ?? true };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const displayEnv = toNonEmptyString(hostEnv(host, "DISPLAY"));
|
|
290
|
+
if (displayEnv !== null && isDisplayMode(displayEnv)) {
|
|
291
|
+
return surfaceSelectionFromDisplayMode(displayEnv);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
const stored = api.kv.get(KV_DISPLAY_KEY, "both");
|
|
296
|
+
if (isDisplayMode(stored)) return surfaceSelectionFromDisplayMode(stored);
|
|
297
|
+
} catch {
|
|
298
|
+
// Persisted settings are best-effort; fall through to the default.
|
|
299
|
+
}
|
|
300
|
+
return { sidebar: true, statusline: true };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// Collapse (independent per surface, persisted in `api.kv`)
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
// The legacy single `collapsed` key is migrated once (when true, both new keys
|
|
308
|
+
// are set true, then the legacy key is cleared) and ignored afterwards.
|
|
309
|
+
export type CollapseState = {
|
|
310
|
+
isSidebarCollapsed: () => boolean;
|
|
311
|
+
isStatuslineCollapsed: () => boolean;
|
|
312
|
+
toggleSidebar: () => void;
|
|
313
|
+
toggleStatusline: () => void;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
function readCollapsedFlag(api: UsagePanelApi, key: string): boolean {
|
|
317
|
+
try {
|
|
318
|
+
return api.kv.get<boolean>(key, false) === true;
|
|
319
|
+
} catch {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function migrateLegacyCollapsedFlag(api: UsagePanelApi): void {
|
|
325
|
+
let legacyCollapsed = false;
|
|
326
|
+
try {
|
|
327
|
+
legacyCollapsed = api.kv.get<boolean>(KV_COLLAPSED_LEGACY_KEY, false) === true;
|
|
328
|
+
} catch {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (!legacyCollapsed) return;
|
|
332
|
+
try {
|
|
333
|
+
api.kv.set(KV_COLLAPSED_SIDEBAR_KEY, true);
|
|
334
|
+
api.kv.set(KV_COLLAPSED_STATUSLINE_KEY, true);
|
|
335
|
+
api.kv.set(KV_COLLAPSED_LEGACY_KEY, false);
|
|
336
|
+
} catch {
|
|
337
|
+
// Collapse state is best-effort persistence only.
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function createCollapseState(api: UsagePanelApi): CollapseState {
|
|
342
|
+
migrateLegacyCollapsedFlag(api);
|
|
343
|
+
const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal<boolean>(
|
|
344
|
+
readCollapsedFlag(api, KV_COLLAPSED_SIDEBAR_KEY),
|
|
345
|
+
);
|
|
346
|
+
const [isStatuslineCollapsed, setIsStatuslineCollapsed] = createSignal<boolean>(
|
|
347
|
+
readCollapsedFlag(api, KV_COLLAPSED_STATUSLINE_KEY),
|
|
348
|
+
);
|
|
349
|
+
return {
|
|
350
|
+
isSidebarCollapsed,
|
|
351
|
+
isStatuslineCollapsed,
|
|
352
|
+
toggleSidebar() {
|
|
353
|
+
try {
|
|
354
|
+
const next = !isSidebarCollapsed();
|
|
355
|
+
setIsSidebarCollapsed(next);
|
|
356
|
+
api.kv.set(KV_COLLAPSED_SIDEBAR_KEY, next);
|
|
357
|
+
} catch {
|
|
358
|
+
// Collapse state is best-effort persistence only.
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
toggleStatusline() {
|
|
362
|
+
try {
|
|
363
|
+
const next = !isStatuslineCollapsed();
|
|
364
|
+
setIsStatuslineCollapsed(next);
|
|
365
|
+
api.kv.set(KV_COLLAPSED_STATUSLINE_KEY, next);
|
|
366
|
+
} catch {
|
|
367
|
+
// Collapse state is best-effort persistence only.
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
// Provider gate
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
export function isGoUsageProvider(providerId: string | undefined): boolean {
|
|
378
|
+
return providerId === GO_PROVIDER_ID;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Which provider is in use, read live on every render rather than latched at
|
|
382
|
+
// init. Latching left the gate closed for anyone without a config-level model,
|
|
383
|
+
// and `session.updated` only fires on a server-side change, so opening a
|
|
384
|
+
// session or switching model in the picker never re-armed it -- the plugin
|
|
385
|
+
// loaded, showed as `active` in the Plugins menu, and rendered nothing.
|
|
386
|
+
export function makeProviderResolver(
|
|
387
|
+
state: ProviderSource | undefined,
|
|
388
|
+
fallback: () => string | undefined,
|
|
389
|
+
): (sessionId: string) => string | undefined {
|
|
390
|
+
return (sessionId: string) => resolveProviderId(state, sessionId, fallback());
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// Provider / model display names
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
// The provider catalog as far as the panels need it. Both hosts expose the same
|
|
398
|
+
// shape (`state.provider[].id|name|models[modelID].name`); the fields are read
|
|
399
|
+
// through `toNonEmptyString`, so a host that renames or drops one degrades to
|
|
400
|
+
// the raw id rather than rendering `undefined`.
|
|
401
|
+
export type ProviderCatalogSource = {
|
|
402
|
+
state?: {
|
|
403
|
+
provider?: ReadonlyArray<{
|
|
404
|
+
id?: unknown;
|
|
405
|
+
name?: unknown;
|
|
406
|
+
models?: Readonly<Record<string, { name?: unknown } | undefined>>;
|
|
407
|
+
}>;
|
|
408
|
+
};
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
export function readProviderDisplayNames(source: ProviderCatalogSource): ReadonlyMap<string, string> {
|
|
412
|
+
const names = new Map<string, string>();
|
|
413
|
+
try {
|
|
414
|
+
for (const provider of source.state?.provider ?? []) {
|
|
415
|
+
const id = toNonEmptyString(provider.id);
|
|
416
|
+
if (id === null) continue;
|
|
417
|
+
names.set(id, toNonEmptyString(provider.name) ?? id);
|
|
418
|
+
}
|
|
419
|
+
} catch {
|
|
420
|
+
// The catalog is optional: a group falls back to its raw provider id.
|
|
421
|
+
}
|
|
422
|
+
return names;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function readModelDisplayName(
|
|
426
|
+
source: ProviderCatalogSource,
|
|
427
|
+
providerID: string,
|
|
428
|
+
modelID: string,
|
|
429
|
+
): string | null {
|
|
430
|
+
try {
|
|
431
|
+
const provider = source.state?.provider?.find((entry) => entry.id === providerID);
|
|
432
|
+
return toNonEmptyString(provider?.models?.[modelID]?.name);
|
|
433
|
+
} catch {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// Row grammar
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
// A `space-between` label/value row, the grammar the hosts' own panels use.
|
|
443
|
+
export function LabeledValueRow(props: { theme: TuiTheme; row: UsageRow }) {
|
|
444
|
+
return (
|
|
445
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
446
|
+
<text fg={props.theme.current.textMuted} wrapMode="none">
|
|
447
|
+
{props.row.label}
|
|
448
|
+
</text>
|
|
449
|
+
<text fg={props.theme.current.textMuted} wrapMode="none">
|
|
450
|
+
{props.row.value}
|
|
451
|
+
</text>
|
|
452
|
+
</box>
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// The narrowest a meter may become before it stops being a meter: enough to read
|
|
457
|
+
// a filled-vs-empty split at a glance.
|
|
458
|
+
export const MIN_METER_WIDTH = 6;
|
|
459
|
+
|
|
460
|
+
// A meter as two boxes: the filled part at the window's percentage, the track for
|
|
461
|
+
// the rest. Both fill whatever the row has, so the bar reaches the sidebar's
|
|
462
|
+
// right edge on any host, at any sidebar width, with nothing to measure and
|
|
463
|
+
// nothing to guess -- the failure modes a fixed-width glyph string has (too short
|
|
464
|
+
// on a wide sidebar, clipped on a narrow one) cannot happen.
|
|
465
|
+
//
|
|
466
|
+
// The track is a surface tone rather than the severity colour, so an empty meter
|
|
467
|
+
// reads as an empty track instead of blank space.
|
|
468
|
+
export function MeterBar(props: { theme: TuiTheme; percent: number; severity: UsageMeterSeverity }) {
|
|
469
|
+
const fill = createMemo(() => meterFillPercent(props.percent));
|
|
470
|
+
return (
|
|
471
|
+
<box flexDirection="row" flexGrow={1} flexShrink={1} minWidth={MIN_METER_WIDTH}>
|
|
472
|
+
<box width={`${fill()}%`} backgroundColor={meterColor(props.theme, props.severity)} />
|
|
473
|
+
<box flexGrow={1} backgroundColor={props.theme.current.backgroundElement} />
|
|
474
|
+
</box>
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// One plan window as a row of three FIXED-WIDTH columns: label, bar, percent.
|
|
479
|
+
//
|
|
480
|
+
// Not `justifyContent="space-between"` on a label plus a bar: that lets the bar
|
|
481
|
+
// start wherever the label happened to end, so the three meters only look
|
|
482
|
+
// stacked when every label is the same width -- and a terminal font that draws
|
|
483
|
+
// the empty meter glyph narrower than the full one shifts the bar of a 0% row
|
|
484
|
+
// against a 100% one (seen at 30d 100% next to 5h 0%). Fixed columns make the
|
|
485
|
+
// meters stack whatever the font does, and the percentages right-align on one
|
|
486
|
+
// edge.
|
|
487
|
+
export function GoPlanRow(props: { theme: TuiTheme; row: PlanRow }) {
|
|
488
|
+
return (
|
|
489
|
+
<box flexDirection="column">
|
|
490
|
+
<box flexDirection="row">
|
|
491
|
+
{/* The label sits in a fixed-width BOX, not a fixed-width text: opentui
|
|
492
|
+
treats a text node's `width` as a wrapping bound, so the box after it
|
|
493
|
+
still starts wherever the label ended -- "5h" and "30d" then draw
|
|
494
|
+
their meters a cell apart. A box is a real layout box, which is why
|
|
495
|
+
Kilo's Steps/Cost columns (boxes) line up and these did not. */}
|
|
496
|
+
{/* The label cell is exactly `30d` wide and carries a one-cell margin:
|
|
497
|
+
without it the longest label butts straight into the meter, while the
|
|
498
|
+
shorter `5h`/`7d` happen to leave one behind -- the meter has to start
|
|
499
|
+
the same column on every row, so the separation is a layout fact and
|
|
500
|
+
not something the label's own length may decide. */}
|
|
501
|
+
<box width={PLAN_LABEL_WIDTH} marginRight={1} flexShrink={0} flexDirection="row">
|
|
502
|
+
<text fg={props.theme.current.textMuted} wrapMode="none">
|
|
503
|
+
{props.row.label}
|
|
504
|
+
</text>
|
|
505
|
+
</box>
|
|
506
|
+
<MeterBar theme={props.theme} percent={props.row.percent} severity={props.row.severity} />
|
|
507
|
+
<text fg={props.theme.current.textMuted} wrapMode="none" flexShrink={0}>
|
|
508
|
+
{formatPercentCell(props.row.percent)}
|
|
509
|
+
</text>
|
|
510
|
+
</box>
|
|
511
|
+
<Show when={props.row.reset}>
|
|
512
|
+
{(reset) => (
|
|
513
|
+
<text fg={props.theme.current.textMuted} wrapMode="none">
|
|
514
|
+
resets in {reset()}
|
|
515
|
+
</text>
|
|
516
|
+
)}
|
|
517
|
+
</Show>
|
|
518
|
+
</box>
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export function GoPlanSection(props: { theme: TuiTheme; rows: PlanRow[] }) {
|
|
523
|
+
return (
|
|
524
|
+
<box flexDirection="column">
|
|
525
|
+
<text fg={props.theme.current.text} wrapMode="none">
|
|
526
|
+
<b>{GO_PLAN_HEADING}</b>
|
|
527
|
+
</text>
|
|
528
|
+
<box flexDirection="column" paddingLeft={1}>
|
|
529
|
+
<For each={props.rows}>
|
|
530
|
+
{(row) => <GoPlanRow theme={props.theme} row={row} />}
|
|
531
|
+
</For>
|
|
532
|
+
</box>
|
|
533
|
+
</box>
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function SectionHeader(props: {
|
|
538
|
+
theme: TuiTheme;
|
|
539
|
+
label: string;
|
|
540
|
+
expanded: () => boolean;
|
|
541
|
+
count: () => number | null;
|
|
542
|
+
onToggle: () => void;
|
|
543
|
+
}) {
|
|
544
|
+
return (
|
|
545
|
+
<box flexDirection="row" gap={1} flexShrink={0} onMouseDown={props.onToggle}>
|
|
546
|
+
<text fg={props.theme.current.text} wrapMode="none" flexShrink={0}>
|
|
547
|
+
{reactiveChild(() => (props.expanded() ? SIDEBAR_EXPANDED_GLYPH : SIDEBAR_COLLAPSED_GLYPH))}
|
|
548
|
+
</text>
|
|
549
|
+
<text fg={props.theme.current.text} wrapMode="none">
|
|
550
|
+
<b>
|
|
551
|
+
{reactiveChild(() => {
|
|
552
|
+
const count = props.count();
|
|
553
|
+
return count === null ? props.label : `${props.label} (${count})`;
|
|
554
|
+
})}
|
|
555
|
+
</b>
|
|
556
|
+
</text>
|
|
557
|
+
</box>
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// A collapsible region: the header reacts to its own state, and the body is a
|
|
562
|
+
// memo so an unchanged subtree keeps the very same element instead of being
|
|
563
|
+
// rebuilt on every unrelated signal write.
|
|
564
|
+
export function CollapsibleSection(props: {
|
|
565
|
+
theme: TuiTheme;
|
|
566
|
+
label: string;
|
|
567
|
+
count: () => number | null;
|
|
568
|
+
expanded: () => boolean;
|
|
569
|
+
onToggle: () => void;
|
|
570
|
+
body: () => JSX.Element;
|
|
571
|
+
}) {
|
|
572
|
+
return (
|
|
573
|
+
<box flexDirection="column">
|
|
574
|
+
<SectionHeader
|
|
575
|
+
theme={props.theme}
|
|
576
|
+
label={props.label}
|
|
577
|
+
count={props.count}
|
|
578
|
+
expanded={props.expanded}
|
|
579
|
+
onToggle={props.onToggle}
|
|
580
|
+
/>
|
|
581
|
+
<box flexDirection="column" gap={1}>
|
|
582
|
+
{reactiveChild(() => (props.expanded() ? props.body() : null))}
|
|
583
|
+
</box>
|
|
584
|
+
</box>
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// A model's share of the Go tokens spent in this session tree, drawn with the
|
|
589
|
+
// same block bar and the same threshold coloring as the plan meters. It is a
|
|
590
|
+
// share of tokens and nothing else: not of the plan, not a quota, not a price.
|
|
591
|
+
// A model's Go share: the bar and the percent pinned to the RIGHT edge of the
|
|
592
|
+
// row, so they line up with the Steps/Cost columns of the table above instead of
|
|
593
|
+
// trailing the label wherever it happens to end. The label takes the slack
|
|
594
|
+
// (`flexGrow`), the meter group never shrinks.
|
|
595
|
+
export function GoShareRow(props: { theme: TuiTheme; percent: number }) {
|
|
596
|
+
const rounded = Math.round(props.percent);
|
|
597
|
+
return (
|
|
598
|
+
<box flexDirection="row" gap={1}>
|
|
599
|
+
<text fg={props.theme.current.textMuted} wrapMode="none" flexShrink={0} flexGrow={1}>
|
|
600
|
+
{INTEGRATED_GO_SHARE_LABEL}
|
|
601
|
+
</text>
|
|
602
|
+
<MeterBar
|
|
603
|
+
theme={props.theme}
|
|
604
|
+
percent={rounded}
|
|
605
|
+
severity={meterSeverityForPercent(rounded)}
|
|
606
|
+
/>
|
|
607
|
+
<text fg={props.theme.current.textMuted} wrapMode="none" flexShrink={0}>
|
|
608
|
+
{formatPercentCell(rounded)}
|
|
609
|
+
</text>
|
|
610
|
+
</box>
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ---------------------------------------------------------------------------
|
|
615
|
+
// The compact `Go Usage` block both hosts lead the sidebar with
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
|
|
618
|
+
// The `Go Usage` block both hosts lead their sidebar with: the next reset on one
|
|
619
|
+
// line, then the plan as the meters themselves, one row per window, stacked in
|
|
620
|
+
// fixed-width columns.
|
|
621
|
+
//
|
|
622
|
+
// One shape for both hosts, on purpose. Kilo's standalone band and opencode's
|
|
623
|
+
// whole sidebar used to be different layouts (label/value rows with a separate
|
|
624
|
+
// `Go Plan` heading, versus meters inline), which meant the plan looked like a
|
|
625
|
+
// different feature depending on where it was drawn. Kilo's integrated mode does
|
|
626
|
+
// not use this block at all -- the plan is drawn once, inside its Models table
|
|
627
|
+
// where it belongs to the provider it meters.
|
|
628
|
+
//
|
|
629
|
+
// There is no meter width in here to tune: `MeterBar` fills the row.
|
|
630
|
+
export function GoUsageBlock(props: {
|
|
631
|
+
api: UsagePanelApi;
|
|
632
|
+
theme: TuiTheme;
|
|
633
|
+
snapshot: () => UsageSnapshot | null;
|
|
634
|
+
resetLine?: () => ResetCountdown | null;
|
|
635
|
+
}) {
|
|
636
|
+
createEffect(() => {
|
|
637
|
+
const snapshot = props.snapshot();
|
|
638
|
+
if (snapshot !== null && snapshot.source === "unavailable") {
|
|
639
|
+
void logUsageError(
|
|
640
|
+
props.api,
|
|
641
|
+
snapshot.apiError ? `Go usage unavailable (${snapshot.apiError})` : "Go usage snapshot unavailable",
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
const body = createMemo(() => {
|
|
647
|
+
const snapshot = props.snapshot();
|
|
648
|
+
if (snapshot === null) {
|
|
649
|
+
return (
|
|
650
|
+
<text fg={props.theme.current.textMuted} wrapMode="none">
|
|
651
|
+
Go loading…
|
|
652
|
+
</text>
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
// Rejected keys (and other unavailable snapshots) must surface a row
|
|
656
|
+
// instead of a bare header with zero rows. The statusline stays hidden for
|
|
657
|
+
// unavailable (isSnapshotEmpty -> null).
|
|
658
|
+
if (snapshot.source === "unavailable") {
|
|
659
|
+
return (
|
|
660
|
+
<text fg={props.theme.current.textMuted} wrapMode="none">
|
|
661
|
+
Go n/a ({snapshot.apiError ?? "unavailable"})
|
|
662
|
+
</text>
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
// The per-row `resets in` suffix is dropped here: `resetLine` already prints
|
|
666
|
+
// the plan's countdown under the header, and printing one countdown twice in
|
|
667
|
+
// a single block is noise rather than emphasis. Both that line and the
|
|
668
|
+
// statusline read `relevantReset`, so the block and the statusline can only
|
|
669
|
+
// ever be counting down to the same window.
|
|
670
|
+
const planRows = buildPlanRows(snapshot).map((row) => ({ ...row, reset: null }));
|
|
671
|
+
return (
|
|
672
|
+
<box flexDirection="column">
|
|
673
|
+
<Show when={props.resetLine === undefined ? null : props.resetLine()}>
|
|
674
|
+
{(reset) => (
|
|
675
|
+
<text fg={props.theme.current.textMuted} wrapMode="none">
|
|
676
|
+
{reset().label} resets in {reset().text}
|
|
677
|
+
</text>
|
|
678
|
+
)}
|
|
679
|
+
</Show>
|
|
680
|
+
<For each={planRows}>{(row) => <GoPlanRow theme={props.theme} row={row} />}</For>
|
|
681
|
+
</box>
|
|
682
|
+
);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
return (
|
|
686
|
+
<box flexDirection="column">
|
|
687
|
+
<text fg={props.theme.current.text}>
|
|
688
|
+
<b>Go Usage</b>
|
|
689
|
+
</text>
|
|
690
|
+
{reactiveChild(body)}
|
|
691
|
+
</box>
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// The compact statusline: one muted line next to the host's context readout.
|
|
696
|
+
export function GoStatusline(props: { snapshot: () => UsageSnapshot | null }) {
|
|
697
|
+
return (
|
|
698
|
+
<Show when={props.snapshot()} fallback={null}>
|
|
699
|
+
{(snapshot) => {
|
|
700
|
+
if (isSnapshotEmpty(snapshot())) return null;
|
|
701
|
+
return <text>{formatStatusline(snapshot())}</text>;
|
|
702
|
+
}}
|
|
703
|
+
</Show>
|
|
704
|
+
);
|
|
705
|
+
}
|