opencode-usage-report 0.1.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/LICENSE +21 -0
- package/README.md +144 -0
- package/package.json +60 -0
- package/src/auth.ts +58 -0
- package/src/cache.ts +97 -0
- package/src/fallback.ts +175 -0
- package/src/index.ts +111 -0
- package/src/normalize.ts +75 -0
- package/src/paths.ts +19 -0
- package/src/providers/index.ts +5 -0
- package/src/providers/kimi.ts +198 -0
- package/src/providers/opencode-go.ts +153 -0
- package/src/render.ts +85 -0
- package/src/report.ts +161 -0
- package/src/tui-format.ts +201 -0
- package/src/tui.tsx +244 -0
- package/src/types.ts +57 -0
- package/src/warn.ts +97 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure formatting helpers for the TUI sidebar usage panel. No JSX, no Solid
|
|
3
|
+
* imports (the only module dependency is the shared `DEFAULT_OPTIONS`).
|
|
4
|
+
*/
|
|
5
|
+
import { DEFAULT_OPTIONS } from "./report.js";
|
|
6
|
+
|
|
7
|
+
export type ToneName = "success" | "warning" | "error" | "textMuted";
|
|
8
|
+
|
|
9
|
+
export const BAR_MIN_WIDTH = 4;
|
|
10
|
+
export const BAR_MAX_WIDTH = 40;
|
|
11
|
+
export const DEFAULT_BAR_WIDTH = 14;
|
|
12
|
+
export const MIN_REFRESH_INTERVAL_SECONDS = 5;
|
|
13
|
+
export const MAX_REFRESH_INTERVAL_SECONDS = 3600;
|
|
14
|
+
export const DEFAULT_REFRESH_INTERVAL_SECONDS = 60;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Renders a fixed-width progress bar. `percent` is clamped to 0-100 and rounded
|
|
18
|
+
* to the nearest filled cell; `null` (or any non-finite value) renders an empty
|
|
19
|
+
* bar. Width is floored and clamped to 4..40 cells so it can never throw or
|
|
20
|
+
* allocate an unbounded string.
|
|
21
|
+
*/
|
|
22
|
+
export function bar(percent: number | null, width = DEFAULT_BAR_WIDTH): string {
|
|
23
|
+
const requested = Number.isFinite(width) ? Math.floor(width) : DEFAULT_BAR_WIDTH;
|
|
24
|
+
const w = Math.min(BAR_MAX_WIDTH, Math.max(BAR_MIN_WIDTH, requested));
|
|
25
|
+
if (percent === null || !Number.isFinite(percent)) return "░".repeat(w);
|
|
26
|
+
const clamped = Math.min(100, Math.max(0, percent));
|
|
27
|
+
const filled = Math.round((clamped / 100) * w);
|
|
28
|
+
return "█".repeat(filled) + "░".repeat(w - filled);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Maps a window's usage to a theme color name. Rate-limited/frozen windows are
|
|
33
|
+
* always an error, unknown percentages are muted, and the remaining bands are
|
|
34
|
+
* 80%+ (error), 50%+ (warning) and everything below (success).
|
|
35
|
+
*/
|
|
36
|
+
export function toneFor(
|
|
37
|
+
percent: number | null,
|
|
38
|
+
status: "ok" | "rate-limited" | "frozen" | "unknown",
|
|
39
|
+
): ToneName {
|
|
40
|
+
if (status === "rate-limited" || status === "frozen") return "error";
|
|
41
|
+
if (percent === null || !Number.isFinite(percent)) return "textMuted";
|
|
42
|
+
if (percent >= 80) return "error";
|
|
43
|
+
if (percent >= 50) return "warning";
|
|
44
|
+
return "success";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compact countdown to an ISO reset time. Returns `null` for missing/invalid
|
|
49
|
+
* input, `"now"` when less than a minute remains, and otherwise the largest
|
|
50
|
+
* non-zero day/hour/minute units (e.g. `"4d 9h 33m"`, `"3h 33m"`, `"12m"`).
|
|
51
|
+
*/
|
|
52
|
+
export function countdown(resetsAt: string | null, now: Date): string | null {
|
|
53
|
+
if (resetsAt === null) return null;
|
|
54
|
+
const target = Date.parse(resetsAt);
|
|
55
|
+
if (!Number.isFinite(target)) return null;
|
|
56
|
+
const deltaMs = target - now.getTime();
|
|
57
|
+
if (!Number.isFinite(deltaMs) || deltaMs < 60_000) return "now";
|
|
58
|
+
|
|
59
|
+
const totalMinutes = Math.floor(deltaMs / 60_000);
|
|
60
|
+
const totalHours = Math.floor(totalMinutes / 60);
|
|
61
|
+
const totalDays = Math.floor(totalHours / 24);
|
|
62
|
+
const days = totalDays;
|
|
63
|
+
const hours = totalHours % 24;
|
|
64
|
+
const minutes = totalMinutes % 60;
|
|
65
|
+
|
|
66
|
+
const parts: string[] = [];
|
|
67
|
+
if (days > 0) parts.push(`${days}d`);
|
|
68
|
+
if (hours > 0) parts.push(`${hours}h`);
|
|
69
|
+
if (minutes > 0) parts.push(`${minutes}m`);
|
|
70
|
+
return parts.length > 0 ? parts.join(" ") : "now";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Human label for a window kind, falling back to the provider-supplied label. */
|
|
74
|
+
export function windowLabel(
|
|
75
|
+
kind: "5h" | "daily" | "weekly" | "monthly" | "other",
|
|
76
|
+
fallback: string,
|
|
77
|
+
): string {
|
|
78
|
+
switch (kind) {
|
|
79
|
+
case "5h":
|
|
80
|
+
return "5h limit";
|
|
81
|
+
case "daily":
|
|
82
|
+
return "Daily limit";
|
|
83
|
+
case "weekly":
|
|
84
|
+
return "Weekly limit";
|
|
85
|
+
case "monthly":
|
|
86
|
+
return "Monthly limit";
|
|
87
|
+
default:
|
|
88
|
+
return fallback;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Prefers a percentage, then used/limit, then a lone used count, then a dash.
|
|
94
|
+
* Fields are treated as unknown so `undefined` (or non-number junk) never
|
|
95
|
+
* reaches `toLocaleString()`.
|
|
96
|
+
*/
|
|
97
|
+
export function percentText(w: {
|
|
98
|
+
usedPercent?: number | null;
|
|
99
|
+
used?: number | null;
|
|
100
|
+
limit?: number | null;
|
|
101
|
+
source?: string;
|
|
102
|
+
}): string {
|
|
103
|
+
const usedPercent = w.usedPercent;
|
|
104
|
+
if (typeof usedPercent === "number" && Number.isFinite(usedPercent)) {
|
|
105
|
+
return `${Math.round(usedPercent)}%`;
|
|
106
|
+
}
|
|
107
|
+
const used = w.used;
|
|
108
|
+
const limit = w.limit;
|
|
109
|
+
const usedIsNumber = typeof used === "number" && Number.isFinite(used);
|
|
110
|
+
const limitIsNumber = typeof limit === "number" && Number.isFinite(limit);
|
|
111
|
+
if (usedIsNumber && limitIsNumber) {
|
|
112
|
+
return `${used.toLocaleString()}/${limit.toLocaleString()}`;
|
|
113
|
+
}
|
|
114
|
+
if (usedIsNumber) {
|
|
115
|
+
return `~${used.toLocaleString()}`;
|
|
116
|
+
}
|
|
117
|
+
return "—";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Compact age of an ISO timestamp relative to `now` (e.g. `"14m"`, `"2h"`,
|
|
122
|
+
* `"3d"`). `null`/invalid input yields `null`; future timestamps floor to `"0s"`.
|
|
123
|
+
*/
|
|
124
|
+
export function formatAge(iso: string | null, now: Date): string | null {
|
|
125
|
+
if (iso === null) return null;
|
|
126
|
+
const then = Date.parse(iso);
|
|
127
|
+
if (!Number.isFinite(then)) return null;
|
|
128
|
+
const ageMs = Math.max(0, now.getTime() - then);
|
|
129
|
+
|
|
130
|
+
const seconds = Math.floor(ageMs / 1000);
|
|
131
|
+
if (seconds < 60) return `${seconds}s`;
|
|
132
|
+
const minutes = Math.floor(seconds / 60);
|
|
133
|
+
if (minutes < 60) return `${minutes}m`;
|
|
134
|
+
const hours = Math.floor(minutes / 60);
|
|
135
|
+
if (hours < 24) return `${hours}h`;
|
|
136
|
+
const days = Math.floor(hours / 24);
|
|
137
|
+
return `${days}d`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface ResolvedTuiOptions {
|
|
141
|
+
providers?: string[];
|
|
142
|
+
cacheTtlSeconds: number;
|
|
143
|
+
thresholdPercent: number;
|
|
144
|
+
refreshIntervalSeconds: number;
|
|
145
|
+
barWidth: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Coerces a loosely-typed value into a finite number, or null. */
|
|
149
|
+
function toFiniteNumber(value: unknown): number | null {
|
|
150
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
151
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
152
|
+
const n = Number(value);
|
|
153
|
+
return Number.isFinite(n) ? n : null;
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Defensive option coercion; malformed values fall back to defaults.
|
|
160
|
+
* `barWidth` is floored and clamped to 4..40; `refreshIntervalSeconds` is
|
|
161
|
+
* rounded and clamped to 5..3600 so the timer can never hot-loop or overflow.
|
|
162
|
+
*/
|
|
163
|
+
export function coerceTuiOptions(
|
|
164
|
+
raw: Record<string, unknown> | undefined,
|
|
165
|
+
): ResolvedTuiOptions {
|
|
166
|
+
const opts: ResolvedTuiOptions = {
|
|
167
|
+
cacheTtlSeconds: DEFAULT_OPTIONS.cacheTtlSeconds,
|
|
168
|
+
thresholdPercent: DEFAULT_OPTIONS.thresholdPercent,
|
|
169
|
+
refreshIntervalSeconds: DEFAULT_REFRESH_INTERVAL_SECONDS,
|
|
170
|
+
barWidth: DEFAULT_BAR_WIDTH,
|
|
171
|
+
};
|
|
172
|
+
if (raw === undefined) return opts;
|
|
173
|
+
|
|
174
|
+
if (Array.isArray(raw.providers)) {
|
|
175
|
+
const ids = raw.providers.filter((id): id is string => typeof id === "string");
|
|
176
|
+
if (ids.length > 0) opts.providers = ids;
|
|
177
|
+
} else if (raw.providers === null) {
|
|
178
|
+
opts.providers = undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const ttl = toFiniteNumber(raw.cacheTtlSeconds);
|
|
182
|
+
if (ttl !== null && ttl >= 0) opts.cacheTtlSeconds = ttl;
|
|
183
|
+
|
|
184
|
+
const threshold = toFiniteNumber(raw.thresholdPercent);
|
|
185
|
+
if (threshold !== null) opts.thresholdPercent = threshold;
|
|
186
|
+
|
|
187
|
+
const interval = toFiniteNumber(raw.refreshIntervalSeconds);
|
|
188
|
+
if (interval !== null) {
|
|
189
|
+
opts.refreshIntervalSeconds = Math.min(
|
|
190
|
+
MAX_REFRESH_INTERVAL_SECONDS,
|
|
191
|
+
Math.max(MIN_REFRESH_INTERVAL_SECONDS, Math.round(interval)),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const width = toFiniteNumber(raw.barWidth);
|
|
196
|
+
if (width !== null) {
|
|
197
|
+
opts.barWidth = Math.min(BAR_MAX_WIDTH, Math.max(BAR_MIN_WIDTH, Math.floor(width)));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return opts;
|
|
201
|
+
}
|
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type {
|
|
3
|
+
TuiPlugin,
|
|
4
|
+
TuiPluginApi,
|
|
5
|
+
TuiPluginModule,
|
|
6
|
+
TuiSlotPlugin,
|
|
7
|
+
} from "@opencode-ai/plugin/tui";
|
|
8
|
+
import { For, createSignal } from "solid-js";
|
|
9
|
+
import { DEFAULT_OPTIONS, collectReports } from "./report.js";
|
|
10
|
+
import {
|
|
11
|
+
bar,
|
|
12
|
+
coerceTuiOptions,
|
|
13
|
+
countdown,
|
|
14
|
+
percentText,
|
|
15
|
+
toneFor,
|
|
16
|
+
windowLabel,
|
|
17
|
+
} from "./tui-format.js";
|
|
18
|
+
import type { ProviderReport, WindowKind } from "./types.js";
|
|
19
|
+
|
|
20
|
+
type Theme = TuiPluginApi["theme"]["current"];
|
|
21
|
+
type ThemeColor = Theme["text"];
|
|
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;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const tui: TuiPlugin = async (api, rawOptions) => {
|
|
135
|
+
const options = coerceTuiOptions(rawOptions);
|
|
136
|
+
|
|
137
|
+
const [reports, setReports] = createSignal<ProviderReport[]>([]);
|
|
138
|
+
const [inFlight, setInFlight] = createSignal(0);
|
|
139
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
140
|
+
const [tick, setTick] = createSignal(Date.now());
|
|
141
|
+
|
|
142
|
+
const busy = (): boolean => inFlight() > 0;
|
|
143
|
+
|
|
144
|
+
let disposed = false;
|
|
145
|
+
|
|
146
|
+
const load = async (refresh = false): Promise<void> => {
|
|
147
|
+
if (disposed) return;
|
|
148
|
+
setInFlight((n) => n + 1);
|
|
149
|
+
try {
|
|
150
|
+
const result = await collectReports({
|
|
151
|
+
providers: options.providers,
|
|
152
|
+
refresh,
|
|
153
|
+
options: {
|
|
154
|
+
...DEFAULT_OPTIONS,
|
|
155
|
+
cacheTtlSeconds: options.cacheTtlSeconds,
|
|
156
|
+
thresholdPercent: options.thresholdPercent,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
if (disposed) return;
|
|
160
|
+
setReports(result);
|
|
161
|
+
setError(null);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (disposed) return;
|
|
164
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
165
|
+
} finally {
|
|
166
|
+
setInFlight((n) => Math.max(0, n - 1));
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// Single timer: refreshes data and advances countdowns.
|
|
171
|
+
const intervalMs = Math.max(1, Math.floor(options.refreshIntervalSeconds)) * 1000;
|
|
172
|
+
const timer = setInterval(() => {
|
|
173
|
+
setTick(Date.now());
|
|
174
|
+
void load();
|
|
175
|
+
}, intervalMs);
|
|
176
|
+
|
|
177
|
+
api.lifecycle.onDispose(() => {
|
|
178
|
+
disposed = true;
|
|
179
|
+
clearInterval(timer);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const offIdle = api.event.on("session.idle", () => {
|
|
183
|
+
setTick(Date.now());
|
|
184
|
+
void load();
|
|
185
|
+
});
|
|
186
|
+
api.lifecycle.onDispose(offIdle);
|
|
187
|
+
|
|
188
|
+
api.lifecycle.onDispose(
|
|
189
|
+
api.keymap.registerLayer({
|
|
190
|
+
commands: [
|
|
191
|
+
{
|
|
192
|
+
name: "usage.refresh",
|
|
193
|
+
title: "Usage: refresh now",
|
|
194
|
+
category: "Plugin",
|
|
195
|
+
namespace: "palette",
|
|
196
|
+
run: () => {
|
|
197
|
+
void load(true);
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
bindings: [
|
|
202
|
+
{ key: "ctrl+shift+u", cmd: "usage.refresh", desc: "Refresh usage panel" },
|
|
203
|
+
],
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const slot: TuiSlotPlugin = {
|
|
208
|
+
order: 150,
|
|
209
|
+
slots: {
|
|
210
|
+
sidebar_content(ctx) {
|
|
211
|
+
const theme = ctx.theme.current;
|
|
212
|
+
return (
|
|
213
|
+
<box flexDirection="column">
|
|
214
|
+
<text fg={theme.text}>
|
|
215
|
+
<b>Usage</b>
|
|
216
|
+
</text>
|
|
217
|
+
<For
|
|
218
|
+
each={buildLines(theme, reports(), busy(), error(), new Date(tick()), options.barWidth)}
|
|
219
|
+
>
|
|
220
|
+
{(line) => (
|
|
221
|
+
<text>
|
|
222
|
+
{line.segments.map((seg) =>
|
|
223
|
+
seg.bold ? (
|
|
224
|
+
<b style={{ fg: seg.fg }}>{seg.text}</b>
|
|
225
|
+
) : (
|
|
226
|
+
<span style={{ fg: seg.fg }}>{seg.text}</span>
|
|
227
|
+
),
|
|
228
|
+
)}
|
|
229
|
+
</text>
|
|
230
|
+
)}
|
|
231
|
+
</For>
|
|
232
|
+
</box>
|
|
233
|
+
);
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
api.slots.register(slot);
|
|
239
|
+
|
|
240
|
+
void load();
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const plugin: TuiPluginModule & { id: string } = { id: "usage-report", tui };
|
|
244
|
+
export default plugin;
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type WindowKind = "5h" | "daily" | "weekly" | "monthly" | "other";
|
|
2
|
+
export type WindowStatus = "ok" | "rate-limited" | "frozen" | "unknown";
|
|
3
|
+
|
|
4
|
+
export interface UsageWindow {
|
|
5
|
+
kind: WindowKind;
|
|
6
|
+
label: string; // "5-hour", "Weekly", "Monthly"
|
|
7
|
+
usedPercent: number | null; // 0-100
|
|
8
|
+
used: number | null;
|
|
9
|
+
limit: number | null;
|
|
10
|
+
remaining: number | null;
|
|
11
|
+
resetsAt: string | null; // ISO-8601
|
|
12
|
+
status: WindowStatus;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ReportSource = "api" | "cache" | "local-estimate" | "error";
|
|
16
|
+
|
|
17
|
+
export interface ProviderReport {
|
|
18
|
+
provider: string;
|
|
19
|
+
displayName: string;
|
|
20
|
+
fetchedAt: string;
|
|
21
|
+
source: ReportSource;
|
|
22
|
+
stale: boolean;
|
|
23
|
+
windows: UsageWindow[];
|
|
24
|
+
extras: Record<string, string>;
|
|
25
|
+
error: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Credential {
|
|
29
|
+
type: "api" | "oauth";
|
|
30
|
+
key: string;
|
|
31
|
+
}
|
|
32
|
+
export interface AdapterResult {
|
|
33
|
+
windows: UsageWindow[];
|
|
34
|
+
extras?: Record<string, string>;
|
|
35
|
+
}
|
|
36
|
+
export interface FetchOptions {
|
|
37
|
+
timeoutMs: number;
|
|
38
|
+
}
|
|
39
|
+
export interface ProviderAdapter {
|
|
40
|
+
id: string;
|
|
41
|
+
displayName: string;
|
|
42
|
+
fetch(cred: Credential, opts: FetchOptions): Promise<AdapterResult>;
|
|
43
|
+
}
|
|
44
|
+
export interface PluginOptions {
|
|
45
|
+
thresholdPercent: number; // default 80
|
|
46
|
+
cacheTtlSeconds: number; // default 120
|
|
47
|
+
providers: string[] | null; // null = all discovered
|
|
48
|
+
fallback: boolean; // default true
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type AdapterErrorKind = "auth" | "no-plan" | "rate-limited" | "network" | "bad-response";
|
|
52
|
+
export class AdapterError extends Error {
|
|
53
|
+
constructor(public kind: AdapterErrorKind, message: string) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "AdapterError";
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/warn.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { ProviderReport, UsageWindow } from "./types.js";
|
|
4
|
+
import { pluginStateDir } from "./paths.js";
|
|
5
|
+
import { formatReset } from "./render.js";
|
|
6
|
+
|
|
7
|
+
export interface WarnHit {
|
|
8
|
+
provider: string;
|
|
9
|
+
displayName: string;
|
|
10
|
+
window: UsageWindow;
|
|
11
|
+
message: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface WarnEntry {
|
|
15
|
+
firedAtReset: string | null;
|
|
16
|
+
}
|
|
17
|
+
type WarnState = Record<string, WarnEntry>;
|
|
18
|
+
|
|
19
|
+
const HITS_WINDOW_STATUSES = new Set<UsageWindow["status"]>(["rate-limited", "frozen"]);
|
|
20
|
+
const REARM_HYSTERESIS = 10;
|
|
21
|
+
|
|
22
|
+
function buildMessage(displayName: string, w: UsageWindow, now: Date): string {
|
|
23
|
+
const base = `${displayName} ${w.label} window`;
|
|
24
|
+
if (HITS_WINDOW_STATUSES.has(w.status)) {
|
|
25
|
+
return `${base} is ${w.status === "rate-limited" ? "rate-limited" : "frozen"}`;
|
|
26
|
+
}
|
|
27
|
+
let message = `${base} at ${w.usedPercent}%`;
|
|
28
|
+
if (w.resetsAt !== null) {
|
|
29
|
+
const reset = formatReset(w.resetsAt, now);
|
|
30
|
+
if (reset !== null) message += ` (resets ${reset})`;
|
|
31
|
+
}
|
|
32
|
+
return message;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readState(file: string): WarnState {
|
|
36
|
+
try {
|
|
37
|
+
const parsed: unknown = JSON.parse(readFileSync(file, "utf8"));
|
|
38
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
39
|
+
return parsed as WarnState;
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Missing or corrupt state file: start from empty.
|
|
43
|
+
}
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Atomic: write a temp file next to the target, then rename it into place. */
|
|
48
|
+
function writeState(file: string, dir: string, state: WarnState): void {
|
|
49
|
+
mkdirSync(dir, { recursive: true });
|
|
50
|
+
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
51
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
52
|
+
renameSync(tmp, file);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Checks reports for windows that crossed the warning threshold or are
|
|
57
|
+
* rate-limited/frozen. Fires at most once per (provider, window-kind) until the
|
|
58
|
+
* window resets; re-arms once usage drops below threshold - 10.
|
|
59
|
+
*/
|
|
60
|
+
export async function checkWarnings(
|
|
61
|
+
reports: ProviderReport[],
|
|
62
|
+
thresholdPercent: number,
|
|
63
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
64
|
+
): Promise<WarnHit[]> {
|
|
65
|
+
const now = new Date();
|
|
66
|
+
const dir = pluginStateDir(env);
|
|
67
|
+
const file = join(dir, "warn-state.json");
|
|
68
|
+
const state = readState(file);
|
|
69
|
+
const hits: WarnHit[] = [];
|
|
70
|
+
|
|
71
|
+
for (const report of reports) {
|
|
72
|
+
for (const w of report.windows) {
|
|
73
|
+
const key = `${report.provider}/${w.kind}`;
|
|
74
|
+
const crossed =
|
|
75
|
+
(w.usedPercent !== null && w.usedPercent >= thresholdPercent) || HITS_WINDOW_STATUSES.has(w.status);
|
|
76
|
+
|
|
77
|
+
if (crossed) {
|
|
78
|
+
const prev = state[key];
|
|
79
|
+
const alreadyFired = prev !== undefined && prev.firedAtReset === w.resetsAt;
|
|
80
|
+
if (!alreadyFired) {
|
|
81
|
+
state[key] = { firedAtReset: w.resetsAt };
|
|
82
|
+
hits.push({
|
|
83
|
+
provider: report.provider,
|
|
84
|
+
displayName: report.displayName,
|
|
85
|
+
window: w,
|
|
86
|
+
message: buildMessage(report.displayName, w, now),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
} else if (w.usedPercent !== null && w.usedPercent < thresholdPercent - REARM_HYSTERESIS) {
|
|
90
|
+
delete state[key];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
writeState(file, dir, state);
|
|
96
|
+
return hits;
|
|
97
|
+
}
|