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.
@@ -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
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Pure formatting for the context-usage block. Turns a `ContextBreakdown` into
3
+ * pre-colored text lines (theme color names, resolved by the TSX layer) in the
4
+ * same shape `buildLines` uses in `src/quota-lines.ts`.
5
+ */
6
+ import type { ContextBreakdown, ContextRow, GridCell, RowKey } from "./types";
7
+
8
+ /** Theme color names used by the context block. */
9
+ export type ContextTone =
10
+ | "text"
11
+ | "textMuted"
12
+ | "borderSubtle"
13
+ | "info"
14
+ | "success"
15
+ | "secondary"
16
+ | "warning"
17
+ | "error";
18
+
19
+ export interface Segment {
20
+ text: string;
21
+ fg: ContextTone;
22
+ bold?: boolean;
23
+ }
24
+
25
+ export interface Line {
26
+ segments: Segment[];
27
+ }
28
+
29
+ export interface BuildContextLinesOptions {
30
+ /** Fallback shown when `breakdown.modelName` is null (e.g. raw providerID/modelID). */
31
+ modelLabel?: string | null;
32
+ }
33
+
34
+ const GRID_ROWS = 6;
35
+ const GRID_MAX_WIDTH = 116;
36
+ const GRID_MAX_COLS = Math.floor(GRID_MAX_WIDTH / 2);
37
+ const LEGEND_LABEL_WIDTH = 24;
38
+ const LEGEND_COUNT_WIDTH = 9;
39
+ const LEGEND_PERCENT_WIDTH = 6;
40
+ const LEGEND_GAP = " ";
41
+ const COLUMNS_PER_LEGEND_ROW = 3;
42
+
43
+ const ROW_LABELS: Record<RowKey, string> = {
44
+ user: "User messages",
45
+ agent: "Agent responses",
46
+ reasoning: "Reasoning",
47
+ tools: "Tool calls",
48
+ system: "System & tools",
49
+ free: "Free space",
50
+ };
51
+
52
+ const CATEGORY_TONES: Record<RowKey, ContextTone> = {
53
+ user: "info",
54
+ agent: "success",
55
+ reasoning: "secondary",
56
+ tools: "warning",
57
+ system: "textMuted",
58
+ free: "borderSubtle",
59
+ };
60
+
61
+ const LEGEND_KEYS: readonly RowKey[] = ["user", "agent", "reasoning", "tools", "system", "free"];
62
+ const BAND_TONES: Record<"ok" | "warning" | "error", ContextTone> = {
63
+ ok: "success",
64
+ warning: "warning",
65
+ error: "error",
66
+ };
67
+
68
+ const MONEY = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
69
+
70
+ /** Locale-grouped integer token count; non-finite input renders as `0`. */
71
+ export function formatTokens(n: number): string {
72
+ const value = typeof n === "number" && Number.isFinite(n) ? Math.round(n) : 0;
73
+ return value.toLocaleString("en-US");
74
+ }
75
+
76
+ /** USD currency string (e.g. `$0.42`); non-finite input renders as `$0.00`. */
77
+ export function formatMoney(n: number): string {
78
+ const value = typeof n === "number" && Number.isFinite(n) ? n : 0;
79
+ return MONEY.format(value);
80
+ }
81
+
82
+ function finite(value: unknown): number | null {
83
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
84
+ }
85
+
86
+ function nonNegative(value: unknown): number | null {
87
+ const n = finite(value);
88
+ return n !== null && n >= 0 ? n : null;
89
+ }
90
+
91
+ function glyphFor(fill: number): string {
92
+ if (fill <= 0) return "░░";
93
+ if (fill <= 0.25) return "▒▒";
94
+ if (fill <= 0.75) return "▓▓";
95
+ return "██";
96
+ }
97
+
98
+ function toneForRowKey(key: unknown): ContextTone {
99
+ if (typeof key !== "string") return "borderSubtle";
100
+ return CATEGORY_TONES[key as RowKey] ?? "borderSubtle";
101
+ }
102
+
103
+ function truncateLabel(label: string, width: number): string {
104
+ if (label.length <= width) return label;
105
+ return label.slice(0, Math.max(0, width - 1)) + "…";
106
+ }
107
+
108
+ function padLabel(label: string, width: number): string {
109
+ return truncateLabel(label, width).padEnd(width);
110
+ }
111
+
112
+ function rightAlign(value: string, width: number): string {
113
+ return value.length >= width ? value : " ".repeat(width - value.length) + value;
114
+ }
115
+
116
+ /**
117
+ * One-decimal percentage. A non-zero share that would round to `0.0%` reads as
118
+ * "nothing at all", so anything under a tenth of a percent collapses to `<0.1%`.
119
+ */
120
+ export function formatPercent(percent: number): string {
121
+ const value = typeof percent === "number" && Number.isFinite(percent) ? percent : 0;
122
+ if (value > 0 && value < 0.1) return "<0.1%";
123
+ return `${Math.min(100, Math.max(0, value)).toFixed(1)}%`;
124
+ }
125
+
126
+ function headerMetric(breakdown: ContextBreakdown): string {
127
+ if (breakdown.ready !== true) return "0 (0.0%)";
128
+ const total = nonNegative(breakdown.total) ?? 0;
129
+ const limit = nonNegative(breakdown.limit);
130
+ if (limit === null || limit <= 0) return `${formatTokens(total)} tokens`;
131
+ const percent = (total / limit) * 100;
132
+ return `${formatTokens(total)} / ${formatTokens(limit)} tokens (${formatPercent(percent)})`;
133
+ }
134
+
135
+ function rowMap(breakdown: ContextBreakdown): Map<RowKey, ContextRow> {
136
+ const map = new Map<RowKey, ContextRow>();
137
+ const rows = Array.isArray(breakdown.rows) ? breakdown.rows : [];
138
+ for (const row of rows) {
139
+ if (row !== null && typeof row === "object" && typeof row.key === "string") {
140
+ map.set(row.key, row);
141
+ }
142
+ }
143
+ return map;
144
+ }
145
+
146
+ function legendEntry(key: RowKey, row: ContextRow | undefined, systemDerived: boolean): Segment[] {
147
+ const baseLabel =
148
+ row !== undefined && typeof row.label === "string" && row.label !== ""
149
+ ? row.label
150
+ : ROW_LABELS[key];
151
+ const label = key === "system" && systemDerived ? `${baseLabel} (derived)` : baseLabel;
152
+ const tokens = nonNegative(row?.tokens);
153
+ const percent = nonNegative(row?.percent);
154
+ const percentText = percent === null ? "—" : formatPercent(percent);
155
+ return [
156
+ { text: "● ", fg: CATEGORY_TONES[key] },
157
+ { text: padLabel(label, LEGEND_LABEL_WIDTH), fg: "text" },
158
+ {
159
+ text: rightAlign(tokens === null ? "—" : formatTokens(tokens), LEGEND_COUNT_WIDTH),
160
+ fg: "text",
161
+ },
162
+ { text: " ", fg: "text" },
163
+ { text: rightAlign(percentText, LEGEND_PERCENT_WIDTH), fg: "textMuted" },
164
+ ];
165
+ }
166
+
167
+ function gridLines(breakdown: ContextBreakdown): Line[] {
168
+ const grid = breakdown.grid;
169
+ const cols = nonNegative(grid?.cols);
170
+ if (cols === null || cols <= 0) return [];
171
+ const requestedRows = nonNegative(grid?.rows);
172
+ const rows = requestedRows !== null && requestedRows >= 1 ? Math.floor(requestedRows) : GRID_ROWS;
173
+ const renderCols = Math.min(Math.floor(cols), GRID_MAX_COLS);
174
+ const cells: readonly GridCell[] = Array.isArray(grid?.cells) ? grid.cells : [];
175
+
176
+ const lines: Line[] = [];
177
+ for (let r = 0; r < rows; r++) {
178
+ const segments: Segment[] = [];
179
+ for (let c = 0; c < renderCols; c++) {
180
+ const cell = cells[r * cols + c] as GridCell | undefined;
181
+ const fill = cell === undefined ? 0 : Math.min(1, Math.max(0, finite(cell.fill) ?? 0));
182
+ segments.push({ text: glyphFor(fill), fg: toneForRowKey(cell?.rowKey) });
183
+ }
184
+ lines.push({ segments });
185
+ }
186
+ return lines;
187
+ }
188
+
189
+ function headroomSegments(breakdown: ContextBreakdown): Segment[] {
190
+ const usable = nonNegative(breakdown.headroom?.usable);
191
+ if (usable === null || breakdown.ready !== true) return [];
192
+ const free = nonNegative(breakdown.headroom?.free) ?? 0;
193
+ const band = breakdown.headroom?.band;
194
+ const tone = band === "warning" || band === "error" ? BAND_TONES[band] : BAND_TONES.ok;
195
+ const segments: Segment[] = [
196
+ { text: `Auto-compacts at ${formatTokens(usable)} · ${formatTokens(free)} headroom`, fg: tone },
197
+ ];
198
+ const cost = nonNegative(breakdown.cost);
199
+ if (cost !== null && cost > 0) {
200
+ segments.push({ text: ` · ${formatMoney(cost)} spent`, fg: "textMuted" });
201
+ }
202
+ return segments;
203
+ }
204
+
205
+ /** Renders a context breakdown as pre-colored lines; empty until the first response. */
206
+ export function buildContextLines(
207
+ breakdown: ContextBreakdown,
208
+ options: BuildContextLinesOptions = {},
209
+ ): Line[] {
210
+ const source = (breakdown ?? {}) as ContextBreakdown;
211
+ const lines: Line[] = [];
212
+
213
+ const modelName =
214
+ typeof source.modelName === "string" && source.modelName !== "" ? source.modelName : null;
215
+ const fallback =
216
+ typeof options.modelLabel === "string" && options.modelLabel !== "" ? options.modelLabel : null;
217
+ const name = modelName ?? fallback;
218
+
219
+ const header: Segment[] = [];
220
+ if (name !== null) {
221
+ header.push({ text: name, fg: "text", bold: true });
222
+ header.push({ text: " · ", fg: "textMuted" });
223
+ }
224
+ header.push({ text: headerMetric(source), fg: "text" });
225
+ lines.push({ segments: header });
226
+
227
+ if (source.ready !== true) {
228
+ lines.push({ segments: [{ text: "awaiting first response", fg: "textMuted" }] });
229
+ }
230
+
231
+ const limit = nonNegative(source.limit);
232
+ if (limit !== null && limit > 0) {
233
+ lines.push(...gridLines(source));
234
+ }
235
+
236
+ const rows = rowMap(source);
237
+ const derived = source.systemDerived === true;
238
+ for (let i = 0; i < LEGEND_KEYS.length / 2; i++) {
239
+ const leftKey = LEGEND_KEYS[i];
240
+ const rightKey = LEGEND_KEYS[i + COLUMNS_PER_LEGEND_ROW];
241
+ lines.push({
242
+ segments: [
243
+ ...legendEntry(leftKey, rows.get(leftKey), derived),
244
+ { text: LEGEND_GAP, fg: "text" },
245
+ ...legendEntry(rightKey, rows.get(rightKey), derived),
246
+ ],
247
+ });
248
+ }
249
+
250
+ const captured = nonNegative(source.systemCaptured);
251
+ if (captured !== null) {
252
+ const systemTokens = nonNegative(rows.get("system")?.tokens) ?? 0;
253
+ const framing = Math.max(0, systemTokens - captured);
254
+ const prefix = `↳ ${formatTokens(captured)} system prompt + `;
255
+ const suffix = `${formatTokens(framing)} tools & framing`;
256
+ lines.push({ segments: [{ text: prefix + suffix, fg: "textMuted" }] });
257
+ }
258
+
259
+ const headroom = headroomSegments(source);
260
+ if (headroom.length > 0) lines.push({ segments: headroom });
261
+
262
+ const pruned = nonNegative(source.prunedToolOutputs);
263
+ if (pruned !== null && pruned > 0) {
264
+ lines.push({
265
+ segments: [
266
+ { text: `${formatTokens(pruned)} tool outputs pruned from context`, fg: "textMuted" },
267
+ ],
268
+ });
269
+ }
270
+
271
+ return lines;
272
+ }
@@ -0,0 +1,90 @@
1
+ import type { ContextRow, GridCell, RowKey } from "./types";
2
+
3
+ const GRID_ROWS_DEFAULT = 6;
4
+ const MIN_COLS = 8;
5
+ const MAX_COLS = 60;
6
+ const CATEGORY_ORDER: readonly RowKey[] = ["user", "agent", "reasoning", "tools", "system"];
7
+
8
+ export interface BuildGridInput {
9
+ rows: readonly ContextRow[];
10
+ limit: number | null;
11
+ total: number | null;
12
+ cols?: number | null;
13
+ gridRows?: number | null;
14
+ }
15
+
16
+ function finiteOrNull(value: unknown): number | null {
17
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
18
+ }
19
+
20
+ function clampCols(value: number | null | undefined): number {
21
+ const n = finiteOrNull(value);
22
+ return Math.min(MAX_COLS, Math.max(MIN_COLS, n === null ? MIN_COLS : Math.floor(n)));
23
+ }
24
+
25
+ function clampGridRows(value: number | null | undefined): number {
26
+ const n = finiteOrNull(value);
27
+ return n !== null && Math.floor(n) >= 1 ? Math.floor(n) : GRID_ROWS_DEFAULT;
28
+ }
29
+
30
+ function rowTokens(rows: readonly ContextRow[], key: RowKey): number {
31
+ if (!Array.isArray(rows)) return 0;
32
+ for (const row of rows) {
33
+ if (row !== null && typeof row === "object" && row.key === key) {
34
+ const tokens = finiteOrNull(row.tokens);
35
+ return tokens !== null && tokens > 0 ? tokens : 0;
36
+ }
37
+ }
38
+ return 0;
39
+ }
40
+
41
+ function categoryAt(
42
+ ranges: readonly { key: RowKey; start: number; end: number }[],
43
+ position: number,
44
+ ): RowKey | null {
45
+ for (const range of ranges) {
46
+ if (position >= range.start && position < range.end) return range.key;
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * Lays out the grid of square cells from the five measured rows. Cells are in
53
+ * row-major order; only the cell straddling `total` is partial and free space
54
+ * is never materialised.
55
+ */
56
+ export function buildGrid(input: BuildGridInput): {
57
+ cols: number;
58
+ rows: number;
59
+ cells: GridCell[];
60
+ } {
61
+ const cols = clampCols(input.cols);
62
+ const rows = clampGridRows(input.gridRows);
63
+ const limit = finiteOrNull(input.limit);
64
+ if (limit === null || limit <= 0) return { cols, rows, cells: [] };
65
+
66
+ const total = Math.max(0, finiteOrNull(input.total) ?? 0);
67
+ const cellCount = cols * rows;
68
+ const tokensPerCell = limit / cellCount;
69
+
70
+ const ranges: { key: RowKey; start: number; end: number }[] = [];
71
+ let acc = 0;
72
+ for (const key of CATEGORY_ORDER) {
73
+ const value = rowTokens(input.rows, key);
74
+ ranges.push({ key, start: acc, end: acc + value });
75
+ acc += value;
76
+ }
77
+
78
+ const cells: GridCell[] = [];
79
+ for (let i = 0; i < cellCount; i++) {
80
+ const position = i * tokensPerCell;
81
+ if (total <= 0 || position >= total) {
82
+ cells.push({ rowKey: null, fill: 0 });
83
+ continue;
84
+ }
85
+ const key = categoryAt(ranges, position) ?? CATEGORY_ORDER[CATEGORY_ORDER.length - 1];
86
+ cells.push({ rowKey: key, fill: Math.min(1, Math.max(0, (total - position) / tokensPerCell)) });
87
+ }
88
+
89
+ return { cols, rows, cells };
90
+ }
@@ -0,0 +1,42 @@
1
+ import type { Model } from "@opencode-ai/sdk/v2";
2
+
3
+ export interface Headroom {
4
+ usable: number | null;
5
+ free: number | null;
6
+ band: "ok" | "warning" | "error";
7
+ }
8
+
9
+ export interface HeadroomInput {
10
+ limit: Model["limit"] | null | undefined;
11
+ total: number | null;
12
+ compaction?: { reserved?: number } | null;
13
+ }
14
+
15
+ function finiteOrNull(value: unknown): number | null {
16
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
17
+ }
18
+
19
+ /** Compaction headroom, mirroring opencode's overflow check; nulls when unusable. */
20
+ export function computeHeadroom(input: HeadroomInput): Headroom {
21
+ const limit = input.limit;
22
+ const context = finiteOrNull(limit?.context);
23
+ const maxOutput = finiteOrNull(limit?.output);
24
+ if (context === null || maxOutput === null) return { usable: null, free: null, band: "ok" };
25
+
26
+ const reservedDefault = Math.min(20000, Math.max(0, maxOutput));
27
+ const configured = finiteOrNull(input.compaction?.reserved);
28
+ const reserved = configured !== null && configured >= 0 ? configured : reservedDefault;
29
+
30
+ const inputLimit = finiteOrNull(limit?.input);
31
+ const usable =
32
+ inputLimit !== null
33
+ ? Math.max(0, inputLimit - reserved)
34
+ : Math.max(0, context - Math.max(0, maxOutput));
35
+
36
+ const total = finiteOrNull(input.total);
37
+ if (total === null) return { usable, free: null, band: "ok" };
38
+
39
+ const free = usable - total;
40
+ const band = free <= usable * 0.05 ? "error" : free <= usable * 0.15 ? "warning" : "ok";
41
+ return { usable, free, band };
42
+ }