opencode-usage-report 0.2.0 → 0.4.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 +218 -73
- package/package.json +43 -35
- package/src/auth.ts +30 -7
- package/src/cache.ts +2 -2
- package/src/context/collect.ts +251 -0
- package/src/context/dialog.tsx +184 -0
- package/src/context/estimate.ts +36 -0
- package/src/context/format.ts +272 -0
- package/src/context/grid.ts +90 -0
- package/src/context/headroom.ts +42 -0
- package/src/context/system.ts +147 -0
- package/src/context/types.ts +51 -0
- package/src/fallback.ts +2 -2
- package/src/index.ts +33 -15
- package/src/normalize.ts +1 -1
- package/src/providers/chatgpt.ts +194 -0
- package/src/providers/copilot.ts +234 -0
- package/src/providers/index.ts +15 -5
- package/src/providers/kimi.ts +6 -10
- package/src/providers/opencode-go.ts +9 -6
- package/src/quota-lines.ts +116 -0
- package/src/render.ts +10 -5
- package/src/report.ts +11 -8
- package/src/tui-format.ts +2 -4
- package/src/tui.tsx +98 -127
- package/src/types.ts +6 -1
- package/src/warn.ts +5 -4
package/src/report.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { PluginOptions, ProviderAdapter, ProviderReport } from "./types
|
|
2
|
-
import { adapters, getAdapter } from "./providers/index
|
|
3
|
-
import { redact, resolveCredential } from "./auth
|
|
4
|
-
import { ensureSessionId, readCache, writeCache } from "./cache
|
|
5
|
-
import type { CacheEntry } from "./cache
|
|
6
|
-
import { localEstimate } from "./fallback
|
|
1
|
+
import type { PluginOptions, ProviderAdapter, ProviderReport } from "./types";
|
|
2
|
+
import { adapters, getAdapter } from "./providers/index";
|
|
3
|
+
import { redact, resolveCredential } from "./auth";
|
|
4
|
+
import { ensureSessionId, readCache, writeCache } from "./cache";
|
|
5
|
+
import type { CacheEntry } from "./cache";
|
|
6
|
+
import { localEstimate } from "./fallback";
|
|
7
7
|
|
|
8
8
|
export const DEFAULT_OPTIONS: PluginOptions = {
|
|
9
9
|
thresholdPercent: 80,
|
|
@@ -102,7 +102,10 @@ async function collectOne(
|
|
|
102
102
|
error: null,
|
|
103
103
|
};
|
|
104
104
|
} catch (err) {
|
|
105
|
-
failure = redact(
|
|
105
|
+
failure = redact(
|
|
106
|
+
redact(err instanceof Error ? err.message : String(err), cred.key),
|
|
107
|
+
cred.accountId ?? null,
|
|
108
|
+
);
|
|
106
109
|
}
|
|
107
110
|
|
|
108
111
|
const cached = await readCache(id, options.cacheTtlSeconds, env);
|
|
@@ -136,7 +139,7 @@ async function collectOne(
|
|
|
136
139
|
* `source: "error"` rows.
|
|
137
140
|
*/
|
|
138
141
|
export async function collectReports(opts: CollectReportsOptions = {}): Promise<ProviderReport[]> {
|
|
139
|
-
const options: PluginOptions = { ...DEFAULT_OPTIONS, ...
|
|
142
|
+
const options: PluginOptions = { ...DEFAULT_OPTIONS, ...opts.options };
|
|
140
143
|
const env = opts.env ?? process.env;
|
|
141
144
|
const refresh = opts.refresh === true;
|
|
142
145
|
|
package/src/tui-format.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Pure formatting helpers for the TUI sidebar usage panel. No JSX, no Solid
|
|
3
3
|
* imports (the only module dependency is the shared `DEFAULT_OPTIONS`).
|
|
4
4
|
*/
|
|
5
|
-
import { DEFAULT_OPTIONS } from "./report
|
|
5
|
+
import { DEFAULT_OPTIONS } from "./report";
|
|
6
6
|
|
|
7
7
|
export type ToneName = "success" | "warning" | "error" | "textMuted";
|
|
8
8
|
|
|
@@ -160,9 +160,7 @@ function toFiniteNumber(value: unknown): number | null {
|
|
|
160
160
|
* `barWidth` is floored and clamped to 4..40; `refreshIntervalSeconds` is
|
|
161
161
|
* rounded and clamped to 5..3600 so the timer can never hot-loop or overflow.
|
|
162
162
|
*/
|
|
163
|
-
export function coerceTuiOptions(
|
|
164
|
-
raw: Record<string, unknown> | undefined,
|
|
165
|
-
): ResolvedTuiOptions {
|
|
163
|
+
export function coerceTuiOptions(raw: Record<string, unknown> | undefined): ResolvedTuiOptions {
|
|
166
164
|
const opts: ResolvedTuiOptions = {
|
|
167
165
|
cacheTtlSeconds: DEFAULT_OPTIONS.cacheTtlSeconds,
|
|
168
166
|
thresholdPercent: DEFAULT_OPTIONS.thresholdPercent,
|
package/src/tui.tsx
CHANGED
|
@@ -6,129 +6,20 @@ import type {
|
|
|
6
6
|
TuiSlotPlugin,
|
|
7
7
|
} from "@opencode-ai/plugin/tui";
|
|
8
8
|
import { For, createSignal } from "solid-js";
|
|
9
|
-
import { DEFAULT_OPTIONS, collectReports } from "./report
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
interface Segment {
|
|
24
|
-
text: string;
|
|
25
|
-
fg: ThemeColor;
|
|
26
|
-
bold?: boolean;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
interface Line {
|
|
30
|
-
segments: Segment[];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const NAMED_WIDTH = 14;
|
|
34
|
-
const EXTRAS_MAX_LENGTH = 80;
|
|
35
|
-
|
|
36
|
-
/** Order windows for display; unknown kinds sort last. */
|
|
37
|
-
const WINDOW_ORDER: Record<WindowKind, number> = {
|
|
38
|
-
"5h": 0,
|
|
39
|
-
weekly: 1,
|
|
40
|
-
monthly: 2,
|
|
41
|
-
daily: 3,
|
|
42
|
-
other: 4,
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
function windowRank(kind: WindowKind): number {
|
|
46
|
-
return WINDOW_ORDER[kind] ?? WINDOW_ORDER.other;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Collapses whitespace and truncates to a single-line label. */
|
|
50
|
-
function clip(value: string, max = 60): string {
|
|
51
|
-
const single = value.replace(/\s+/g, " ").trim();
|
|
52
|
-
if (single.length <= max) return single;
|
|
53
|
-
return single.slice(0, Math.max(0, max - 1)) + "…";
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Pads a label to a fixed width, truncating (with a trailing space) when long. */
|
|
57
|
-
function alignLabel(label: string): string {
|
|
58
|
-
const text =
|
|
59
|
-
label.length > NAMED_WIDTH ? label.slice(0, NAMED_WIDTH - 1) + " " : label;
|
|
60
|
-
return text.padEnd(NAMED_WIDTH);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Flattens the current signal state into plain, pre-colored text lines. */
|
|
64
|
-
function buildLines(
|
|
65
|
-
theme: Theme,
|
|
66
|
-
reports: ProviderReport[],
|
|
67
|
-
busy: boolean,
|
|
68
|
-
error: string | null,
|
|
69
|
-
now: Date,
|
|
70
|
-
barWidth: number,
|
|
71
|
-
): Line[] {
|
|
72
|
-
const lines: Line[] = [];
|
|
73
|
-
|
|
74
|
-
if (error !== null) {
|
|
75
|
-
lines.push({
|
|
76
|
-
segments: [{ text: `api error: ${clip(error)}`, fg: theme.error }],
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (reports.length === 0 && busy) {
|
|
81
|
-
lines.push({
|
|
82
|
-
segments: [{ text: "loading…", fg: theme.textMuted }],
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
for (const report of reports) {
|
|
87
|
-
const nameColor = report.source === "error" ? theme.textMuted : theme.accent;
|
|
88
|
-
const segments: Segment[] = [{ text: report.displayName, fg: nameColor, bold: true }];
|
|
89
|
-
if (report.stale) segments.push({ text: " (stale)", fg: theme.textMuted });
|
|
90
|
-
if (report.source === "local-estimate") {
|
|
91
|
-
segments.push({ text: " (est)", fg: theme.textMuted });
|
|
92
|
-
}
|
|
93
|
-
lines.push({ segments });
|
|
94
|
-
|
|
95
|
-
if (report.source === "error") {
|
|
96
|
-
lines.push({
|
|
97
|
-
segments: [
|
|
98
|
-
{ text: clip(report.error ?? "unknown error"), fg: theme.error },
|
|
99
|
-
],
|
|
100
|
-
});
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const windows = [...report.windows].sort(
|
|
105
|
-
(a, b) => windowRank(a.kind) - windowRank(b.kind),
|
|
106
|
-
);
|
|
107
|
-
for (const w of windows) {
|
|
108
|
-
const tone = toneFor(w.usedPercent, w.status);
|
|
109
|
-
const row: Segment[] = [
|
|
110
|
-
{ text: alignLabel(windowLabel(w.kind, w.label)), fg: theme.textMuted },
|
|
111
|
-
{ text: bar(w.usedPercent, barWidth), fg: theme[tone] },
|
|
112
|
-
{ text: " ", fg: theme.text },
|
|
113
|
-
{ text: percentText(w), fg: theme.text },
|
|
114
|
-
];
|
|
115
|
-
const reset = countdown(w.resetsAt, now);
|
|
116
|
-
if (reset !== null) {
|
|
117
|
-
row.push({ text: ` resets in ${reset}`, fg: theme.textMuted });
|
|
118
|
-
}
|
|
119
|
-
lines.push({ segments: row });
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
for (const [key, value] of Object.entries(report.extras)) {
|
|
123
|
-
lines.push({
|
|
124
|
-
segments: [
|
|
125
|
-
{ text: clip(`${key}: ${value}`, EXTRAS_MAX_LENGTH), fg: theme.textMuted },
|
|
126
|
-
],
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return lines;
|
|
9
|
+
import { DEFAULT_OPTIONS, collectReports } from "./report";
|
|
10
|
+
import { coerceTuiOptions } from "./tui-format";
|
|
11
|
+
import { buildLines } from "./quota-lines";
|
|
12
|
+
import { UsageDialog } from "./context/dialog";
|
|
13
|
+
import { readCapture } from "./context/system";
|
|
14
|
+
import type { SystemCapture } from "./context/types";
|
|
15
|
+
import type { ProviderReport } from "./types";
|
|
16
|
+
|
|
17
|
+
/** Resolves the session the `/usage` dialog should describe, if any. */
|
|
18
|
+
function activeSessionID(api: TuiPluginApi): string | null {
|
|
19
|
+
const route = api.route.current;
|
|
20
|
+
if (route.name !== "session") return null;
|
|
21
|
+
const id: unknown = route.params?.sessionID;
|
|
22
|
+
return typeof id === "string" ? id : null;
|
|
132
23
|
}
|
|
133
24
|
|
|
134
25
|
export const tui: TuiPlugin = async (api, rawOptions) => {
|
|
@@ -138,11 +29,13 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
138
29
|
const [inFlight, setInFlight] = createSignal(0);
|
|
139
30
|
const [error, setError] = createSignal<string | null>(null);
|
|
140
31
|
const [tick, setTick] = createSignal(Date.now());
|
|
32
|
+
const [capture, setCapture] = createSignal<SystemCapture | null>(null);
|
|
33
|
+
const [scope, setScope] = createSignal(0);
|
|
34
|
+
const [showJson, setShowJson] = createSignal(false);
|
|
141
35
|
|
|
142
36
|
const busy = (): boolean => inFlight() > 0;
|
|
143
37
|
|
|
144
38
|
let disposed = false;
|
|
145
|
-
|
|
146
39
|
const load = async (refresh = false): Promise<void> => {
|
|
147
40
|
if (disposed) return;
|
|
148
41
|
setInFlight((n) => n + 1);
|
|
@@ -167,11 +60,22 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
167
60
|
}
|
|
168
61
|
};
|
|
169
62
|
|
|
170
|
-
// Single timer: refreshes data
|
|
63
|
+
// Single timer: refreshes data, advances countdowns, reloads the capture.
|
|
64
|
+
const refreshCapture = async (): Promise<void> => {
|
|
65
|
+
if (disposed) return;
|
|
66
|
+
const sessionID = activeSessionID(api);
|
|
67
|
+
if (sessionID === null) {
|
|
68
|
+
setCapture(null);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
setCapture(await readCapture(sessionID));
|
|
72
|
+
};
|
|
73
|
+
|
|
171
74
|
const intervalMs = Math.max(1, Math.floor(options.refreshIntervalSeconds)) * 1000;
|
|
172
75
|
const timer = setInterval(() => {
|
|
173
76
|
setTick(Date.now());
|
|
174
77
|
void load();
|
|
78
|
+
void refreshCapture();
|
|
175
79
|
}, intervalMs);
|
|
176
80
|
|
|
177
81
|
api.lifecycle.onDispose(() => {
|
|
@@ -182,9 +86,35 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
182
86
|
const offIdle = api.event.on("session.idle", () => {
|
|
183
87
|
setTick(Date.now());
|
|
184
88
|
void load();
|
|
89
|
+
void refreshCapture();
|
|
185
90
|
});
|
|
186
91
|
api.lifecycle.onDispose(offIdle);
|
|
187
92
|
|
|
93
|
+
const openDialog = (): void => {
|
|
94
|
+
const sessionID = activeSessionID(api);
|
|
95
|
+
void refreshCapture().finally(() => {
|
|
96
|
+
if (disposed) return;
|
|
97
|
+
setScope(0);
|
|
98
|
+
setShowJson(false);
|
|
99
|
+
api.ui.dialog.replace(() => (
|
|
100
|
+
<UsageDialog
|
|
101
|
+
api={api}
|
|
102
|
+
sessionID={sessionID}
|
|
103
|
+
reports={reports}
|
|
104
|
+
busy={busy}
|
|
105
|
+
error={error}
|
|
106
|
+
now={() => new Date(tick())}
|
|
107
|
+
system={capture}
|
|
108
|
+
scope={scope}
|
|
109
|
+
showJson={showJson}
|
|
110
|
+
barWidth={options.barWidth}
|
|
111
|
+
/>
|
|
112
|
+
));
|
|
113
|
+
// Must follow replace(): the stack resets the size to "medium".
|
|
114
|
+
api.ui.dialog.setSize("xlarge");
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
188
118
|
api.lifecycle.onDispose(
|
|
189
119
|
api.keymap.registerLayer({
|
|
190
120
|
commands: [
|
|
@@ -197,9 +127,43 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
197
127
|
void load(true);
|
|
198
128
|
},
|
|
199
129
|
},
|
|
130
|
+
{
|
|
131
|
+
name: "usage.open",
|
|
132
|
+
title: "Usage: open report",
|
|
133
|
+
description: "Context usage and provider quota windows",
|
|
134
|
+
category: "Plugin",
|
|
135
|
+
namespace: "palette",
|
|
136
|
+
slashName: "usage",
|
|
137
|
+
run: openDialog,
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
bindings: [{ key: "ctrl+shift+u", cmd: "usage.refresh", desc: "Refresh usage panel" }],
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Scoped to the dialog's "modal" mode so these keys only fire while it is open.
|
|
145
|
+
api.lifecycle.onDispose(
|
|
146
|
+
api.keymap.registerLayer({
|
|
147
|
+
mode: "modal",
|
|
148
|
+
commands: [
|
|
149
|
+
{ name: "usage.dialog.refresh", run: () => void load(true) },
|
|
150
|
+
{
|
|
151
|
+
name: "usage.dialog.scope",
|
|
152
|
+
run: () => {
|
|
153
|
+
setScope((n) => (n + 1) % Math.max(1, reports().length + 1));
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: "usage.dialog.json",
|
|
158
|
+
run: () => {
|
|
159
|
+
setShowJson((v) => !v);
|
|
160
|
+
},
|
|
161
|
+
},
|
|
200
162
|
],
|
|
201
163
|
bindings: [
|
|
202
|
-
{ key: "
|
|
164
|
+
{ key: "r", cmd: "usage.dialog.refresh", desc: "Refresh usage report" },
|
|
165
|
+
{ key: "tab", cmd: "usage.dialog.scope", desc: "Cycle provider scope" },
|
|
166
|
+
{ key: "j", cmd: "usage.dialog.json", desc: "Toggle raw JSON" },
|
|
203
167
|
],
|
|
204
168
|
}),
|
|
205
169
|
);
|
|
@@ -215,7 +179,14 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
215
179
|
<b>Usage</b>
|
|
216
180
|
</text>
|
|
217
181
|
<For
|
|
218
|
-
each={buildLines(
|
|
182
|
+
each={buildLines(
|
|
183
|
+
theme,
|
|
184
|
+
reports(),
|
|
185
|
+
busy(),
|
|
186
|
+
error(),
|
|
187
|
+
new Date(tick()),
|
|
188
|
+
options.barWidth,
|
|
189
|
+
)}
|
|
219
190
|
>
|
|
220
191
|
{(line) => (
|
|
221
192
|
<text>
|
package/src/types.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface ProviderReport {
|
|
|
28
28
|
export interface Credential {
|
|
29
29
|
type: "api" | "oauth";
|
|
30
30
|
key: string;
|
|
31
|
+
/** Provider account id, when the auth entry carries one (e.g. ChatGPT). */
|
|
32
|
+
accountId?: string;
|
|
31
33
|
}
|
|
32
34
|
export interface AdapterResult {
|
|
33
35
|
windows: UsageWindow[];
|
|
@@ -50,7 +52,10 @@ export interface PluginOptions {
|
|
|
50
52
|
|
|
51
53
|
export type AdapterErrorKind = "auth" | "no-plan" | "rate-limited" | "network" | "bad-response";
|
|
52
54
|
export class AdapterError extends Error {
|
|
53
|
-
constructor(
|
|
55
|
+
constructor(
|
|
56
|
+
public kind: AdapterErrorKind,
|
|
57
|
+
message: string,
|
|
58
|
+
) {
|
|
54
59
|
super(message);
|
|
55
60
|
this.name = "AdapterError";
|
|
56
61
|
}
|
package/src/warn.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import type { ProviderReport, UsageWindow } from "./types
|
|
4
|
-
import { pluginStateDir } from "./paths
|
|
5
|
-
import { formatReset } from "./render
|
|
3
|
+
import type { ProviderReport, UsageWindow } from "./types";
|
|
4
|
+
import { pluginStateDir } from "./paths";
|
|
5
|
+
import { formatReset } from "./render";
|
|
6
6
|
|
|
7
7
|
export interface WarnHit {
|
|
8
8
|
provider: string;
|
|
@@ -72,7 +72,8 @@ export async function checkWarnings(
|
|
|
72
72
|
for (const w of report.windows) {
|
|
73
73
|
const key = `${report.provider}/${w.kind}`;
|
|
74
74
|
const crossed =
|
|
75
|
-
(w.usedPercent !== null && w.usedPercent >= thresholdPercent) ||
|
|
75
|
+
(w.usedPercent !== null && w.usedPercent >= thresholdPercent) ||
|
|
76
|
+
HITS_WINDOW_STATUSES.has(w.status);
|
|
76
77
|
|
|
77
78
|
if (crossed) {
|
|
78
79
|
const prev = state[key];
|