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
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
// Pure helpers shared by the plugin entry modules.
|
|
2
|
+
//
|
|
3
|
+
// IMPORTANT: these live OUTSIDE `src/index.ts` / `src/tui.tsx`. OpenCode's
|
|
4
|
+
// loader enumerates every export of a plugin entry module and (when the
|
|
5
|
+
// default is not a `{ id, server }` module) invokes each one as a plugin
|
|
6
|
+
// factory. An entry module must therefore export ONLY its default module;
|
|
7
|
+
// keeping the helper surface here makes that contract structural instead of
|
|
8
|
+
// accidental (a stray `export function` returning null used to crash
|
|
9
|
+
// `Provider.list`).
|
|
10
|
+
//
|
|
11
|
+
// Unit-tested via `dist/helpers.js` (see test/unit/server.test.js and
|
|
12
|
+
// test/unit/tui.test.js).
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import {
|
|
16
|
+
resolveHostRoots,
|
|
17
|
+
formatResetDuration,
|
|
18
|
+
hostEnv,
|
|
19
|
+
isRecord,
|
|
20
|
+
resolveUsageHost,
|
|
21
|
+
safeJoinPath,
|
|
22
|
+
toNonEmptyString,
|
|
23
|
+
toUsageCount,
|
|
24
|
+
CACHE_RATE_DECIMALS,
|
|
25
|
+
CACHE_RATE_EMPTY,
|
|
26
|
+
GO_MODEL_MIX_BUDGET,
|
|
27
|
+
GO_MODEL_MIX_NAME_MAX_CHARS,
|
|
28
|
+
GO_MODEL_MIX_SEPARATOR,
|
|
29
|
+
GO_PROVIDER_ID,
|
|
30
|
+
PERCENT_CELL_WIDTH,
|
|
31
|
+
KILO_MODEL_NAME_MAX_CHARS,
|
|
32
|
+
KILO_TOKEN_USAGE_ROWS,
|
|
33
|
+
TOP_GO_MODELS_LIMIT,
|
|
34
|
+
} from "./shared.js";
|
|
35
|
+
import type { ModelUsage, UsageHost, UsageSnapshot, UsageTokens, UsageWindow } from "./shared.js";
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Server: compact one-line snapshot summary (keeps the rolling reset suffix)
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
function formatWindow(window: UsageWindow | null): string {
|
|
42
|
+
if (!window) return "n/a";
|
|
43
|
+
return `${window.percent}%`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function formatServerLine(snapshot: UsageSnapshot): string {
|
|
47
|
+
if (snapshot.apiUnavailable || (!snapshot.rolling && !snapshot.weekly && !snapshot.monthly)) {
|
|
48
|
+
const reason = snapshot.apiError ?? "unknown error";
|
|
49
|
+
return `Go n/a (${reason})`;
|
|
50
|
+
}
|
|
51
|
+
const rollingReset =
|
|
52
|
+
formatResetDuration(snapshot.rolling?.resetInSec ?? null) ??
|
|
53
|
+
snapshot.rolling?.resetText ??
|
|
54
|
+
null;
|
|
55
|
+
const rollingText =
|
|
56
|
+
snapshot.rolling === null
|
|
57
|
+
? "5h n/a"
|
|
58
|
+
: `5h ${snapshot.rolling.percent}%${rollingReset ? ` (reset ${rollingReset})` : ""}`;
|
|
59
|
+
return `Go ${rollingText} | 7d ${formatWindow(snapshot.weekly)} | 30d ${formatWindow(snapshot.monthly)}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Server: auth cookie boundary (malformed-cookie rejection; never logged)
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export type FileConfig = { workspaceId: string | null; authCookie: string | null };
|
|
67
|
+
|
|
68
|
+
export function fileConfigPath(host: UsageHost = "opencode"): string {
|
|
69
|
+
return safeJoinPath(resolveHostRoots(host).configDir, "oc-go-usage-display.json");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function readFileConfig(host: UsageHost = "opencode"): FileConfig {
|
|
73
|
+
let raw: string;
|
|
74
|
+
try {
|
|
75
|
+
raw = fs.readFileSync(fileConfigPath(host), "utf8");
|
|
76
|
+
} catch {
|
|
77
|
+
return { workspaceId: null, authCookie: null };
|
|
78
|
+
}
|
|
79
|
+
let parsed: unknown;
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(raw);
|
|
82
|
+
} catch {
|
|
83
|
+
return { workspaceId: null, authCookie: null };
|
|
84
|
+
}
|
|
85
|
+
if (!isRecord(parsed)) return { workspaceId: null, authCookie: null };
|
|
86
|
+
return {
|
|
87
|
+
workspaceId: toNonEmptyString(parsed.workspaceId),
|
|
88
|
+
authCookie: toNonEmptyString(parsed.authCookie),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isMalformedAuthCookie(cookie: string): boolean {
|
|
93
|
+
// Reject CR/LF (header injection), separators that confuse cookie jars,
|
|
94
|
+
// plus tab, NUL, and double-quote (never valid in a cookie value).
|
|
95
|
+
return /[\r\n;,\t\0"]/.test(cookie);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function hasMalformedAuthCookie(
|
|
99
|
+
fileConfig: FileConfig = readFileConfig(),
|
|
100
|
+
host: UsageHost = resolveUsageHost(),
|
|
101
|
+
): boolean {
|
|
102
|
+
const raw = toNonEmptyString(hostEnv(host, "AUTH_COOKIE")) ?? fileConfig.authCookie;
|
|
103
|
+
if (!raw) return false;
|
|
104
|
+
return isMalformedAuthCookie(raw);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Server: cookie-fetch redirect policy
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
//
|
|
111
|
+
// The workspace scrape carries the user's `auth` cookie. Automatic redirect
|
|
112
|
+
// following may re-send caller headers (including the cookie) to a cross-origin
|
|
113
|
+
// Location, and runtime header stripping cannot be relied on to prevent that.
|
|
114
|
+
// The cookie path therefore requests `redirect: "manual"` and re-sends only
|
|
115
|
+
// after these helpers approve a Location on the allowlisted canonical HTTPS
|
|
116
|
+
// hosts. `fetchViaApiKey` is untouched (anonymous API path).
|
|
117
|
+
|
|
118
|
+
const ALLOWED_REDIRECT_HOSTS = new Set(["opencode.ai", "auth.opencode.ai"]);
|
|
119
|
+
|
|
120
|
+
// Follow at most this many redirects before giving up (a redirect loop or an
|
|
121
|
+
// endless login bounce becomes a clear unavailable snapshot instead of a hang).
|
|
122
|
+
export const MAX_REDIRECT_HOPS = 3;
|
|
123
|
+
|
|
124
|
+
export type RedirectDecision =
|
|
125
|
+
| { follow: true; url: string }
|
|
126
|
+
| { follow: false; reason: string };
|
|
127
|
+
|
|
128
|
+
// Only the canonical HTTPS origins may receive the auth cookie: no explicit
|
|
129
|
+
// ports, no userinfo, and an exact allowlisted hostname (so `opencode.ai.evil`
|
|
130
|
+
// never matches).
|
|
131
|
+
function isAllowedHttpsUrl(url: URL): boolean {
|
|
132
|
+
return (
|
|
133
|
+
url.protocol === "https:" &&
|
|
134
|
+
url.username === "" &&
|
|
135
|
+
url.password === "" &&
|
|
136
|
+
url.port === "" &&
|
|
137
|
+
ALLOWED_REDIRECT_HOSTS.has(url.hostname)
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Resolve `location` against `fromUrl`; null unless BOTH the current URL and
|
|
142
|
+
// the resolved target are allowed HTTPS origins.
|
|
143
|
+
function resolveRedirectUrl(fromUrl: string, location: string): URL | null {
|
|
144
|
+
let base: URL;
|
|
145
|
+
try {
|
|
146
|
+
base = new URL(fromUrl);
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
if (!isAllowedHttpsUrl(base)) return null;
|
|
151
|
+
let next: URL;
|
|
152
|
+
try {
|
|
153
|
+
next = new URL(location, base);
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
return isAllowedHttpsUrl(next) ? next : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function redirectTargetHost(fromUrl: string, location: string): string | null {
|
|
161
|
+
try {
|
|
162
|
+
const host = new URL(location, fromUrl).host;
|
|
163
|
+
return host.length > 0 ? host : null;
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// True when `location` resolves to an allowed canonical HTTPS host relative to
|
|
170
|
+
// an allowed `fromUrl`. Relative locations inherit the current host.
|
|
171
|
+
export function isAllowedRedirect(fromUrl: string, location: string): boolean {
|
|
172
|
+
return resolveRedirectUrl(fromUrl, location) !== null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Pure redirect decision: follow (with the resolved absolute URL) or stop with
|
|
176
|
+
// a user-facing reason. Never throws.
|
|
177
|
+
export function resolveAllowedRedirect(
|
|
178
|
+
fromUrl: string,
|
|
179
|
+
location: string | null,
|
|
180
|
+
hopsFollowed: number,
|
|
181
|
+
): RedirectDecision {
|
|
182
|
+
if (hopsFollowed >= MAX_REDIRECT_HOPS) {
|
|
183
|
+
return { follow: false, reason: `too many redirects (limit ${MAX_REDIRECT_HOPS})` };
|
|
184
|
+
}
|
|
185
|
+
if (location === null || location.trim().length === 0) {
|
|
186
|
+
return { follow: false, reason: "redirect without a Location header" };
|
|
187
|
+
}
|
|
188
|
+
const next = resolveRedirectUrl(fromUrl, location);
|
|
189
|
+
if (next === null) {
|
|
190
|
+
const host = redirectTargetHost(fromUrl, location);
|
|
191
|
+
return {
|
|
192
|
+
follow: false,
|
|
193
|
+
reason: `redirect blocked (target not allowed${host ? `: ${host}` : ""})`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return { follow: true, url: next.toString() };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// TUI: display-mode + snapshot shaping
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
export type DisplayMode = "sidebar" | "statusline" | "both";
|
|
204
|
+
|
|
205
|
+
export type SurfaceSelection = {
|
|
206
|
+
sidebar: boolean;
|
|
207
|
+
statusline: boolean;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// One label/value row, the grammar the host's own panels use.
|
|
211
|
+
export type UsageRow = {
|
|
212
|
+
label: string;
|
|
213
|
+
value: string;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
export function isDisplayMode(value: unknown): value is DisplayMode {
|
|
217
|
+
return value === "sidebar" || value === "statusline" || value === "both";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Where the Go block sits in Kilo's sidebar ladder:
|
|
221
|
+
// - `integrated` — takes over the host's own token-usage band and retires
|
|
222
|
+
// its panel, so both usage readouts are one block instead of two.
|
|
223
|
+
// - `standalone` — a free band of our own, host panel untouched.
|
|
224
|
+
export type SidebarMode = "integrated" | "standalone";
|
|
225
|
+
|
|
226
|
+
export const DEFAULT_SIDEBAR_MODE: SidebarMode = "integrated";
|
|
227
|
+
|
|
228
|
+
// Tolerant like `parseBooleanFlag`, because the same value reaches us from a
|
|
229
|
+
// hand-written env var and from typed JSON: null means "unset or unrecognized",
|
|
230
|
+
// and the caller keeps falling through to the next source.
|
|
231
|
+
export function parseSidebarMode(value: unknown): SidebarMode | null {
|
|
232
|
+
if (typeof value !== "string") return null;
|
|
233
|
+
const normalized = value.trim().toLowerCase();
|
|
234
|
+
return normalized === "integrated" || normalized === "standalone" ? normalized : null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Which provider a session is actually running, for the Go-only display gate.
|
|
238
|
+
// Pure so the precedence is unit-testable: the TUI entry reads this on every
|
|
239
|
+
// render rather than latching a value at init.
|
|
240
|
+
export type ProviderSource = {
|
|
241
|
+
config?: { model?: unknown } | undefined;
|
|
242
|
+
session?: { get?: ((sessionID: string) => { model?: { providerID?: unknown } | undefined } | undefined) | undefined } | undefined;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export function providerIdFromModel(model: unknown): string | undefined {
|
|
246
|
+
if (typeof model !== "string" || model.length === 0) return undefined;
|
|
247
|
+
const provider = model.split("/")[0];
|
|
248
|
+
return provider !== undefined && provider.length > 0 ? provider : undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function resolveProviderId(
|
|
252
|
+
state: ProviderSource | undefined,
|
|
253
|
+
sessionId: string,
|
|
254
|
+
fallback: string | undefined,
|
|
255
|
+
): string | undefined {
|
|
256
|
+
// The session's own model is the one in use, so it outranks the config default.
|
|
257
|
+
try {
|
|
258
|
+
const fromSession = state?.session?.get?.(sessionId)?.model?.providerID;
|
|
259
|
+
if (typeof fromSession === "string" && fromSession.length > 0) return fromSession;
|
|
260
|
+
} catch {
|
|
261
|
+
// Fall through: a throwing store is not a reason to hide the panel.
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const fromConfig = providerIdFromModel(state?.config?.model);
|
|
265
|
+
if (fromConfig !== undefined) return fromConfig;
|
|
266
|
+
} catch {
|
|
267
|
+
// Fall through to the event-signal fallback.
|
|
268
|
+
}
|
|
269
|
+
return fallback;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function surfaceSelectionFromDisplayMode(mode: DisplayMode): SurfaceSelection {
|
|
273
|
+
return { sidebar: mode !== "statusline", statusline: mode !== "sidebar" };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function parseBooleanFlag(value: unknown): boolean | null {
|
|
277
|
+
if (typeof value === "boolean") return value;
|
|
278
|
+
if (typeof value === "number") {
|
|
279
|
+
if (value === 1) return true;
|
|
280
|
+
if (value === 0) return false;
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
if (typeof value !== "string") return null;
|
|
284
|
+
const normalized = value.trim().toLowerCase();
|
|
285
|
+
if (normalized === "1" || normalized === "true") return true;
|
|
286
|
+
if (normalized === "0" || normalized === "false") return false;
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function isSnapshotEmpty(snapshot: UsageSnapshot): boolean {
|
|
291
|
+
return snapshot.rolling === null && snapshot.weekly === null && snapshot.monthly === null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// The countdown the plan is currently waiting on, as the label of the window it
|
|
295
|
+
// belongs to plus the text to print. `null` when the plan has no usable reset
|
|
296
|
+
// at all, and every surface then prints nothing rather than a placeholder.
|
|
297
|
+
export type ResetCountdown = { label: string; text: string };
|
|
298
|
+
|
|
299
|
+
// Which window's countdown the plan is waiting on. One decision, read by the
|
|
300
|
+
// sidebar's reset line, by the per-row suffixes under a capped meter and by the
|
|
301
|
+
// statusline, so no two of them can name different windows.
|
|
302
|
+
//
|
|
303
|
+
// A capped window outranks a nearer one, and the longest cap outranks the
|
|
304
|
+
// shorter ones: an exhausted `30d` is what actually stops work, so a `5h` that
|
|
305
|
+
// rolls over in two hours is not the fact worth the cells. With nothing capped
|
|
306
|
+
// the soonest reset is the answer, because that is the first moment the
|
|
307
|
+
// percentages printed beside it will move.
|
|
308
|
+
//
|
|
309
|
+
// The capped case is read back out of `buildPlanRows` instead of re-deriving
|
|
310
|
+
// what "capped" means, so the statusline cannot drift from the sidebar. Only
|
|
311
|
+
// `resetInSec` is ever compared; a window that carries nothing but the host's
|
|
312
|
+
// free text can be printed once it has been chosen, never compared as a string.
|
|
313
|
+
export function relevantReset(snapshot: UsageSnapshot): ResetCountdown | null {
|
|
314
|
+
const rows = buildPlanRows(snapshot);
|
|
315
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
316
|
+
const row = rows[i];
|
|
317
|
+
if (row?.reset != null) return { label: row.label, text: row.reset };
|
|
318
|
+
}
|
|
319
|
+
return soonestReset(snapshot);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// The soonest reset across the windows, capped or not. A stale payload, clock
|
|
323
|
+
// skew or a reset that fired mid-flight can all send a negative span, and an
|
|
324
|
+
// elapsed countdown is as unusable as an unreadable one.
|
|
325
|
+
function soonestReset(snapshot: UsageSnapshot): ResetCountdown | null {
|
|
326
|
+
const windows: ReadonlyArray<readonly [string, UsageWindow | null]> = [
|
|
327
|
+
["5h", snapshot.rolling],
|
|
328
|
+
["7d", snapshot.weekly],
|
|
329
|
+
["30d", snapshot.monthly],
|
|
330
|
+
];
|
|
331
|
+
let soonest: { label: string; seconds: number } | null = null;
|
|
332
|
+
for (const [name, window] of windows) {
|
|
333
|
+
if (window === null) continue;
|
|
334
|
+
const seconds = window.resetInSec;
|
|
335
|
+
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) continue;
|
|
336
|
+
if (soonest !== null && seconds >= soonest.seconds) continue;
|
|
337
|
+
soonest = { label: name, seconds };
|
|
338
|
+
}
|
|
339
|
+
if (soonest === null) return null;
|
|
340
|
+
const text = formatResetDuration(soonest.seconds);
|
|
341
|
+
return text === null ? null : { label: soonest.label, text };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function formatStatusline(snapshot: UsageSnapshot): string {
|
|
345
|
+
const rolling = snapshot.rolling === null ? "5h n/a" : `5h ${snapshot.rolling.percent}%`;
|
|
346
|
+
const weekly = snapshot.weekly === null ? "7d n/a" : `7d ${snapshot.weekly.percent}%`;
|
|
347
|
+
const monthly = snapshot.monthly === null ? "30d n/a" : `30d ${snapshot.monthly.percent}%`;
|
|
348
|
+
const windows = `Go ${rolling} | ${weekly} | ${monthly}`;
|
|
349
|
+
// The same countdown the sidebar shows, without its window label: the
|
|
350
|
+
// percentages are right there in the same order, so naming the window again
|
|
351
|
+
// would spend cells restating what the line already says.
|
|
352
|
+
const reset = relevantReset(snapshot);
|
|
353
|
+
return reset === null ? windows : `${windows} · resets in ${reset.text}`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// TUI: plan meters (gauge fill + threshold severity)
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
//
|
|
361
|
+
// Severity is a name, not a color: this module is inlined into the server
|
|
362
|
+
// bundle too, and only the TUI entry can resolve it against a theme.
|
|
363
|
+
|
|
364
|
+
export type UsageMeterSeverity = "muted" | "warning" | "error";
|
|
365
|
+
|
|
366
|
+
const WARNING_PERCENT = 75;
|
|
367
|
+
const ERROR_PERCENT = 90;
|
|
368
|
+
|
|
369
|
+
// A capped window is a hard stop rather than a percentage: `limited` wins over
|
|
370
|
+
// whatever the gauge says, so a window that is capped at 3% still reads as error
|
|
371
|
+
// instead of reassuring the eye.
|
|
372
|
+
export function usageMeterSeverity(window: UsageWindow): UsageMeterSeverity {
|
|
373
|
+
if (window.limited) return "error";
|
|
374
|
+
return meterSeverityForPercent(window.percent);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// The threshold ladder on its own, so a gauge that is not backed by a plan
|
|
378
|
+
// window (the per-model Go share) colors by the same rules.
|
|
379
|
+
export function meterSeverityForPercent(percent: number): UsageMeterSeverity {
|
|
380
|
+
if (!Number.isFinite(percent)) return "muted";
|
|
381
|
+
if (percent >= ERROR_PERCENT) return "error";
|
|
382
|
+
if (percent >= WARNING_PERCENT) return "warning";
|
|
383
|
+
return "muted";
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// The meter's filled fraction, clamped to 0-100: the API can report a percent
|
|
387
|
+
// outside the range and a NaN would be a layout error rather than a drawing one.
|
|
388
|
+
//
|
|
389
|
+
// The meter itself is TWO BOXES -- a filled one at this percentage and a track
|
|
390
|
+
// for the rest -- not a string of block glyphs. A glyph string has a fixed cell
|
|
391
|
+
// count, and a plugin cannot measure the sidebar it is rendering into (opentui
|
|
392
|
+
// resolves a text node's `width` as a wrapping bound, and no layout callback
|
|
393
|
+
// reaches a plugin), so a glyph meter is either too short for a wide sidebar or
|
|
394
|
+
// clipped by a narrow one. Boxes fill whatever the row gives them, which is why
|
|
395
|
+
// the meter now reaches the sidebar's right edge on any host and any width.
|
|
396
|
+
export function meterFillPercent(percent: number): number {
|
|
397
|
+
if (!Number.isFinite(percent)) return 0;
|
|
398
|
+
return Math.min(Math.max(percent, 0), 100);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export type PlanRow = {
|
|
402
|
+
label: string;
|
|
403
|
+
percent: number;
|
|
404
|
+
severity: UsageMeterSeverity;
|
|
405
|
+
reset: string | null;
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
// `meterWidth` is the sidebar's budget, not the bar's design: 16 cells fit next
|
|
409
|
+
// to Kilo's model table, 10 fit opencode's ~30-cell sidebar. The glyphs and the
|
|
410
|
+
// saturating fill are identical, so two widths are the same bar at two sizes.
|
|
411
|
+
export function buildPlanRows(snapshot: UsageSnapshot): PlanRow[] {
|
|
412
|
+
const windows: ReadonlyArray<readonly [string, UsageWindow | null]> = [
|
|
413
|
+
["5h", snapshot.rolling],
|
|
414
|
+
["7d", snapshot.weekly],
|
|
415
|
+
["30d", snapshot.monthly],
|
|
416
|
+
];
|
|
417
|
+
const rows: PlanRow[] = [];
|
|
418
|
+
for (const [label, window] of windows) {
|
|
419
|
+
if (window === null) continue;
|
|
420
|
+
rows.push({
|
|
421
|
+
label,
|
|
422
|
+
percent: window.percent,
|
|
423
|
+
severity: usageMeterSeverity(window),
|
|
424
|
+
// A countdown only tells the user something once the window is capped;
|
|
425
|
+
// before that the schedule is noise, and the header rows already show it.
|
|
426
|
+
reset: window.limited ? formatResetDuration(window.resetInSec) ?? window.resetText : null,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
return rows;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
// TUI: session model usage (the integrated panel's replacement for Kilo's own
|
|
434
|
+
// token-usage band)
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
//
|
|
437
|
+
// The host hands over raw counts and a USD cost with no display metadata, so
|
|
438
|
+
// every number crosses a formatter. The formatters below guard first: a bare
|
|
439
|
+
// `Intl.NumberFormat` prints NaN as "NaN" and Infinity as "∞", and either one
|
|
440
|
+
// silently stretches a sidebar column that is width-budgeted by the host.
|
|
441
|
+
|
|
442
|
+
const COUNT_FORMAT = new Intl.NumberFormat("en-US");
|
|
443
|
+
const CURRENCY_FORMAT = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
|
|
444
|
+
|
|
445
|
+
export function formatUsageCount(value: unknown): string {
|
|
446
|
+
return COUNT_FORMAT.format(toUsageCount(value));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function formatUsageCost(value: unknown): string {
|
|
450
|
+
return CURRENCY_FORMAT.format(toUsageCount(value));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Every bucket a token can land in. Cache is included because cached reads and
|
|
454
|
+
// writes are billed work too; excluding them would make the per-model totals
|
|
455
|
+
// disagree with the host's own `totals` row.
|
|
456
|
+
export function usageTokenCount(tokens: UsageTokens): number {
|
|
457
|
+
return (
|
|
458
|
+
toUsageCount(tokens.input) +
|
|
459
|
+
toUsageCount(tokens.output) +
|
|
460
|
+
toUsageCount(tokens.reasoning) +
|
|
461
|
+
toUsageCount(tokens.cache.read) +
|
|
462
|
+
toUsageCount(tokens.cache.write)
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Kilo's cache rate: the read share of the three buckets a cache hit can be
|
|
467
|
+
// served from. Output and reasoning are excluded on purpose — neither can be
|
|
468
|
+
// cached, so counting them would deflate the rate towards a value that means
|
|
469
|
+
// nothing.
|
|
470
|
+
export function cacheRatePercent(tokens: UsageTokens): string {
|
|
471
|
+
const denominator =
|
|
472
|
+
toUsageCount(tokens.input) + toUsageCount(tokens.cache.read) + toUsageCount(tokens.cache.write);
|
|
473
|
+
if (denominator === 0) return CACHE_RATE_EMPTY;
|
|
474
|
+
return `${((toUsageCount(tokens.cache.read) / denominator) * 100).toFixed(CACHE_RATE_DECIMALS)}%`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// A model's share of the Go tokens spent in this session tree. This is a share
|
|
478
|
+
// of Go tokens and nothing else: it is not a share of the plan, a quota, or a
|
|
479
|
+
// price, and it never carries a currency. A zero total is 0% rather than NaN so
|
|
480
|
+
// the row still renders before any Go tokens are attributed.
|
|
481
|
+
export function goSharePercent(modelTokens: unknown, totalGoTokens: unknown): number {
|
|
482
|
+
const total = toUsageCount(totalGoTokens);
|
|
483
|
+
if (total === 0) return 0;
|
|
484
|
+
return (toUsageCount(modelTokens) / total) * 100;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function totalGoTokens(models: readonly ModelUsage[]): number {
|
|
488
|
+
let total = 0;
|
|
489
|
+
for (const model of models) {
|
|
490
|
+
if (model.providerID !== GO_PROVIDER_ID) continue;
|
|
491
|
+
total += usageTokenCount(model.tokens);
|
|
492
|
+
}
|
|
493
|
+
return total;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export type ModelProviderGroup = {
|
|
497
|
+
providerID: string;
|
|
498
|
+
providerName: string;
|
|
499
|
+
models: ModelUsage[];
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
// Group in first-seen order (the host's own `models[]` order), which keeps the
|
|
503
|
+
// active provider on top. A provider with no catalog entry falls back to its
|
|
504
|
+
// raw id rather than dropping its models.
|
|
505
|
+
export function groupModelsByProvider(
|
|
506
|
+
models: readonly ModelUsage[],
|
|
507
|
+
providerNames: ReadonlyMap<string, string> = new Map(),
|
|
508
|
+
): ModelProviderGroup[] {
|
|
509
|
+
const groups = new Map<string, ModelProviderGroup>();
|
|
510
|
+
for (const model of models) {
|
|
511
|
+
const group = groups.get(model.providerID) ?? {
|
|
512
|
+
providerID: model.providerID,
|
|
513
|
+
providerName: providerNames.get(model.providerID) ?? model.providerID,
|
|
514
|
+
models: [],
|
|
515
|
+
};
|
|
516
|
+
group.models.push(model);
|
|
517
|
+
groups.set(model.providerID, group);
|
|
518
|
+
}
|
|
519
|
+
return [...groups.values()];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Kilo's truncation: a single ellipsis appended to a fixed-width budget, never
|
|
523
|
+
// a trailing "..." that would make the budget worth two extra cells.
|
|
524
|
+
export function truncateModelName(value: string, max: number = KILO_MODEL_NAME_MAX_CHARS): string {
|
|
525
|
+
if (value.length <= max) return value;
|
|
526
|
+
const budget = Math.max(max - 1, 0);
|
|
527
|
+
return `${value.slice(0, budget)}…`;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Kilo's display normalization, applied before truncation: strip a `vendor:`
|
|
531
|
+
// prefix, a lone leading `vendor/`, and a trailing discount badge, then split a
|
|
532
|
+
// letter run that runs straight into digits. Without it a catalog name like
|
|
533
|
+
// "anthropic/claude-sonnet-4" spends the whole 19-cell budget on the vendor.
|
|
534
|
+
export function displayModelName(name: string): string {
|
|
535
|
+
return name
|
|
536
|
+
.trim()
|
|
537
|
+
.replace(/^[^:]+:\s+/, "")
|
|
538
|
+
.replace(/^[^/\s]+\/(?=[^/]+$)/, "")
|
|
539
|
+
.replace(/\s*\([^)]*%\s*off[^)]*\)\s*$/i, "")
|
|
540
|
+
.replace(/^([A-Za-z]{2,})(?=\d)/, "$1 ")
|
|
541
|
+
.replace(/\s+/g, " ");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function modelDisplayName(catalogName: string | null, modelID: string, max: number = KILO_MODEL_NAME_MAX_CHARS): string {
|
|
545
|
+
const raw = toNonEmptyString(catalogName) ?? toNonEmptyString(modelID) ?? "";
|
|
546
|
+
return truncateModelName(displayModelName(raw), max);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// The first segment of a model name, for a mix line that has to fit several of
|
|
550
|
+
// them in one line: `mimo-v2.6-pro` -> `mimo`, `qwen3-max` -> `qwen3`, `gpt-5.1`
|
|
551
|
+
// -> `gpt`. A name with no separator is already short and is only truncated.
|
|
552
|
+
// Lowercased: the rest of the sidebar is lowercase, and the mix line is a
|
|
553
|
+
// ranking of three labels rather than three model names to copy out of.
|
|
554
|
+
export function shortModelName(value: string, max: number = GO_MODEL_MIX_NAME_MAX_CHARS): string {
|
|
555
|
+
const trimmed = value.trim().toLowerCase();
|
|
556
|
+
if (trimmed.length === 0) return "";
|
|
557
|
+
const head = trimmed.split(/[-._/]/)[0] ?? trimmed;
|
|
558
|
+
return truncateModelName(head, max);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// The plan window label cell ("5h", "7d", "30d"): exactly the longest label the
|
|
562
|
+
// plan has, so every meter starts on the same column and none of the width goes
|
|
563
|
+
// to padding.
|
|
564
|
+
export const PLAN_LABEL_WIDTH = 3;
|
|
565
|
+
|
|
566
|
+
// A percent padded into a fixed-width cell, right-aligned by construction. A
|
|
567
|
+
// non-finite percent renders as 0 rather than "NaN%", which would break the
|
|
568
|
+
// column it sits in.
|
|
569
|
+
export function formatPercentCell(percent: number, width: number = PERCENT_CELL_WIDTH): string {
|
|
570
|
+
const cells = Math.max(Math.floor(width), 1);
|
|
571
|
+
const rounded = Number.isFinite(percent) ? Math.round(percent) : 0;
|
|
572
|
+
return `${`${rounded}%`}`.padStart(cells, " ");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// A token count as a weight, not an invoice: short enough to sit next to a bar
|
|
576
|
+
// in a 30-cell sidebar, precise enough to rank models. `610K` rather than
|
|
577
|
+
// `0.61M` — below a million the integer form is both shorter and the one people
|
|
578
|
+
// say out loud.
|
|
579
|
+
export function formatTokenCount(value: unknown): string {
|
|
580
|
+
const count = toUsageCount(value);
|
|
581
|
+
if (count >= 1_000_000_000) return `${trimTrailingZeros((count / 1_000_000_000).toFixed(2))}B`;
|
|
582
|
+
if (count >= 1_000_000) return `${trimTrailingZeros((count / 1_000_000).toFixed(2))}M`;
|
|
583
|
+
if (count >= 1_000) return `${Math.round(count / 1_000)}K`;
|
|
584
|
+
return COUNT_FORMAT.format(count);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// `1.20M` claims a precision the API never had, and spends a cell on it. Only
|
|
588
|
+
// `toFixed` output goes through this, which always carries a decimal point -- so
|
|
589
|
+
// the trailing zeros it strips are always fractional ones, never `100`.
|
|
590
|
+
function trimTrailingZeros(value: string): string {
|
|
591
|
+
return value.replace(/\.?0+$/, "");
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
// TUI: per-model weights (the opencode sidebar's model mix)
|
|
596
|
+
// ---------------------------------------------------------------------------
|
|
597
|
+
//
|
|
598
|
+
// A model's WEIGHT is its share of the Go tokens spent in this session. It is
|
|
599
|
+
// not a share of the plan, a quota, or a price, and it never carries a currency:
|
|
600
|
+
// the plan's absolute limits are not client-visible, so a number that looked
|
|
601
|
+
// like one would be a guess dressed as a measurement. The bars reuse the plan's
|
|
602
|
+
// glyphs and the same threshold coloring, which is why a heavily used model
|
|
603
|
+
// paints like a hot plan window.
|
|
604
|
+
|
|
605
|
+
export type GoModelWeight = {
|
|
606
|
+
providerID: string;
|
|
607
|
+
modelID: string;
|
|
608
|
+
tokens: number;
|
|
609
|
+
share: number;
|
|
610
|
+
steps: number;
|
|
611
|
+
cost: number;
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
export type GoModelWeights = {
|
|
615
|
+
// The heaviest Go models, already ranked and sliced to `limit`.
|
|
616
|
+
models: GoModelWeight[];
|
|
617
|
+
// How many Go models the session used in total, so a section titled
|
|
618
|
+
// `Top Go models (7)` above three rows is explained by the rows themselves.
|
|
619
|
+
goModels: number;
|
|
620
|
+
goTokens: number;
|
|
621
|
+
listedTokens: number;
|
|
622
|
+
listedShare: number;
|
|
623
|
+
otherCount: number;
|
|
624
|
+
// Session totals over EVERY Go model, not just the listed ones: the footer is
|
|
625
|
+
// about the session, the rows are about the ranking.
|
|
626
|
+
steps: number;
|
|
627
|
+
cost: number;
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
// The heaviest Go models by token count. Ties break on steps and then on the
|
|
631
|
+
// model id, so the same session always ranks the same way — a list that reshuffles
|
|
632
|
+
// between renders would make the weights unreadable. `otherCount` counts the
|
|
633
|
+
// models the limit dropped, and `goTokens`/`steps`/`cost` cover all of them.
|
|
634
|
+
export function weightGoModels(
|
|
635
|
+
models: readonly ModelUsage[],
|
|
636
|
+
limit: number = TOP_GO_MODELS_LIMIT,
|
|
637
|
+
): GoModelWeights {
|
|
638
|
+
const goModels = models.filter((model) => model.providerID === GO_PROVIDER_ID);
|
|
639
|
+
const goTokens = goModels.reduce((sum, model) => sum + usageTokenCount(model.tokens), 0);
|
|
640
|
+
const ranked = [...goModels].sort(
|
|
641
|
+
(left, right) =>
|
|
642
|
+
usageTokenCount(right.tokens) - usageTokenCount(left.tokens) ||
|
|
643
|
+
toUsageCount(right.steps) - toUsageCount(left.steps) ||
|
|
644
|
+
left.modelID.localeCompare(right.modelID),
|
|
645
|
+
);
|
|
646
|
+
const top = ranked.slice(0, Math.max(Math.floor(limit), 0));
|
|
647
|
+
const listedTokens = top.reduce((sum, model) => sum + usageTokenCount(model.tokens), 0);
|
|
648
|
+
return {
|
|
649
|
+
models: top.map((model) => {
|
|
650
|
+
const tokens = usageTokenCount(model.tokens);
|
|
651
|
+
const share = goSharePercent(tokens, goTokens);
|
|
652
|
+
return {
|
|
653
|
+
providerID: model.providerID,
|
|
654
|
+
modelID: model.modelID,
|
|
655
|
+
tokens,
|
|
656
|
+
share,
|
|
657
|
+
steps: toUsageCount(model.steps),
|
|
658
|
+
cost: toUsageCount(model.cost),
|
|
659
|
+
};
|
|
660
|
+
}),
|
|
661
|
+
goModels: goModels.length,
|
|
662
|
+
goTokens,
|
|
663
|
+
listedTokens,
|
|
664
|
+
listedShare: goSharePercent(listedTokens, goTokens),
|
|
665
|
+
otherCount: Math.max(goModels.length - top.length, 0),
|
|
666
|
+
steps: goModels.reduce((sum, model) => sum + toUsageCount(model.steps), 0),
|
|
667
|
+
cost: goModels.reduce((sum, model) => sum + toUsageCount(model.cost), 0),
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// The collapsed mix line: `mimo 59%·qwen 25%·gpt 12%`, as many entries as fit
|
|
672
|
+
// `budget` cells. Built cell by cell rather than joined from a fixed list because
|
|
673
|
+
// a narrow sidebar truncates mid-entry, and a half-written `qwen 2…` reads as a
|
|
674
|
+
// different number than `qwen 25%`.
|
|
675
|
+
//
|
|
676
|
+
// The leader is always included even when it alone overruns the budget: a mix
|
|
677
|
+
// line without the heaviest model is not a summary of anything, and the entry
|
|
678
|
+
// cannot actually be that wide because the names are capped (see
|
|
679
|
+
// `shortModelName`), so the budget only ever decides the second entry onwards.
|
|
680
|
+
export function buildModelMixSummary(
|
|
681
|
+
entries: ReadonlyArray<{ name: string; share: number }>,
|
|
682
|
+
budget: number = GO_MODEL_MIX_BUDGET,
|
|
683
|
+
): string {
|
|
684
|
+
const first = entries[0];
|
|
685
|
+
if (first === undefined) return "";
|
|
686
|
+
const parts: string[] = [];
|
|
687
|
+
let used = 0;
|
|
688
|
+
for (const entry of entries) {
|
|
689
|
+
const part = `${entry.name} ${Math.round(entry.share)}%`;
|
|
690
|
+
const width = part.length + (parts.length === 0 ? 0 : GO_MODEL_MIX_SEPARATOR.length);
|
|
691
|
+
if (parts.length > 0 && used + width > budget) break;
|
|
692
|
+
parts.push(part);
|
|
693
|
+
used += width;
|
|
694
|
+
}
|
|
695
|
+
return parts.join(GO_MODEL_MIX_SEPARATOR);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Two muted lines under the rows: what the listed models account for, and the
|
|
699
|
+
// session's own Go totals. Both are counts of this session's tokens and cost —
|
|
700
|
+
// never of the plan.
|
|
701
|
+
export function buildGoModelFooters(weights: GoModelWeights): string[] {
|
|
702
|
+
if (weights.models.length === 0) return [];
|
|
703
|
+
return [
|
|
704
|
+
`${formatTokenCount(weights.listedTokens)} of ${formatTokenCount(weights.goTokens)} Go tokens`,
|
|
705
|
+
`${formatUsageCount(weights.steps)} steps · ${formatUsageCost(weights.cost)}`,
|
|
706
|
+
];
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// One row per entry of KILO_TOKEN_USAGE_ROWS, in that order. A label with no
|
|
710
|
+
// value is dropped rather than rendered blank, so the row list and the constant
|
|
711
|
+
// can only disagree in a way the unit tier fails on.
|
|
712
|
+
export function buildTokenUsageRows(totals: { cost: number; tokens: UsageTokens }): UsageRow[] {
|
|
713
|
+
const values: Readonly<Record<string, string>> = {
|
|
714
|
+
Input: formatUsageCount(totals.tokens.input),
|
|
715
|
+
Output: formatUsageCount(totals.tokens.output),
|
|
716
|
+
Reasoning: formatUsageCount(totals.tokens.reasoning),
|
|
717
|
+
"Cache read": formatUsageCount(totals.tokens.cache.read),
|
|
718
|
+
"Cache write": formatUsageCount(totals.tokens.cache.write),
|
|
719
|
+
"Cache rate": cacheRatePercent(totals.tokens),
|
|
720
|
+
Cost: formatUsageCost(totals.cost),
|
|
721
|
+
};
|
|
722
|
+
return KILO_TOKEN_USAGE_ROWS.flatMap((label) => {
|
|
723
|
+
const value = values[label];
|
|
724
|
+
return value === undefined ? [] : [{ label, value }];
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// The per-model token breakdown shown when a model row is expanded: the same
|
|
729
|
+
// labels and order as the totals block, minus `Cost`, which the model row
|
|
730
|
+
// already carries in its own cost column.
|
|
731
|
+
export function buildModelTokenRows(tokens: UsageTokens): UsageRow[] {
|
|
732
|
+
return buildTokenUsageRows({ cost: 0, tokens }).filter((row) => row.label !== "Cost");
|
|
733
|
+
}
|