opencode-usage-report 0.3.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 +201 -69
- package/package.json +1 -1
- 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/index.ts +27 -9
- package/src/quota-lines.ts +116 -0
- package/src/tui.tsx +90 -110
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System-prompt capture sidecar. The server plugin writes the fully joined
|
|
3
|
+
* system prompt's size once per request; the TUI reads it back to split the
|
|
4
|
+
* residual "System & tools" row. Every operation is best-effort and total:
|
|
5
|
+
* failures degrade to `null` and must never break a request.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type { SystemCapture } from "./types";
|
|
10
|
+
import { pluginStateDir } from "../paths";
|
|
11
|
+
|
|
12
|
+
const CAPTURE_VERSION = 1;
|
|
13
|
+
const WRITE_THROTTLE_MS = 60_000;
|
|
14
|
+
const MAX_SESSION_ID_LENGTH = 200;
|
|
15
|
+
|
|
16
|
+
/** sessionID -> epoch ms of the last successful write, for throttling. */
|
|
17
|
+
const writeThrottle = new Map<string, number>();
|
|
18
|
+
|
|
19
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
20
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function nonNegative(value: unknown): number | null {
|
|
24
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** <pluginStateDir>/context/<sanitized-sessionID>.json */
|
|
28
|
+
export function contextCapturePath(sessionID: string, env?: NodeJS.ProcessEnv): string {
|
|
29
|
+
const raw = typeof sessionID === "string" ? sessionID : "";
|
|
30
|
+
const safe = raw.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, MAX_SESSION_ID_LENGTH) || "_";
|
|
31
|
+
return join(pluginStateDir(env), "context", `${safe}.json`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reads and validates a capture. Returns null for a missing/unreadable file,
|
|
36
|
+
* invalid JSON, an unsupported version, a session mismatch, non-string
|
|
37
|
+
* provider/model, or non-finite/negative sizes and timestamps.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately independent of the write-throttle map: the server plugin writes
|
|
40
|
+
* and the TUI plugin reads in separate processes, so a reader can never have a
|
|
41
|
+
* throttle entry of its own.
|
|
42
|
+
*/
|
|
43
|
+
export async function readCapture(
|
|
44
|
+
sessionID: string,
|
|
45
|
+
env?: NodeJS.ProcessEnv,
|
|
46
|
+
): Promise<SystemCapture | null> {
|
|
47
|
+
try {
|
|
48
|
+
if (typeof sessionID !== "string") return null;
|
|
49
|
+
|
|
50
|
+
const raw = await readFile(contextCapturePath(sessionID, env), "utf8");
|
|
51
|
+
const parsed: unknown = JSON.parse(raw);
|
|
52
|
+
if (!isRecord(parsed)) return null;
|
|
53
|
+
if (parsed.version !== CAPTURE_VERSION) return null;
|
|
54
|
+
if (parsed.sessionID !== sessionID) return null;
|
|
55
|
+
if (typeof parsed.providerID !== "string" || parsed.providerID === "") return null;
|
|
56
|
+
if (typeof parsed.modelID !== "string" || parsed.modelID === "") return null;
|
|
57
|
+
|
|
58
|
+
const systemChars = nonNegative(parsed.systemChars);
|
|
59
|
+
const systemTokens = nonNegative(parsed.systemTokens);
|
|
60
|
+
const capturedAt = nonNegative(parsed.capturedAt);
|
|
61
|
+
if (systemChars === null || systemTokens === null || capturedAt === null) return null;
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
version: CAPTURE_VERSION,
|
|
65
|
+
sessionID,
|
|
66
|
+
providerID: parsed.providerID,
|
|
67
|
+
modelID: parsed.modelID,
|
|
68
|
+
systemChars,
|
|
69
|
+
systemTokens,
|
|
70
|
+
capturedAt,
|
|
71
|
+
};
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Atomically persists a capture (temp file + rename), throttled to at most one
|
|
79
|
+
* write per session per 60 s unless the provider/model changed. Never throws.
|
|
80
|
+
*/
|
|
81
|
+
export async function writeCapture(capture: SystemCapture, env?: NodeJS.ProcessEnv): Promise<void> {
|
|
82
|
+
try {
|
|
83
|
+
if (!isRecord(capture)) return;
|
|
84
|
+
const { sessionID, providerID, modelID } = capture;
|
|
85
|
+
if (typeof sessionID !== "string" || sessionID === "") return;
|
|
86
|
+
if (typeof providerID !== "string" || providerID === "") return;
|
|
87
|
+
if (typeof modelID !== "string" || modelID === "") return;
|
|
88
|
+
if (capture.version !== CAPTURE_VERSION) return;
|
|
89
|
+
|
|
90
|
+
const systemChars = nonNegative(capture.systemChars);
|
|
91
|
+
const systemTokens = nonNegative(capture.systemTokens);
|
|
92
|
+
const capturedAt = nonNegative(capture.capturedAt);
|
|
93
|
+
if (systemChars === null || systemTokens === null || capturedAt === null) return;
|
|
94
|
+
|
|
95
|
+
const previous = await readCapture(sessionID, env);
|
|
96
|
+
const modelChanged =
|
|
97
|
+
previous === null || previous.providerID !== providerID || previous.modelID !== modelID;
|
|
98
|
+
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
const last = writeThrottle.get(sessionID);
|
|
101
|
+
if (!modelChanged && typeof last === "number" && now - last < WRITE_THROTTLE_MS) return;
|
|
102
|
+
|
|
103
|
+
const directory = join(pluginStateDir(env), "context");
|
|
104
|
+
await mkdir(directory, { recursive: true });
|
|
105
|
+
const file = contextCapturePath(sessionID, env);
|
|
106
|
+
const normalized: SystemCapture = {
|
|
107
|
+
version: CAPTURE_VERSION,
|
|
108
|
+
sessionID,
|
|
109
|
+
providerID,
|
|
110
|
+
modelID,
|
|
111
|
+
systemChars,
|
|
112
|
+
systemTokens,
|
|
113
|
+
capturedAt,
|
|
114
|
+
};
|
|
115
|
+
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
116
|
+
await writeFile(tmp, JSON.stringify(normalized), "utf8");
|
|
117
|
+
await rename(tmp, file);
|
|
118
|
+
writeThrottle.set(sessionID, now);
|
|
119
|
+
} catch {
|
|
120
|
+
// Capture writes are best-effort; a failure must never break a request.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* True only when the capture matches the session/provider/model and was taken
|
|
126
|
+
* at or after `since`.
|
|
127
|
+
*/
|
|
128
|
+
export function captureIsFresh(
|
|
129
|
+
capture: SystemCapture | null | undefined,
|
|
130
|
+
reference: { sessionID: string; providerID: string; modelID: string; since: number },
|
|
131
|
+
): boolean {
|
|
132
|
+
if (!isRecord(capture)) return false;
|
|
133
|
+
const ref = isRecord(reference) ? reference : null;
|
|
134
|
+
if (ref === null) return false;
|
|
135
|
+
if (capture.sessionID !== ref.sessionID) return false;
|
|
136
|
+
if (capture.providerID !== ref.providerID) return false;
|
|
137
|
+
if (capture.modelID !== ref.modelID) return false;
|
|
138
|
+
const capturedAt = nonNegative(capture.capturedAt);
|
|
139
|
+
const since = nonNegative(ref.since);
|
|
140
|
+
if (capturedAt === null || since === null) return false;
|
|
141
|
+
return capturedAt >= since;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Clears the write-throttle map so tests can reset between cases. */
|
|
145
|
+
export function resetCaptureThrottle(): void {
|
|
146
|
+
writeThrottle.clear();
|
|
147
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Message, Model, Part } from "@opencode-ai/sdk/v2";
|
|
2
|
+
|
|
3
|
+
export type RowKey = "user" | "agent" | "reasoning" | "tools" | "system" | "free";
|
|
4
|
+
|
|
5
|
+
export interface ContextRow {
|
|
6
|
+
key: RowKey;
|
|
7
|
+
label: string;
|
|
8
|
+
tokens: number | null;
|
|
9
|
+
percent: number | null; // 0-100, null when the limit is unknown
|
|
10
|
+
exact: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface GridCell {
|
|
14
|
+
rowKey: RowKey | null; // null = free space
|
|
15
|
+
fill: number; // 0..1
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ContextBreakdown {
|
|
19
|
+
ready: boolean;
|
|
20
|
+
modelName: string | null;
|
|
21
|
+
total: number | null;
|
|
22
|
+
limit: number | null;
|
|
23
|
+
cost: number;
|
|
24
|
+
rows: ContextRow[];
|
|
25
|
+
grid: { cols: number; rows: number; cells: GridCell[] };
|
|
26
|
+
headroom: { usable: number | null; free: number | null; band: "ok" | "warning" | "error" };
|
|
27
|
+
systemDerived: boolean;
|
|
28
|
+
/** Measured system-prompt tokens when a valid capture was supplied, else null. */
|
|
29
|
+
systemCaptured: number | null;
|
|
30
|
+
prunedToolOutputs: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SystemCapture {
|
|
34
|
+
version: 1;
|
|
35
|
+
sessionID: string;
|
|
36
|
+
providerID: string;
|
|
37
|
+
modelID: string;
|
|
38
|
+
systemChars: number;
|
|
39
|
+
systemTokens: number;
|
|
40
|
+
capturedAt: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CollectInput {
|
|
44
|
+
messages: readonly Message[];
|
|
45
|
+
model: Model | null;
|
|
46
|
+
part?: ((messageID: string) => readonly Part[]) | null;
|
|
47
|
+
system?: SystemCapture | null;
|
|
48
|
+
sessionCost?: number;
|
|
49
|
+
cols?: number | null;
|
|
50
|
+
compaction?: { reserved?: number } | null;
|
|
51
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { renderJson, renderText } from "./render";
|
|
|
5
5
|
import { checkWarnings } from "./warn";
|
|
6
6
|
import { toNumber } from "./normalize";
|
|
7
7
|
import { adapters } from "./providers/index";
|
|
8
|
+
import { estimateTokens } from "./context/estimate";
|
|
9
|
+
import { writeCapture } from "./context/system";
|
|
8
10
|
import type { PluginOptions } from "./types";
|
|
9
11
|
|
|
10
12
|
const WARN_THROTTLE_MS = 10 * 60 * 1000;
|
|
@@ -13,9 +15,6 @@ const TOOL_DESCRIPTION =
|
|
|
13
15
|
"Show subscription usage/quota windows (5h, weekly, monthly) for configured inference providers " +
|
|
14
16
|
`(${adapters.map((adapter) => adapter.id).join(", ")})`;
|
|
15
17
|
|
|
16
|
-
const COMMAND_TEMPLATE =
|
|
17
|
-
"Call the usage_report tool with these arguments: $ARGUMENTS and present the result verbatim.";
|
|
18
|
-
|
|
19
18
|
/** Merges user plugin options over defaults, defensively ignoring malformed values. */
|
|
20
19
|
export function coerceOptions(raw: Record<string, unknown> | undefined): PluginOptions {
|
|
21
20
|
const opts: PluginOptions = { ...DEFAULT_OPTIONS };
|
|
@@ -91,12 +90,31 @@ const plugin: Plugin = async ({ client }, options) => {
|
|
|
91
90
|
}),
|
|
92
91
|
},
|
|
93
92
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
93
|
+
// `/usage` is owned by the TUI plugin so the slash command opens a local
|
|
94
|
+
// dialog instead of routing through the model. This hook's only job is to
|
|
95
|
+
// measure the assembled system prompt, which opencode never persists, so
|
|
96
|
+
// the TUI can split the "System & tools" residual (spec §9.1).
|
|
97
|
+
async "experimental.chat.system.transform"(input, output) {
|
|
98
|
+
try {
|
|
99
|
+
const sessionID = input?.sessionID;
|
|
100
|
+
if (typeof sessionID !== "string" || sessionID === "") return;
|
|
101
|
+
const model = input?.model;
|
|
102
|
+
if (model === undefined) return;
|
|
103
|
+
const system = Array.isArray(output?.system) ? output.system.join("\n") : "";
|
|
104
|
+
if (system === "") return;
|
|
105
|
+
|
|
106
|
+
await writeCapture({
|
|
107
|
+
version: 1,
|
|
108
|
+
sessionID,
|
|
109
|
+
providerID: model.providerID,
|
|
110
|
+
modelID: model.id,
|
|
111
|
+
systemChars: system.length,
|
|
112
|
+
systemTokens: estimateTokens(system),
|
|
113
|
+
capturedAt: Date.now(),
|
|
114
|
+
});
|
|
115
|
+
} catch {
|
|
116
|
+
// Best-effort: an experimental hook must never be able to break a request.
|
|
117
|
+
}
|
|
100
118
|
},
|
|
101
119
|
|
|
102
120
|
async event({ event }) {
|
|
@@ -0,0 +1,116 @@
|
|
|
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, countdown, 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
|
+
export const WINDOW_ORDER: Record<WindowKind, number> = {
|
|
29
|
+
"5h": 0,
|
|
30
|
+
weekly: 1,
|
|
31
|
+
monthly: 2,
|
|
32
|
+
daily: 3,
|
|
33
|
+
other: 4,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function windowRank(kind: WindowKind): number {
|
|
37
|
+
return WINDOW_ORDER[kind] ?? WINDOW_ORDER.other;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Collapses whitespace and truncates to a single-line label. */
|
|
41
|
+
export function clip(value: string, max = 60): string {
|
|
42
|
+
const single = value.replace(/\s+/g, " ").trim();
|
|
43
|
+
if (single.length <= max) return single;
|
|
44
|
+
return single.slice(0, Math.max(0, max - 1)) + "…";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Pads a label to a fixed width, truncating (with a trailing space) when long. */
|
|
48
|
+
export function alignLabel(label: string): string {
|
|
49
|
+
const text = label.length > NAMED_WIDTH ? label.slice(0, NAMED_WIDTH - 1) + " " : label;
|
|
50
|
+
return text.padEnd(NAMED_WIDTH);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Flattens provider reports into plain, pre-colored text lines. */
|
|
54
|
+
export function buildLines(
|
|
55
|
+
theme: Theme,
|
|
56
|
+
reports: ProviderReport[],
|
|
57
|
+
busy: boolean,
|
|
58
|
+
error: string | null,
|
|
59
|
+
now: Date,
|
|
60
|
+
barWidth: number,
|
|
61
|
+
): Line[] {
|
|
62
|
+
const lines: Line[] = [];
|
|
63
|
+
|
|
64
|
+
if (error !== null) {
|
|
65
|
+
lines.push({
|
|
66
|
+
segments: [{ text: `api error: ${clip(error)}`, fg: theme.error }],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (reports.length === 0 && busy) {
|
|
71
|
+
lines.push({
|
|
72
|
+
segments: [{ text: "loading…", fg: theme.textMuted }],
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const report of reports) {
|
|
77
|
+
const nameColor = report.source === "error" ? theme.textMuted : theme.accent;
|
|
78
|
+
const segments: Segment[] = [{ text: report.displayName, fg: nameColor, bold: true }];
|
|
79
|
+
if (report.stale) segments.push({ text: " (stale)", fg: theme.textMuted });
|
|
80
|
+
if (report.source === "local-estimate") {
|
|
81
|
+
segments.push({ text: " (est)", fg: theme.textMuted });
|
|
82
|
+
}
|
|
83
|
+
lines.push({ segments });
|
|
84
|
+
|
|
85
|
+
if (report.source === "error") {
|
|
86
|
+
lines.push({
|
|
87
|
+
segments: [{ text: clip(report.error ?? "unknown error"), fg: theme.error }],
|
|
88
|
+
});
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const windows = report.windows.toSorted((a, b) => windowRank(a.kind) - windowRank(b.kind));
|
|
93
|
+
for (const w of windows) {
|
|
94
|
+
const tone = toneFor(w.usedPercent, w.status);
|
|
95
|
+
const row: Segment[] = [
|
|
96
|
+
{ text: alignLabel(windowLabel(w.kind, w.label)), fg: theme.textMuted },
|
|
97
|
+
{ text: bar(w.usedPercent, barWidth), fg: theme[tone] },
|
|
98
|
+
{ text: " ", fg: theme.text },
|
|
99
|
+
{ text: percentText(w), fg: theme.text },
|
|
100
|
+
];
|
|
101
|
+
const reset = countdown(w.resetsAt, now);
|
|
102
|
+
if (reset !== null) {
|
|
103
|
+
row.push({ text: ` resets in ${reset}`, fg: theme.textMuted });
|
|
104
|
+
}
|
|
105
|
+
lines.push({ segments: row });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const [key, value] of Object.entries(report.extras)) {
|
|
109
|
+
lines.push({
|
|
110
|
+
segments: [{ text: clip(`${key}: ${value}`, EXTRAS_MAX_LENGTH), fg: theme.textMuted }],
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return lines;
|
|
116
|
+
}
|
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,11 +60,22 @@ 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
75
|
const timer = setInterval(() => {
|
|
159
76
|
setTick(Date.now());
|
|
160
77
|
void load();
|
|
78
|
+
void refreshCapture();
|
|
161
79
|
}, intervalMs);
|
|
162
80
|
|
|
163
81
|
api.lifecycle.onDispose(() => {
|
|
@@ -168,9 +86,35 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
168
86
|
const offIdle = api.event.on("session.idle", () => {
|
|
169
87
|
setTick(Date.now());
|
|
170
88
|
void load();
|
|
89
|
+
void refreshCapture();
|
|
171
90
|
});
|
|
172
91
|
api.lifecycle.onDispose(offIdle);
|
|
173
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
|
+
|
|
174
118
|
api.lifecycle.onDispose(
|
|
175
119
|
api.keymap.registerLayer({
|
|
176
120
|
commands: [
|
|
@@ -183,11 +127,47 @@ export const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
183
127
|
void load(true);
|
|
184
128
|
},
|
|
185
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
|
+
},
|
|
186
139
|
],
|
|
187
140
|
bindings: [{ key: "ctrl+shift+u", cmd: "usage.refresh", desc: "Refresh usage panel" }],
|
|
188
141
|
}),
|
|
189
142
|
);
|
|
190
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
|
+
},
|
|
162
|
+
],
|
|
163
|
+
bindings: [
|
|
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" },
|
|
167
|
+
],
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
|
|
191
171
|
const slot: TuiSlotPlugin = {
|
|
192
172
|
order: 150,
|
|
193
173
|
slots: {
|