opencode-usage-report 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +204 -69
- package/package.json +1 -2
- package/src/context/collect.ts +210 -0
- package/src/context/dialog.tsx +184 -0
- package/src/context/estimate.ts +11 -0
- package/src/context/format.ts +240 -0
- package/src/context/grid.ts +76 -0
- package/src/context/headroom.ts +39 -0
- package/src/context/system.ts +115 -0
- package/src/context/types.ts +50 -0
- package/src/fallback.ts +1 -6
- package/src/index.ts +27 -9
- package/src/normalize.ts +17 -27
- package/src/providers/chatgpt.ts +24 -78
- package/src/providers/copilot.ts +28 -86
- package/src/providers/http.ts +90 -0
- package/src/providers/kimi.ts +47 -59
- package/src/providers/opencode-go.ts +33 -89
- package/src/quota-lines.ts +99 -0
- package/src/render.ts +11 -14
- package/src/tui-format.ts +12 -0
- package/src/tui.tsx +93 -116
|
@@ -10,9 +10,8 @@ import type {
|
|
|
10
10
|
import { AdapterError } from "../types";
|
|
11
11
|
import { toISODate, toNumber } from "../normalize";
|
|
12
12
|
import { redact } from "../auth";
|
|
13
|
+
import { asRecord, handleError, readBody, request } from "./http";
|
|
13
14
|
|
|
14
|
-
const VERSION = "0.3.0";
|
|
15
|
-
const USER_AGENT = "opencode-usage-report/" + VERSION;
|
|
16
15
|
const ENDPOINT = "https://opencode.ai/zen/go/v1/usage";
|
|
17
16
|
|
|
18
17
|
/** Additive options: `sessionId` is optional, so the adapter still satisfies `ProviderAdapter`. */
|
|
@@ -28,12 +27,6 @@ function mapStatus(v: unknown): WindowStatus {
|
|
|
28
27
|
return v === "ok" || v === "rate-limited" ? v : "unknown";
|
|
29
28
|
}
|
|
30
29
|
|
|
31
|
-
function asRecord(v: unknown): Record<string, unknown> | null {
|
|
32
|
-
return v !== null && typeof v === "object" && !Array.isArray(v)
|
|
33
|
-
? (v as Record<string, unknown>)
|
|
34
|
-
: null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
30
|
function buildWindow(kind: WindowKind, label: string, src: unknown): UsageWindow | null {
|
|
38
31
|
const record = asRecord(src);
|
|
39
32
|
if (!record) return null;
|
|
@@ -49,91 +42,42 @@ function buildWindow(kind: WindowKind, label: string, src: unknown): UsageWindow
|
|
|
49
42
|
};
|
|
50
43
|
}
|
|
51
44
|
|
|
52
|
-
/** Reads the body once as text and best-effort parses JSON, so error and success paths share it. */
|
|
53
|
-
async function readBody(res: Response): Promise<{ text: string; json: unknown }> {
|
|
54
|
-
const text = await res.text().catch(() => "");
|
|
55
|
-
let json: unknown = null;
|
|
56
|
-
try {
|
|
57
|
-
json = JSON.parse(text);
|
|
58
|
-
} catch {
|
|
59
|
-
json = null;
|
|
60
|
-
}
|
|
61
|
-
return { text, json };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function attempt(cred: Credential, opts: OpenCodeGoFetchOptions): Promise<Response> {
|
|
65
|
-
return fetch(ENDPOINT, {
|
|
66
|
-
method: "GET",
|
|
67
|
-
headers: {
|
|
68
|
-
Authorization: "Bearer " + cred.key,
|
|
69
|
-
Accept: "application/json",
|
|
70
|
-
"User-Agent": USER_AGENT,
|
|
71
|
-
"x-opencode-session": opts.sessionId ?? "unknown",
|
|
72
|
-
},
|
|
73
|
-
signal: AbortSignal.timeout(opts.timeoutMs),
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Exactly one retry on a network throw or 5xx; 4xx is returned immediately (never retried). */
|
|
78
|
-
async function request(cred: Credential, opts: OpenCodeGoFetchOptions): Promise<Response> {
|
|
79
|
-
try {
|
|
80
|
-
const first = await attempt(cred, opts);
|
|
81
|
-
if (first.status < 500) return first;
|
|
82
|
-
} catch {
|
|
83
|
-
// fall through to the single retry
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
return await attempt(cred, opts);
|
|
88
|
-
} catch (err) {
|
|
89
|
-
throw new AdapterError(
|
|
90
|
-
"network",
|
|
91
|
-
redact(
|
|
92
|
-
`OpenCode Go request failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
93
|
-
cred.key,
|
|
94
|
-
),
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async function handleError(res: Response, cred: Credential): Promise<never> {
|
|
100
|
-
const { text, json } = await readBody(res);
|
|
101
|
-
|
|
102
|
-
if (res.status === 401) {
|
|
103
|
-
throw new AdapterError("auth", redact("invalid OpenCode Go API key", cred.key));
|
|
104
|
-
}
|
|
105
|
-
if (res.status === 403) {
|
|
106
|
-
const record = asRecord(json);
|
|
107
|
-
const error = record ? asRecord(record.error) : null;
|
|
108
|
-
if (error && error.type === "EntitlementError") {
|
|
109
|
-
throw new AdapterError(
|
|
110
|
-
"no-plan",
|
|
111
|
-
redact("OpenCode Go subscription not active on this key", cred.key),
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
throw new AdapterError(
|
|
115
|
-
"network",
|
|
116
|
-
redact("OpenCode Go request blocked (check User-Agent)", cred.key),
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
if (res.status === 429) {
|
|
120
|
-
throw new AdapterError("rate-limited", redact("OpenCode Go rate limit reached", cred.key));
|
|
121
|
-
}
|
|
122
|
-
if (res.status >= 500) {
|
|
123
|
-
throw new AdapterError("network", redact(`OpenCode Go server error ${res.status}`, cred.key));
|
|
124
|
-
}
|
|
125
|
-
throw new AdapterError(
|
|
126
|
-
"bad-response",
|
|
127
|
-
redact(`OpenCode Go unexpected status ${res.status}: ${text.slice(0, 200)}`, cred.key),
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
45
|
export const opencodeGoAdapter: ProviderAdapter = {
|
|
132
46
|
id: "opencode-go",
|
|
133
47
|
displayName: "OpenCode Go",
|
|
134
48
|
async fetch(cred: Credential, opts: OpenCodeGoFetchOptions): Promise<AdapterResult> {
|
|
135
|
-
const res = await request(cred, opts
|
|
136
|
-
|
|
49
|
+
const res = await request(cred, opts, {
|
|
50
|
+
endpoint: ENDPOINT,
|
|
51
|
+
label: "OpenCode Go request failed",
|
|
52
|
+
headers: { "x-opencode-session": opts.sessionId ?? "unknown" },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
return handleError(res, cred, (status, { text, json }) => {
|
|
57
|
+
if (status === 401) return { kind: "auth", message: "invalid OpenCode Go API key" };
|
|
58
|
+
if (status === 403) {
|
|
59
|
+
const record = asRecord(json);
|
|
60
|
+
const error = record ? asRecord(record.error) : null;
|
|
61
|
+
if (error && error.type === "EntitlementError") {
|
|
62
|
+
return {
|
|
63
|
+
kind: "no-plan",
|
|
64
|
+
message: "OpenCode Go subscription not active on this key",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { kind: "network", message: "OpenCode Go request blocked (check User-Agent)" };
|
|
68
|
+
}
|
|
69
|
+
if (status === 429) {
|
|
70
|
+
return { kind: "rate-limited", message: "OpenCode Go rate limit reached" };
|
|
71
|
+
}
|
|
72
|
+
if (status >= 500) {
|
|
73
|
+
return { kind: "network", message: `OpenCode Go server error ${status}` };
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
kind: "bad-response",
|
|
77
|
+
message: `OpenCode Go unexpected status ${status}: ${text.slice(0, 200)}`,
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
|
137
81
|
|
|
138
82
|
const { json } = await readBody(res);
|
|
139
83
|
const body = asRecord(json);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure line-building for the sidebar quota panel, extracted verbatim from
|
|
3
|
+
* `src/tui.tsx` so the formatting can be unit-tested and later reused by the
|
|
4
|
+
* `/usage` dialog. No JSX and no Solid imports (only the shared formatting
|
|
5
|
+
* helpers and types).
|
|
6
|
+
*/
|
|
7
|
+
import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
|
|
8
|
+
import type { ProviderReport, WindowKind } from "./types";
|
|
9
|
+
import { bar, clip, countdown, padLabel, percentText, toneFor, windowLabel } from "./tui-format";
|
|
10
|
+
|
|
11
|
+
type Theme = TuiPluginApi["theme"]["current"];
|
|
12
|
+
type ThemeColor = Theme["text"];
|
|
13
|
+
|
|
14
|
+
export interface Segment {
|
|
15
|
+
text: string;
|
|
16
|
+
fg: ThemeColor;
|
|
17
|
+
bold?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Line {
|
|
21
|
+
segments: Segment[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const NAMED_WIDTH = 14;
|
|
25
|
+
export const EXTRAS_MAX_LENGTH = 80;
|
|
26
|
+
|
|
27
|
+
/** Order windows for display; unknown kinds sort last. */
|
|
28
|
+
const WINDOW_ORDER: Record<WindowKind, number> = {
|
|
29
|
+
"5h": 0,
|
|
30
|
+
weekly: 1,
|
|
31
|
+
monthly: 2,
|
|
32
|
+
daily: 3,
|
|
33
|
+
other: 4,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Flattens provider reports into plain, pre-colored text lines. */
|
|
37
|
+
export function buildLines(
|
|
38
|
+
theme: Theme,
|
|
39
|
+
reports: ProviderReport[],
|
|
40
|
+
busy: boolean,
|
|
41
|
+
error: string | null,
|
|
42
|
+
now: Date,
|
|
43
|
+
barWidth: number,
|
|
44
|
+
): Line[] {
|
|
45
|
+
const lines: Line[] = [];
|
|
46
|
+
|
|
47
|
+
if (error !== null) {
|
|
48
|
+
lines.push({
|
|
49
|
+
segments: [{ text: `api error: ${clip(error)}`, fg: theme.error }],
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (reports.length === 0 && busy) {
|
|
54
|
+
lines.push({
|
|
55
|
+
segments: [{ text: "loading…", fg: theme.textMuted }],
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const report of reports) {
|
|
60
|
+
const nameColor = report.source === "error" ? theme.textMuted : theme.accent;
|
|
61
|
+
const segments: Segment[] = [{ text: report.displayName, fg: nameColor, bold: true }];
|
|
62
|
+
if (report.stale) segments.push({ text: " (stale)", fg: theme.textMuted });
|
|
63
|
+
if (report.source === "local-estimate") {
|
|
64
|
+
segments.push({ text: " (est)", fg: theme.textMuted });
|
|
65
|
+
}
|
|
66
|
+
lines.push({ segments });
|
|
67
|
+
|
|
68
|
+
if (report.source === "error") {
|
|
69
|
+
lines.push({
|
|
70
|
+
segments: [{ text: clip(report.error ?? "unknown error"), fg: theme.error }],
|
|
71
|
+
});
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const windows = report.windows.toSorted((a, b) => WINDOW_ORDER[a.kind] - WINDOW_ORDER[b.kind]);
|
|
76
|
+
for (const w of windows) {
|
|
77
|
+
const tone = toneFor(w.usedPercent, w.status);
|
|
78
|
+
const row: Segment[] = [
|
|
79
|
+
{ text: padLabel(windowLabel(w.kind, w.label), NAMED_WIDTH), fg: theme.textMuted },
|
|
80
|
+
{ text: bar(w.usedPercent, barWidth), fg: theme[tone] },
|
|
81
|
+
{ text: " ", fg: theme.text },
|
|
82
|
+
{ text: percentText(w), fg: theme.text },
|
|
83
|
+
];
|
|
84
|
+
const reset = countdown(w.resetsAt, now);
|
|
85
|
+
if (reset !== null) {
|
|
86
|
+
row.push({ text: ` resets in ${reset}`, fg: theme.textMuted });
|
|
87
|
+
}
|
|
88
|
+
lines.push({ segments: row });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const [key, value] of Object.entries(report.extras)) {
|
|
92
|
+
lines.push({
|
|
93
|
+
segments: [{ text: clip(`${key}: ${value}`, EXTRAS_MAX_LENGTH), fg: theme.textMuted }],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return lines;
|
|
99
|
+
}
|
package/src/render.ts
CHANGED
|
@@ -1,39 +1,36 @@
|
|
|
1
1
|
import type { ProviderReport, UsageWindow } from "./types";
|
|
2
|
-
|
|
3
|
-
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
4
|
-
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
2
|
+
import { formatAge } from "./tui-format";
|
|
5
3
|
|
|
6
4
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
7
5
|
const LABEL_WIDTH = 8;
|
|
8
6
|
const PERCENT_WIDTH = 10;
|
|
9
7
|
const ABSOLUTES_WIDTH = 24;
|
|
10
8
|
|
|
9
|
+
const RESET_DATE = new Intl.DateTimeFormat("en-US", {
|
|
10
|
+
weekday: "short",
|
|
11
|
+
month: "short",
|
|
12
|
+
day: "numeric",
|
|
13
|
+
});
|
|
14
|
+
|
|
11
15
|
function formatNumber(n: number): string {
|
|
12
16
|
return n.toLocaleString("en-US");
|
|
13
17
|
}
|
|
14
18
|
|
|
15
|
-
/** "14:05" for a same-day reset, "Wed Sep 16" otherwise. */
|
|
19
|
+
/** "14:05" for a same-day reset, "Wed, Sep 16" otherwise. */
|
|
16
20
|
export function formatReset(resetsAt: string, now: Date): string | null {
|
|
17
21
|
const d = new Date(resetsAt);
|
|
18
22
|
if (Number.isNaN(d.getTime())) return null;
|
|
19
23
|
const within24h = Math.abs(d.getTime() - now.getTime()) < DAY_MS;
|
|
20
24
|
if (within24h) {
|
|
21
|
-
return
|
|
25
|
+
return d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
22
26
|
}
|
|
23
|
-
return
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function formatAge(fetchedAt: string, now: Date): string {
|
|
27
|
-
const then = new Date(fetchedAt).getTime();
|
|
28
|
-
const minutes = Number.isNaN(then) ? 0 : Math.max(0, Math.floor((now.getTime() - then) / 60000));
|
|
29
|
-
if (minutes < 60) return `${minutes}m`;
|
|
30
|
-
return `${Math.floor(minutes / 60)}h`;
|
|
27
|
+
return RESET_DATE.format(d);
|
|
31
28
|
}
|
|
32
29
|
|
|
33
30
|
function header(report: ProviderReport, now: Date): string {
|
|
34
31
|
const parts: string[] = [report.source];
|
|
35
32
|
if (report.stale) {
|
|
36
|
-
parts.push(`stale ${formatAge(report.fetchedAt, now)} old`);
|
|
33
|
+
parts.push(`stale ${formatAge(report.fetchedAt, now) ?? "0m"} old`);
|
|
37
34
|
}
|
|
38
35
|
return `${report.displayName} (${parts.join(", ")})`;
|
|
39
36
|
}
|
package/src/tui-format.ts
CHANGED
|
@@ -13,6 +13,18 @@ export const MIN_REFRESH_INTERVAL_SECONDS = 5;
|
|
|
13
13
|
export const MAX_REFRESH_INTERVAL_SECONDS = 3600;
|
|
14
14
|
export const DEFAULT_REFRESH_INTERVAL_SECONDS = 60;
|
|
15
15
|
|
|
16
|
+
/** Collapses whitespace and truncates to a single-line label. */
|
|
17
|
+
export function clip(value: string, max = 60): string {
|
|
18
|
+
const single = value.replace(/\s+/g, " ").trim();
|
|
19
|
+
if (single.length <= max) return single;
|
|
20
|
+
return single.slice(0, Math.max(0, max - 1)) + "…";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Pads a label to a fixed width, truncating with an ellipsis when long. */
|
|
24
|
+
export function padLabel(label: string, width: number): string {
|
|
25
|
+
return clip(label, width).padEnd(width);
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
/**
|
|
17
29
|
* Renders a fixed-width progress bar. `percent` is clamped to 0-100 and rounded
|
|
18
30
|
* to the nearest filled cell; `null` (or any non-finite value) renders an empty
|
package/src/tui.tsx
CHANGED
|
@@ -7,114 +7,19 @@ import type {
|
|
|
7
7
|
} from "@opencode-ai/plugin/tui";
|
|
8
8
|
import { For, createSignal } from "solid-js";
|
|
9
9
|
import { DEFAULT_OPTIONS, collectReports } from "./report";
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
type
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
segments: Segment[];
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const NAMED_WIDTH = 14;
|
|
27
|
-
const EXTRAS_MAX_LENGTH = 80;
|
|
28
|
-
|
|
29
|
-
/** Order windows for display; unknown kinds sort last. */
|
|
30
|
-
const WINDOW_ORDER: Record<WindowKind, number> = {
|
|
31
|
-
"5h": 0,
|
|
32
|
-
weekly: 1,
|
|
33
|
-
monthly: 2,
|
|
34
|
-
daily: 3,
|
|
35
|
-
other: 4,
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
function windowRank(kind: WindowKind): number {
|
|
39
|
-
return WINDOW_ORDER[kind] ?? WINDOW_ORDER.other;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Collapses whitespace and truncates to a single-line label. */
|
|
43
|
-
function clip(value: string, max = 60): string {
|
|
44
|
-
const single = value.replace(/\s+/g, " ").trim();
|
|
45
|
-
if (single.length <= max) return single;
|
|
46
|
-
return single.slice(0, Math.max(0, max - 1)) + "…";
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Pads a label to a fixed width, truncating (with a trailing space) when long. */
|
|
50
|
-
function alignLabel(label: string): string {
|
|
51
|
-
const text = label.length > NAMED_WIDTH ? label.slice(0, NAMED_WIDTH - 1) + " " : label;
|
|
52
|
-
return text.padEnd(NAMED_WIDTH);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Flattens the current signal state into plain, pre-colored text lines. */
|
|
56
|
-
function buildLines(
|
|
57
|
-
theme: Theme,
|
|
58
|
-
reports: ProviderReport[],
|
|
59
|
-
busy: boolean,
|
|
60
|
-
error: string | null,
|
|
61
|
-
now: Date,
|
|
62
|
-
barWidth: number,
|
|
63
|
-
): Line[] {
|
|
64
|
-
const lines: Line[] = [];
|
|
65
|
-
|
|
66
|
-
if (error !== null) {
|
|
67
|
-
lines.push({
|
|
68
|
-
segments: [{ text: `api error: ${clip(error)}`, fg: theme.error }],
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (reports.length === 0 && busy) {
|
|
73
|
-
lines.push({
|
|
74
|
-
segments: [{ text: "loading…", fg: theme.textMuted }],
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
for (const report of reports) {
|
|
79
|
-
const nameColor = report.source === "error" ? theme.textMuted : theme.accent;
|
|
80
|
-
const segments: Segment[] = [{ text: report.displayName, fg: nameColor, bold: true }];
|
|
81
|
-
if (report.stale) segments.push({ text: " (stale)", fg: theme.textMuted });
|
|
82
|
-
if (report.source === "local-estimate") {
|
|
83
|
-
segments.push({ text: " (est)", fg: theme.textMuted });
|
|
84
|
-
}
|
|
85
|
-
lines.push({ segments });
|
|
86
|
-
|
|
87
|
-
if (report.source === "error") {
|
|
88
|
-
lines.push({
|
|
89
|
-
segments: [{ text: clip(report.error ?? "unknown error"), fg: theme.error }],
|
|
90
|
-
});
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const windows = report.windows.toSorted((a, b) => windowRank(a.kind) - windowRank(b.kind));
|
|
95
|
-
for (const w of windows) {
|
|
96
|
-
const tone = toneFor(w.usedPercent, w.status);
|
|
97
|
-
const row: Segment[] = [
|
|
98
|
-
{ text: alignLabel(windowLabel(w.kind, w.label)), fg: theme.textMuted },
|
|
99
|
-
{ text: bar(w.usedPercent, barWidth), fg: theme[tone] },
|
|
100
|
-
{ text: " ", fg: theme.text },
|
|
101
|
-
{ text: percentText(w), fg: theme.text },
|
|
102
|
-
];
|
|
103
|
-
const reset = countdown(w.resetsAt, now);
|
|
104
|
-
if (reset !== null) {
|
|
105
|
-
row.push({ text: ` resets in ${reset}`, fg: theme.textMuted });
|
|
106
|
-
}
|
|
107
|
-
lines.push({ segments: row });
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
for (const [key, value] of Object.entries(report.extras)) {
|
|
111
|
-
lines.push({
|
|
112
|
-
segments: [{ text: clip(`${key}: ${value}`, EXTRAS_MAX_LENGTH), fg: theme.textMuted }],
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return lines;
|
|
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;
|
|
118
23
|
}
|
|
119
24
|
|
|
120
25
|
export const tui: TuiPlugin = async (api, rawOptions) => {
|
|
@@ -124,11 +29,13 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
124
29
|
const [inFlight, setInFlight] = createSignal(0);
|
|
125
30
|
const [error, setError] = createSignal<string | null>(null);
|
|
126
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);
|
|
127
35
|
|
|
128
36
|
const busy = (): boolean => inFlight() > 0;
|
|
129
37
|
|
|
130
38
|
let disposed = false;
|
|
131
|
-
|
|
132
39
|
const load = async (refresh = false): Promise<void> => {
|
|
133
40
|
if (disposed) return;
|
|
134
41
|
setInFlight((n) => n + 1);
|
|
@@ -153,24 +60,58 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
153
60
|
}
|
|
154
61
|
};
|
|
155
62
|
|
|
156
|
-
// 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
|
+
|
|
157
74
|
const intervalMs = Math.max(1, Math.floor(options.refreshIntervalSeconds)) * 1000;
|
|
158
|
-
const
|
|
75
|
+
const refresh = (): void => {
|
|
159
76
|
setTick(Date.now());
|
|
160
77
|
void load();
|
|
161
|
-
|
|
78
|
+
void refreshCapture();
|
|
79
|
+
};
|
|
80
|
+
const timer = setInterval(refresh, intervalMs);
|
|
162
81
|
|
|
163
82
|
api.lifecycle.onDispose(() => {
|
|
164
83
|
disposed = true;
|
|
165
84
|
clearInterval(timer);
|
|
166
85
|
});
|
|
167
86
|
|
|
168
|
-
const offIdle = api.event.on("session.idle",
|
|
169
|
-
setTick(Date.now());
|
|
170
|
-
void load();
|
|
171
|
-
});
|
|
87
|
+
const offIdle = api.event.on("session.idle", refresh);
|
|
172
88
|
api.lifecycle.onDispose(offIdle);
|
|
173
89
|
|
|
90
|
+
const openDialog = (): void => {
|
|
91
|
+
const sessionID = activeSessionID(api);
|
|
92
|
+
void refreshCapture().finally(() => {
|
|
93
|
+
if (disposed) return;
|
|
94
|
+
setScope(0);
|
|
95
|
+
setShowJson(false);
|
|
96
|
+
api.ui.dialog.replace(() => (
|
|
97
|
+
<UsageDialog
|
|
98
|
+
api={api}
|
|
99
|
+
sessionID={sessionID}
|
|
100
|
+
reports={reports}
|
|
101
|
+
busy={busy}
|
|
102
|
+
error={error}
|
|
103
|
+
now={() => new Date(tick())}
|
|
104
|
+
system={capture}
|
|
105
|
+
scope={scope}
|
|
106
|
+
showJson={showJson}
|
|
107
|
+
barWidth={options.barWidth}
|
|
108
|
+
/>
|
|
109
|
+
));
|
|
110
|
+
// Must follow replace(): the stack resets the size to "medium".
|
|
111
|
+
api.ui.dialog.setSize("xlarge");
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
|
|
174
115
|
api.lifecycle.onDispose(
|
|
175
116
|
api.keymap.registerLayer({
|
|
176
117
|
commands: [
|
|
@@ -183,11 +124,47 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
183
124
|
void load(true);
|
|
184
125
|
},
|
|
185
126
|
},
|
|
127
|
+
{
|
|
128
|
+
name: "usage.open",
|
|
129
|
+
title: "Usage: open report",
|
|
130
|
+
description: "Context usage and provider quota windows",
|
|
131
|
+
category: "Plugin",
|
|
132
|
+
namespace: "palette",
|
|
133
|
+
slashName: "usage",
|
|
134
|
+
run: openDialog,
|
|
135
|
+
},
|
|
186
136
|
],
|
|
187
137
|
bindings: [{ key: "ctrl+shift+u", cmd: "usage.refresh", desc: "Refresh usage panel" }],
|
|
188
138
|
}),
|
|
189
139
|
);
|
|
190
140
|
|
|
141
|
+
// Scoped to the dialog's "modal" mode so these keys only fire while it is open.
|
|
142
|
+
api.lifecycle.onDispose(
|
|
143
|
+
api.keymap.registerLayer({
|
|
144
|
+
mode: "modal",
|
|
145
|
+
commands: [
|
|
146
|
+
{ name: "usage.dialog.refresh", run: () => void load(true) },
|
|
147
|
+
{
|
|
148
|
+
name: "usage.dialog.scope",
|
|
149
|
+
run: () => {
|
|
150
|
+
setScope((n) => (n + 1) % Math.max(1, reports().length + 1));
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: "usage.dialog.json",
|
|
155
|
+
run: () => {
|
|
156
|
+
setShowJson((v) => !v);
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
bindings: [
|
|
161
|
+
{ key: "r", cmd: "usage.dialog.refresh", desc: "Refresh usage report" },
|
|
162
|
+
{ key: "tab", cmd: "usage.dialog.scope", desc: "Cycle provider scope" },
|
|
163
|
+
{ key: "j", cmd: "usage.dialog.json", desc: "Toggle raw JSON" },
|
|
164
|
+
],
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
|
|
191
168
|
const slot: TuiSlotPlugin = {
|
|
192
169
|
order: 150,
|
|
193
170
|
slots: {
|