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.
@@ -0,0 +1,251 @@
1
+ import type { AssistantMessage, Part } from "@opencode-ai/sdk/v2";
2
+ import type { CollectInput, ContextBreakdown, ContextRow, RowKey, SystemCapture } from "./types";
3
+ import { estimateJson, estimateTokens } from "./estimate";
4
+ import { buildGrid } from "./grid";
5
+ import { computeHeadroom } from "./headroom";
6
+
7
+ const ROW_LABELS: Record<RowKey, string> = {
8
+ user: "User messages",
9
+ agent: "Agent responses",
10
+ reasoning: "Reasoning",
11
+ tools: "Tool calls",
12
+ system: "System & tools",
13
+ free: "Free space",
14
+ };
15
+
16
+ const MEASURED_KEYS: readonly RowKey[] = ["user", "agent", "reasoning", "tools", "system"];
17
+
18
+ function isRecord(value: unknown): value is Record<string, unknown> {
19
+ return typeof value === "object" && value !== null;
20
+ }
21
+
22
+ function finiteOrNull(value: unknown): number | null {
23
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
24
+ }
25
+
26
+ function safeString(value: unknown): string {
27
+ if (typeof value === "string") return value;
28
+ if (value === null || value === undefined) return "";
29
+ try {
30
+ return String(value);
31
+ } catch {
32
+ return "";
33
+ }
34
+ }
35
+
36
+ function isAssistant(value: unknown): value is AssistantMessage {
37
+ return isRecord(value) && value.role === "assistant";
38
+ }
39
+
40
+ function readParts(accessor: CollectInput["part"], messageID: string): readonly Part[] {
41
+ if (typeof accessor !== "function") return [];
42
+ try {
43
+ const parts = accessor(messageID);
44
+ return Array.isArray(parts) ? parts : [];
45
+ } catch {
46
+ return [];
47
+ }
48
+ }
49
+
50
+ function lastReportingAssistant(messages: readonly unknown[]): AssistantMessage | null {
51
+ for (let i = messages.length - 1; i >= 0; i--) {
52
+ const message = messages[i];
53
+ if (!isAssistant(message)) continue;
54
+ const output = finiteOrNull(isRecord(message.tokens) ? message.tokens.output : undefined);
55
+ if (output !== null && output > 0) return message;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ function summaryStartIndex(messages: readonly unknown[]): number {
61
+ for (let i = messages.length - 1; i >= 0; i--) {
62
+ const message = messages[i];
63
+ if (isAssistant(message) && message.summary === true) return i + 1;
64
+ }
65
+ return 0;
66
+ }
67
+
68
+ function isUsableCapture(capture: SystemCapture | null | undefined): capture is SystemCapture {
69
+ if (!isRecord(capture)) return false;
70
+ const tokens = finiteOrNull(capture.systemTokens);
71
+ return capture.version === 1 && tokens !== null && tokens >= 0;
72
+ }
73
+
74
+ /**
75
+ * Scales `values` so they sum to `target` by the largest-remainder method; the
76
+ * result never exceeds `target`. Used when raw `chars/4` estimates overshoot.
77
+ */
78
+ function largestRemainderScale(
79
+ values: readonly number[],
80
+ target: number,
81
+ divisor: number,
82
+ ): number[] {
83
+ if (!Number.isFinite(divisor) || divisor <= 0) return values.map(() => 0);
84
+ const total = Math.max(0, Math.round(target));
85
+ const scaled = values.map((value) => (value > 0 ? (value * total) / divisor : 0));
86
+ const rounded = scaled.map((value) => Math.floor(value));
87
+ let deficit = total - rounded.reduce((sum, value) => sum + value, 0);
88
+ if (deficit <= 0) return rounded;
89
+
90
+ const order = scaled
91
+ .map((value, index) => ({ index, fraction: value - Math.floor(value) }))
92
+ .sort((a, b) => b.fraction - a.fraction);
93
+ for (const { index } of order) {
94
+ if (deficit <= 0) break;
95
+ rounded[index] += 1;
96
+ deficit -= 1;
97
+ }
98
+ return rounded;
99
+ }
100
+
101
+ function makeRow(
102
+ key: RowKey,
103
+ tokens: number | null,
104
+ exact: boolean,
105
+ limit: number | null,
106
+ ): ContextRow {
107
+ const percent =
108
+ tokens !== null && limit !== null && limit > 0
109
+ ? Math.min(100, Math.max(0, Math.round((tokens / limit) * 100)))
110
+ : null;
111
+ return { key, label: ROW_LABELS[key], tokens, percent, exact };
112
+ }
113
+
114
+ function emptyRows(limit: number | null): ContextRow[] {
115
+ return [...MEASURED_KEYS, "free" as RowKey].map((key) =>
116
+ makeRow(key, null, key === "free", limit),
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Builds a `ContextBreakdown` from session messages. Exact prompt totals come
122
+ * from the last reporting assistant message; the remaining rows are `chars/4`
123
+ * estimates normalized against that total. Never throws and never emits NaN.
124
+ */
125
+ export function collectContext(input: CollectInput): ContextBreakdown {
126
+ const source = (isRecord(input) ? input : {}) as CollectInput;
127
+ const messages: readonly unknown[] = Array.isArray(source.messages) ? source.messages : [];
128
+ const limit = finiteOrNull(source.model?.limit?.context);
129
+ const modelName = typeof source.model?.name === "string" ? source.model.name : null;
130
+ const cost = finiteOrNull(source.sessionCost) ?? 0;
131
+ const systemDerived = !isUsableCapture(source.system);
132
+ const systemCaptured = systemDerived ? null : (source.system as SystemCapture).systemTokens;
133
+ const cols = typeof source.cols === "number" && Number.isFinite(source.cols) ? source.cols : null;
134
+
135
+ const last = lastReportingAssistant(messages);
136
+ if (last === null) {
137
+ const rows = emptyRows(limit);
138
+ return {
139
+ ready: false,
140
+ modelName,
141
+ total: null,
142
+ limit,
143
+ cost,
144
+ rows,
145
+ grid: buildGrid({ rows, limit, total: null, cols }),
146
+ headroom: computeHeadroom({
147
+ limit: source.model?.limit ?? null,
148
+ total: null,
149
+ compaction: source.compaction ?? null,
150
+ }),
151
+ systemDerived,
152
+ systemCaptured,
153
+ prunedToolOutputs: 0,
154
+ };
155
+ }
156
+
157
+ const tokens: Record<string, unknown> = isRecord(last.tokens) ? last.tokens : {};
158
+ const cache: Record<string, unknown> = isRecord(tokens.cache) ? tokens.cache : {};
159
+ const total = Math.max(
160
+ 0,
161
+ (finiteOrNull(tokens.input) ?? 0) +
162
+ (finiteOrNull(cache.read) ?? 0) +
163
+ (finiteOrNull(cache.write) ?? 0),
164
+ );
165
+
166
+ let userTokens = 0;
167
+ let agentTokens = 0;
168
+ let reasoningTokens = 0;
169
+ let toolsTokens = 0;
170
+ let prunedToolOutputs = 0;
171
+
172
+ const start = summaryStartIndex(messages);
173
+ for (let i = start; i < messages.length; i++) {
174
+ const message = messages[i];
175
+ if (!isRecord(message)) continue;
176
+ const role = message.role;
177
+ if (role !== "user" && role !== "assistant") continue;
178
+ const messageID = typeof message.id === "string" ? message.id : "";
179
+
180
+ for (const part of readParts(source.part, messageID)) {
181
+ if (!isRecord(part)) continue;
182
+ const type = part.type;
183
+
184
+ if (type === "text") {
185
+ const estimated = estimateTokens(safeString(part.text));
186
+ if (role === "user") userTokens += estimated;
187
+ else agentTokens += estimated;
188
+ } else if (type === "reasoning") {
189
+ if (role === "assistant") reasoningTokens += estimateTokens(safeString(part.text));
190
+ } else if (type === "file") {
191
+ if (role === "user") {
192
+ userTokens += estimateTokens(safeString(part.filename) + safeString(part.mime));
193
+ }
194
+ } else if (type === "tool") {
195
+ const state = part.state;
196
+ const stateRecord: Record<string, unknown> = isRecord(state) ? state : {};
197
+ const timeRecord: Record<string, unknown> = isRecord(stateRecord.time)
198
+ ? stateRecord.time
199
+ : {};
200
+ if (timeRecord.compacted !== undefined && timeRecord.compacted !== null) {
201
+ prunedToolOutputs += 1;
202
+ continue;
203
+ }
204
+ toolsTokens +=
205
+ estimateJson(stateRecord.input) + estimateTokens(safeString(stateRecord.output));
206
+ }
207
+ // step-start, step-finish, snapshot and every other part type are skipped.
208
+ }
209
+ }
210
+
211
+ const estimatedSum = userTokens + agentTokens + reasoningTokens + toolsTokens;
212
+ let systemTokens = Math.max(0, total - estimatedSum);
213
+
214
+ // chars/4 is an estimate in both directions; when it overshoots the exact
215
+ // total, scale all five measured rows down so they sum to `total` (6.5).
216
+ if (estimatedSum > total) {
217
+ const scaled = largestRemainderScale(
218
+ [userTokens, agentTokens, reasoningTokens, toolsTokens, systemTokens],
219
+ total,
220
+ estimatedSum,
221
+ );
222
+ userTokens = scaled[0];
223
+ agentTokens = scaled[1];
224
+ reasoningTokens = scaled[2];
225
+ toolsTokens = scaled[3];
226
+ systemTokens = scaled[4];
227
+ }
228
+
229
+ const measured = [userTokens, agentTokens, reasoningTokens, toolsTokens, systemTokens];
230
+ const rows = MEASURED_KEYS.map((key, index) => makeRow(key, measured[index], false, limit));
231
+ const freeTokens = limit !== null ? Math.max(0, limit - total) : null;
232
+ rows.push(makeRow("free", freeTokens, true, limit));
233
+
234
+ return {
235
+ ready: true,
236
+ modelName,
237
+ total,
238
+ limit,
239
+ cost,
240
+ rows,
241
+ grid: buildGrid({ rows, limit, total, cols }),
242
+ headroom: computeHeadroom({
243
+ limit: source.model?.limit ?? null,
244
+ total,
245
+ compaction: source.compaction ?? null,
246
+ }),
247
+ systemDerived,
248
+ systemCaptured,
249
+ prunedToolOutputs,
250
+ };
251
+ }
@@ -0,0 +1,184 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
3
+ import type { AssistantMessage, Model } from "@opencode-ai/sdk/v2";
4
+ import { For, Show, createMemo } from "solid-js";
5
+ import { buildContextLines, type ContextTone } from "./format";
6
+ import { collectContext } from "./collect";
7
+ import { captureIsFresh } from "./system";
8
+ import type { ContextBreakdown, SystemCapture } from "./types";
9
+ import { buildLines } from "../quota-lines";
10
+ import type { ProviderReport } from "../types";
11
+
12
+ type Theme = TuiPluginApi["theme"]["current"];
13
+
14
+ const GRID_COLS = 48;
15
+
16
+ export interface UsageDialogProps {
17
+ api: TuiPluginApi;
18
+ sessionID: string | null;
19
+ /** Accessors, not values: the dialog must stay live while the dialog is open. */
20
+ reports: () => ProviderReport[];
21
+ busy: () => boolean;
22
+ error: () => string | null;
23
+ now: () => Date;
24
+ system: () => SystemCapture | null;
25
+ scope: () => number;
26
+ showJson: () => boolean;
27
+ barWidth: number;
28
+ }
29
+
30
+ /**
31
+ * A rendered line. `fg` is either a theme color name (context block) or an
32
+ * already-resolved RGBA (quota block), so both feeds share one renderer.
33
+ */
34
+ interface RenderedLine {
35
+ segments: { text: string; fg: unknown; bold?: boolean }[];
36
+ }
37
+
38
+ function resolveColor(theme: Theme, fg: unknown) {
39
+ return typeof fg === "string" ? theme[fg as ContextTone] : fg;
40
+ }
41
+
42
+ /** Renders pre-colored lines; shared by the context, quota and raw blocks. */
43
+ function Lines(props: { lines: RenderedLine[]; theme: Theme }) {
44
+ return (
45
+ <For each={props.lines}>
46
+ {(line) => (
47
+ <text>
48
+ {line.segments.map((seg) => {
49
+ const color = resolveColor(props.theme, seg.fg);
50
+ return seg.bold ? (
51
+ <b style={{ fg: color }}>{seg.text}</b>
52
+ ) : (
53
+ <span style={{ fg: color }}>{seg.text}</span>
54
+ );
55
+ })}
56
+ </text>
57
+ )}
58
+ </For>
59
+ );
60
+ }
61
+
62
+ function jsonLines(value: unknown): RenderedLine[] {
63
+ let text: string;
64
+ try {
65
+ text = JSON.stringify(value, null, 2) ?? "";
66
+ } catch {
67
+ text = "{}";
68
+ }
69
+ return text.split("\n").map((row) => ({ segments: [{ text: row, fg: "textMuted" as const }] }));
70
+ }
71
+
72
+ /** The `/usage` dialog: context usage on top, the existing quota output below. */
73
+ export function UsageDialog(props: UsageDialogProps) {
74
+ const theme = () => props.api.theme.current;
75
+
76
+ const messages = createMemo(() =>
77
+ props.sessionID === null ? [] : props.api.state.session.messages(props.sessionID),
78
+ );
79
+ const reference = createMemo(() =>
80
+ messages().findLast(
81
+ (item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0,
82
+ ),
83
+ );
84
+ const model = createMemo<Model | undefined>(() => {
85
+ const last = reference();
86
+ if (last === undefined) return undefined;
87
+ return props.api.state.provider.find((item) => item.id === last.providerID)?.models[
88
+ last.modelID
89
+ ];
90
+ });
91
+
92
+ /** The capture only counts when it matches this message's session, model and turn. */
93
+ const freshCapture = createMemo<SystemCapture | null>(() => {
94
+ const last = reference();
95
+ const capture = props.system();
96
+ if (last === undefined || capture === null) return null;
97
+ const ok = captureIsFresh(capture, {
98
+ sessionID: last.sessionID,
99
+ providerID: last.providerID,
100
+ modelID: last.modelID,
101
+ since: last.time?.created ?? 0,
102
+ });
103
+ return ok ? capture : null;
104
+ });
105
+
106
+ const scopedReports = createMemo(() => {
107
+ const all = props.reports();
108
+ if (props.scope() <= 0) return all;
109
+ const target = all[props.scope() - 1];
110
+ return target === undefined ? all : [target];
111
+ });
112
+
113
+ const breakdown = createMemo<ContextBreakdown>(() =>
114
+ collectContext({
115
+ messages: messages(),
116
+ model: model() ?? null,
117
+ part: (messageID) => props.api.state.part(messageID),
118
+ system: freshCapture(),
119
+ sessionCost:
120
+ props.sessionID === null ? 0 : (props.api.state.session.get(props.sessionID)?.cost ?? 0),
121
+ cols: GRID_COLS,
122
+ compaction: props.api.state.config?.compaction ?? null,
123
+ }),
124
+ );
125
+
126
+ const modelLabel = createMemo(() => {
127
+ const last = reference();
128
+ return last === undefined ? null : `${last.providerID}/${last.modelID}`;
129
+ });
130
+
131
+ const contextLines = createMemo(() =>
132
+ buildContextLines(breakdown(), { modelLabel: modelLabel() }),
133
+ );
134
+ const quotaLines = createMemo(() =>
135
+ buildLines(theme(), scopedReports(), props.busy(), props.error(), props.now(), props.barWidth),
136
+ );
137
+ const rawLines = createMemo(() => jsonLines({ context: breakdown(), reports: scopedReports() }));
138
+
139
+ const footer = createMemo(() => {
140
+ const hints = ["esc close"];
141
+ if (props.reports().length > 1) {
142
+ const current = props.scope() <= 0 ? "all" : (scopedReports()[0]?.provider ?? "?");
143
+ hints.push(`tab scope: ${current}`);
144
+ }
145
+ hints.push("r refresh", "j json");
146
+ return hints.join(" · ");
147
+ });
148
+
149
+ return (
150
+ <box paddingLeft={2} paddingRight={2} paddingBottom={1} flexDirection="column" gap={1}>
151
+ <box flexDirection="row" justifyContent="space-between">
152
+ <text fg={theme().text}>
153
+ <b>Usage</b>
154
+ </text>
155
+ <text fg={theme().textMuted}>{footer()}</text>
156
+ </box>
157
+
158
+ <Show
159
+ when={!props.showJson()}
160
+ fallback={
161
+ <box flexDirection="column" gap={1}>
162
+ <text fg={theme().accent}>
163
+ <b>Raw</b>
164
+ </text>
165
+ <Lines lines={rawLines()} theme={theme()} />
166
+ </box>
167
+ }
168
+ >
169
+ <box flexDirection="column">
170
+ <text fg={theme().accent}>
171
+ <b>Context</b>
172
+ </text>
173
+ <Lines lines={contextLines()} theme={theme()} />
174
+ </box>
175
+
176
+ <text fg={theme().borderSubtle}>{"─".repeat(100)}</text>
177
+
178
+ <box flexDirection="column">
179
+ <Lines lines={quotaLines()} theme={theme()} />
180
+ </box>
181
+ </Show>
182
+ </box>
183
+ );
184
+ }
@@ -0,0 +1,36 @@
1
+ /** Mirrors opencode's `chars/4` estimator: non-string input yields 0, never NaN. */
2
+ export function estimateTokens(text: string): number {
3
+ const value: unknown = text;
4
+ if (typeof value !== "string") return 0;
5
+ return Math.max(0, Math.round(value.length / 4));
6
+ }
7
+
8
+ const MAX_JSON_CHARS = 2_000_000;
9
+
10
+ /**
11
+ * Estimates tokens for the JSON serialization of an arbitrary value. Circular
12
+ * references and unserializable values are handled without throwing, and the
13
+ * serialized prefix is capped so allocation stays bounded.
14
+ */
15
+ export function estimateJson(value: unknown): number {
16
+ let json: string;
17
+ try {
18
+ const seen = new WeakSet<object>();
19
+ json =
20
+ JSON.stringify(value, (_key, val) => {
21
+ if (typeof val === "bigint") return val.toString();
22
+ if (typeof val === "object" && val !== null) {
23
+ if (seen.has(val)) return "[Circular]";
24
+ seen.add(val);
25
+ }
26
+ return val;
27
+ }) ?? "";
28
+ } catch {
29
+ try {
30
+ json = String(value);
31
+ } catch {
32
+ json = "";
33
+ }
34
+ }
35
+ return estimateTokens(json.length > MAX_JSON_CHARS ? json.slice(0, MAX_JSON_CHARS) : json);
36
+ }