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 CHANGED
@@ -4,64 +4,85 @@
4
4
  [![CI](https://github.com/kaanchinar/opencode-usage-report/actions/workflows/ci.yml/badge.svg)](https://github.com/kaanchinar/opencode-usage-report/actions/workflows/ci.yml)
5
5
  [![License: MIT](https://img.shields.io/npm/l/opencode-usage-report)](./LICENSE)
6
6
 
7
- An [opencode](https://opencode.ai) plugin that adds a `/usage` command (and a
8
- `usage_report` tool) showing the quota windows (5-hour, weekly, monthly) of your
9
- inference subscriptions — currently **Kimi Code** (both regional plans:
10
- `kimi-code-plan-global` on kimi.ai and `kimi-code-plan-cn` on kimi.com),
11
- **OpenCode Go** (`opencode-go`), **GitHub Copilot** (`github-copilot`) and
12
- **ChatGPT** (`openai`). It fetches from each provider's API, caches
13
- results on disk, and can fall back to a local estimate when the API is
14
- unreachable. It also emits background low-quota warnings in the TUI.
7
+ An [opencode](https://opencode.ai) plugin that shows how much of your inference
8
+ subscriptions you have left. It adds a `/usage` command and a `usage_report`
9
+ tool covering both the **context window** of the current session and the
10
+ **quota windows** (5-hour, weekly, monthly) of your provider plans —
11
+ **Kimi Code** (`kimi-code-plan-global` on kimi.ai and `kimi-code-plan-cn` on
12
+ kimi.com), **OpenCode Go** (`opencode-go`), **GitHub Copilot**
13
+ (`github-copilot`) and **ChatGPT** (`openai`). Results are cached on disk, fall
14
+ back to a local estimate when an API is unreachable, and raise background
15
+ low-quota toasts in the TUI.
15
16
 
16
17
  ## Features
17
18
 
18
19
  - `/usage` command and `usage_report` tool for on-demand quota reports, with
19
- JSON and single-provider filtering.
20
- - TUI sidebar panel with live progress bars, `NN%` usage, and reset countdowns
21
- for each quota window.
22
- - On-disk caching with a configurable TTL, plus `--refresh` to bypass it.
23
- - Local fallback estimate when the provider API is unreachable and no cache
20
+ JSON output and single-provider filtering.
21
+ - **Context usage panel** (new in 0.4.0) — the exact prompt-token total against
22
+ the model's context window, a cell grid that fills by category, the
23
+ auto-compaction point with live headroom, and session cost.
24
+ - TUI sidebar panel with live progress bars, `NN%` usage and reset countdowns
25
+ for every quota window.
26
+ - On-disk caching with a configurable TTL, plus an explicit refresh that
27
+ bypasses it.
28
+ - Local fallback estimate when a provider API is unreachable and no cache
24
29
  exists.
25
- - Background low-quota TUI warnings on startup and `session.idle`.
30
+ - Low-quota warnings on startup and on `session.idle`.
26
31
  - Privacy-first: API keys are never logged, cached, or rendered.
27
32
 
28
33
  ## Install
29
34
 
30
- ### Server plugin (`/usage` command + warnings)
35
+ The plugin exposes **two entrypoints** and opencode loads them from two
36
+ different config files. Install both for the full experience:
31
37
 
32
- Add the plugin to `opencode.json` / `opencode.jsonc` and restart opencode:
38
+ | Config | Entrypoint | Provides |
39
+ | --------------- | ------------- | ------------------------------------------------------- |
40
+ | `opencode.json` | server plugin | `usage_report` tool, low-quota warnings, prompt capture |
41
+ | `tui.json` | TUI plugin | `/usage` command, sidebar panel |
42
+
43
+ The quickest path is opencode's own installer, which detects both entrypoints
44
+ and writes both configs for you:
45
+
46
+ ```sh
47
+ opencode plugin opencode-usage-report # project scope (./.opencode)
48
+ opencode plugin -g opencode-usage-report # global scope
49
+ ```
50
+
51
+ Or add the plugin manually to each file and restart opencode:
33
52
 
34
53
  ```jsonc
54
+ // opencode.json — server plugin
35
55
  {
36
56
  "plugin": ["opencode-usage-report"],
37
57
  }
38
58
  ```
39
59
 
40
- Credentials are read from the same places your other opencode providers already
41
- use (see [Data sources & privacy](#data-sources--privacy)). Optionally pass
42
- options in the tuple form:
43
-
44
60
  ```jsonc
61
+ // tui.json — TUI plugin
45
62
  {
46
- "plugin": [["opencode-usage-report", { "thresholdPercent": 75 }]],
63
+ "plugin": ["opencode-usage-report"],
47
64
  }
48
65
  ```
49
66
 
50
- ### TUI sidebar panel
67
+ > **Use the bare package name in both files.** opencode reads the package
68
+ > `exports` map itself and resolves `exports["./tui"]` for the TUI and
69
+ > `exports["./server"]` (or `main`) for the server. A subpath spec such as
70
+ > `opencode-usage-report/tui` is **not** valid and will silently load nothing.
71
+ > `opencode plugin opencode-usage-report` prints `Detected server + tui targets`
72
+ > when the resolution works.
51
73
 
52
- Add the plugin to `tui.json` and restart opencode:
74
+ Options can be passed in the tuple form, independently per entrypoint:
53
75
 
54
76
  ```jsonc
55
77
  {
56
- "plugin": ["opencode-usage-report"],
78
+ "plugin": [["opencode-usage-report", { "thresholdPercent": 75 }]],
57
79
  }
58
80
  ```
59
81
 
60
- Use the bare package name — opencode resolves the `./tui` entrypoint from the
61
- package `exports` on its own. (`opencode-usage-report/tui` is **not** a valid
62
- spec and will silently not load.)
82
+ ### Local development
63
83
 
64
- For local development, point the configs at the source instead:
84
+ Point the configs at the source instead of the published package. Config is read
85
+ once at startup and is not hot-reloaded, so restart opencode after every edit:
65
86
 
66
87
  ```jsonc
67
88
  // opencode.json
@@ -70,34 +91,80 @@ For local development, point the configs at the source instead:
70
91
  { "plugin": ["file:///abs/path/to/opencode-usage-report/src/tui.tsx"] }
71
92
  ```
72
93
 
73
- The panel renders under opencode's native Context block in the session sidebar
74
- (order 150) and shows a colored progress bar per quota window, `NN%`, and a
75
- live reset countdown — refreshed every 60s, on `session.idle`, and on demand via
76
- the `Usage: refresh now` command (default binding `ctrl+shift+u`). It reuses the
77
- same cache/fallback pipeline as the command; stale results are marked `(stale)`
78
- and local estimates `(est)`.
79
-
80
- TUI options (tuple form): `providers`, `cacheTtlSeconds`, `thresholdPercent`,
81
- `refreshIntervalSeconds` (default `60`), `barWidth` (default `14`).
82
-
83
94
  ## Commands
84
95
 
85
- - `/usage` — show every configured provider's quota windows. Providers without a
86
- resolved credential are skipped in this view.
87
- - `/usage kimi-code-plan-global` — filter to a single provider. The argument must
88
- match a registered provider id **exactly** (`kimi-code-plan-global`,
89
- `kimi-code-plan-cn`, `opencode-go`, `github-copilot` or `openai`); an unknown
90
- id (e.g. `/usage kimi`)
91
- returns a helpful error listing the known ids. An explicitly requested
92
- provider with no credential is reported as an error row.
93
- - `/usage --json` — emit the raw `ProviderReport[]` JSON.
94
- - `/usage --refresh` — bypass the on-disk cache and fetch live.
96
+ - `/usage` — open the report dialog: the context panel for the current session
97
+ on top, and every configured provider's quota windows below. This is a local
98
+ TUI command, so it costs no model tokens. Requires the TUI plugin. Providers
99
+ whose credential cannot be resolved are skipped in this view.
100
+ - `/usage` dialog keys — `esc` close, `r` refresh now, `tab` cycle provider
101
+ scope (all → each provider), `j` toggle the raw JSON view.
102
+ - `usage_report` tool — the same report for agents and headless runs. Takes
103
+ `provider` (exact id: `kimi-code-plan-global`, `kimi-code-plan-cn`,
104
+ `opencode-go`, `github-copilot` or `openai`; an unknown id returns an error
105
+ listing the known ids), `json` (raw `ProviderReport[]`) and `refresh` (bypass
106
+ the on-disk cache).
107
+
108
+ > **Changed in 0.4.0.** `/usage` used to be an LLM-mediated prompt template that
109
+ > routed through the model, and it accepted `--json` / `--refresh` / a provider
110
+ > id as slash arguments. The TUI command now owns the name, so `/usage` reaches
111
+ > no model and those capabilities moved to the dialog keys and the
112
+ > `usage_report` tool arguments. Running `/usage --json` is no longer supported.
113
+
114
+ ## Context usage panel
115
+
116
+ The top of the `/usage` dialog reports how full the model's context window is
117
+ for the current session:
95
118
 
96
- The command simply calls the `usage_report` tool with those arguments, so the
97
- tool can also be invoked directly by an agent.
119
+ ```
120
+ Context
121
+ Kimi K2 (High) · 42,318 / 200,000 tokens (21.2%)
122
+
123
+ ████████████████████████████ ████████████████████████████████ ████████████ ░░░░░░░░░░░░░░
124
+ ████████████████████████████ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░
125
+ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░
126
+ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░
127
+ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░
128
+ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░
129
+
130
+ ● User messages 8,204 4.1% ● System & tools 8,144 4.1%
131
+ ● Agent responses 11,650 5.8% ● Free space 157,682 78.8%
132
+ ● Reasoning 2,140 1.1%
133
+ ● Tool calls 12,180 6.1%
134
+
135
+ Auto-compacts at 180,000 · 137,682 headroom $0.42 spent
136
+ ```
137
+
138
+ **What is exact.** The headline total, the percentage, free space, the
139
+ compaction point, the cost and the number of pruned tool outputs all come from
140
+ numbers the provider and opencode report. The total is the prompt size —
141
+ `input + cache.read + cache.write` — which is what actually occupies the context
142
+ window. opencode's own sidebar Context block sums five fields including the
143
+ completion, so it reads slightly higher than this panel by design.
144
+
145
+ **What is estimated.** The per-category rows. opencode records no token count
146
+ per message or part, so each category is estimated with the same `characters / 4`
147
+ heuristic opencode itself uses, then normalized so the rows agree with the exact
148
+ total.
149
+
150
+ **System & tools.** opencode assembles the system prompt and tool definitions on
151
+ every request and never persists them. The server plugin measures the real
152
+ system string through the `experimental.chat.system.transform` hook and stores
153
+ only its size, so the row can be split into `system prompt` and
154
+ `tools & framing`. That hook is undocumented, so it is treated as best-effort:
155
+ if it stops firing, the sidecar goes stale, or anything fails to validate, the
156
+ panel falls back to the derived residual and labels the row `(derived)`. The
157
+ plugin never breaks a request over it.
158
+
159
+ **Compaction headroom.** `Auto-compacts at N` mirrors opencode's own overflow
160
+ check, including the configured `compaction.reserved` buffer. This is the point
161
+ at which opencode automatically summarizes the session — a number opencode
162
+ exposes nowhere else.
98
163
 
99
164
  ## Options
100
165
 
166
+ Shared by both entrypoints:
167
+
101
168
  | Option | Type | Default | Meaning |
102
169
  | ------------------ | ------------------ | ------- | ------------------------------------------------------------ |
103
170
  | `thresholdPercent` | `number` | `80` | Warn when a window is at/above this percent used. |
@@ -105,41 +172,77 @@ tool can also be invoked directly by an agent.
105
172
  | `providers` | `string[] \| null` | `null` | Providers to report; `null` = every registered adapter. |
106
173
  | `fallback` | `boolean` | `true` | Use a local estimate when the API fails and no cache exists. |
107
174
 
108
- Malformed option values are ignored and the defaults are kept.
175
+ TUI only:
176
+
177
+ | Option | Type | Default | Meaning |
178
+ | ------------------------ | -------- | ------- | -------------------------------------------------- |
179
+ | `refreshIntervalSeconds` | `number` | `60` | Sidebar/dialog refresh cadence, clamped to 5–3600. |
180
+ | `barWidth` | `number` | `14` | Progress-bar width in cells, clamped to 4–40. |
181
+
182
+ `fallback` is fixed at `true` for the TUI entrypoint. Malformed option values
183
+ are ignored and the defaults are kept.
109
184
 
110
185
  ## Warnings
111
186
 
112
- On startup and again on `session.idle` (both throttled to at most once every 10
113
- minutes, sharing the same timer), the plugin checks the cached reports. Any
114
- window at or above `thresholdPercent`, or whose
115
- status is `rate-limited` / `frozen`, produces a TUI toast. A warning fires once
116
- per window until that window resets; it re-arms after usage drops below
117
- `thresholdPercent - 10`. Toast failures (headless/server mode) are swallowed.
187
+ On startup and again on `session.idle` (both throttled to at most once every
188
+ 10 minutes, sharing one timer), the plugin checks the cached reports. Any window
189
+ at or above `thresholdPercent`, or whose status is `rate-limited` / `frozen`,
190
+ produces a TUI toast. A warning fires once per window until that window resets;
191
+ it re-arms after usage drops below `thresholdPercent - 10`. Toast failures
192
+ (headless/server mode) are swallowed.
118
193
 
119
194
  ## Data sources & privacy
120
195
 
121
- - Credential resolution order: `OPENCODE_USAGE_<ID>_KEY` env override (optionally
196
+ - **Credential resolution order.** First `OPENCODE_USAGE_<ID>_KEY` (optionally
122
197
  paired with `OPENCODE_USAGE_<ID>_ACCOUNT_ID`, e.g.
123
198
  `OPENCODE_USAGE_OPENAI_ACCOUNT_ID`, to supply the ChatGPT account id), then
124
- `~/.local/share/opencode/auth.json` (`type: "api"` `.key`, or `type: "oauth"`
125
- `.access`, falling back to `.refresh` for GitHub Copilot). For ChatGPT the
126
- oauth entry's `.accountId` is read automatically. `$OPENCODE_DATA_HOME`
127
- overrides the data directory.
128
- - APIs: `https://api.kimi.ai/coding/v1/usages` (global plan),
199
+ `~/.local/share/opencode/auth.json`: a `type: "api"` entry's `.key`, or a
200
+ `type: "oauth"` entry's `.access` (falling back to `.refresh` for GitHub
201
+ Copilot, which stores its token there). For ChatGPT the oauth entry's
202
+ `.accountId` is read automatically. `$OPENCODE_DATA_HOME` overrides the data
203
+ directory.
204
+ - **APIs.** `https://api.kimi.ai/coding/v1/usages` (global plan),
129
205
  `https://api.kimi.com/coding/v1/usages` (China plan),
130
- `https://opencode.ai/zen/go/v1/usage` (custom `User-Agent` is required by the
131
- latter), `https://api.github.com/copilot_internal/user` (GitHub Copilot) and
206
+ `https://opencode.ai/zen/go/v1/usage` (requires a custom `User-Agent`),
207
+ `https://api.github.com/copilot_internal/user` (GitHub Copilot) and
132
208
  `https://chatgpt.com/backend-api/wham/usage` (ChatGPT).
133
- - Auth: run `opencode auth login` and pick **GitHub Copilot** or **OpenAI
209
+ - **Auth.** Run `opencode auth login` and pick **GitHub Copilot** or **OpenAI
134
210
  ChatGPT**. ChatGPT additionally needs the account id that login writes to
135
211
  `auth.json`; the plugin reads it automatically.
136
- - Cache and state live in `<data-home>/usage-report/` (TTL cache, session id,
137
- warn state).
138
- - Local fallback reads `opencode.db` read-only.
212
+ - **Cache and state** live in `<data-home>/usage-report/`: the TTL cache, the
213
+ session id, the warn state, and `context/<sessionID>.json` — the system-prompt
214
+ size sidecar, which holds counts only and never prompt text.
215
+ - **Local fallback** reads `opencode.db` read-only.
139
216
  - **API keys are never logged, cached, or rendered.** Adapter errors are
140
217
  sanitized (key substrings replaced with `<redacted>`) before they reach any
141
218
  output, and the live smoke script never prints credentials.
142
219
 
220
+ ## Troubleshooting
221
+
222
+ **`/usage` does not appear in the command list.**
223
+ Both configs are required — the command comes from the TUI plugin, not the
224
+ server plugin. Check that `tui.json` lists `"opencode-usage-report"` (bare name)
225
+ and restart opencode; config is not hot-reloaded. To confirm the package exposes
226
+ what opencode expects, run `opencode plugin opencode-usage-report` — it should
227
+ print `Detected server + tui targets`.
228
+
229
+ **The sidebar is empty and no providers are reported.**
230
+ Every window belongs to a provider whose credential could not be resolved, so it
231
+ is skipped. Run `opencode auth login`, or set
232
+ `OPENCODE_USAGE_<ID>_KEY` for the provider in question.
233
+
234
+ **An old version keeps loading after you upgrade.**
235
+ opencode caches installed plugin packages under
236
+ `~/.cache/opencode/packages/<name>@latest/` and re-resolves versions on install,
237
+ not on every start. Re-run `opencode plugin opencode-usage-report`, or delete
238
+ that cache directory, to force a refresh. This is the usual cause of "I
239
+ published a new version but opencode still runs the old one".
240
+
241
+ **Quota numbers look stale or marked `(stale)` / `(est)`.**
242
+ `(stale)` means a cached result was served past its TTL without a successful
243
+ refresh; `(est)` means the API was unreachable and a local estimate was used.
244
+ Press `r` in the dialog (or `ctrl+shift+u` in the sidebar) to force a refetch.
245
+
143
246
  ## Extending
144
247
 
145
248
  New providers are trivial:
@@ -170,6 +273,38 @@ npm run check # lint + format:check + typecheck + test
170
273
  npm run smoke -- --yes-live # manual live check (real keys; opt-in)
171
274
  ```
172
275
 
276
+ ## Releasing
277
+
278
+ Releases are **staged**, not published directly. Pushing a `v*` tag triggers
279
+ `.github/workflows/publish.yml`, which verifies that the tag matches
280
+ `package.json`, runs `npm ci`, and uploads the tarball with
281
+ `npm stage publish --provenance --access public` using npm Trusted Publishing
282
+ (OIDC — no `NPM_TOKEN` secret). Nothing goes live until a maintainer approves
283
+ the staged release with 2FA:
284
+
285
+ ```sh
286
+ git tag v0.4.1 && git push origin main v0.4.1
287
+ npm stage list # find the stage id
288
+ npm stage approve <stage-id> # 2FA prompt; or approve in the npm UI
289
+ ```
290
+
291
+ `prepublishOnly` runs lint, format check, typecheck and tests before the upload,
292
+ so a failing check blocks the stage.
293
+
294
+ ## Changelog
295
+
296
+ - **0.4.1** — internal: shared provider HTTP plumbing (`src/providers/http.ts`)
297
+ replaces the copy-pasted fetch/retry/error code; context estimates simplified;
298
+ reset times render as `Wed, Sep 16`.
299
+ - **0.4.0** — context usage panel (`/usage` now shows context breakdown, grid,
300
+ compaction headroom and cost); `/usage` became a local TUI command that costs
301
+ no model tokens; sidebar rendering extracted to `src/quota-lines.ts`.
302
+ - **0.3.0** — GitHub Copilot and ChatGPT (OpenAI) adapters.
303
+ - **0.2.0** — Kimi Code split into the kimi.ai (`kimi-code-plan-global`) and
304
+ kimi.com (`kimi-code-plan-cn`) plans.
305
+
173
306
  ## License
174
307
 
175
308
  MIT © [Kaan Chinar](https://github.com/kaanchinar)
309
+
310
+ Security policy: [SECURITY.md](./SECURITY.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-usage-report",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "opencode plugin: /usage command showing subscription quota windows for configured providers",
5
5
  "keywords": [
6
6
  "kimi",
@@ -49,7 +49,6 @@
49
49
  "devDependencies": {
50
50
  "@opencode-ai/plugin": "^1.0.0",
51
51
  "@opentui/core": "^0.5.11",
52
- "@opentui/keymap": "^0.5.11",
53
52
  "@opentui/solid": "^0.5.11",
54
53
  "@types/node": "^26.6.2",
55
54
  "oxfmt": "^0.70.0",
@@ -0,0 +1,210 @@
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 { finiteOrNull, isRecord, safeString } from "../normalize";
5
+ import { buildGrid } from "./grid";
6
+ import { computeHeadroom } from "./headroom";
7
+
8
+ const ROW_LABELS: Record<RowKey, string> = {
9
+ user: "User messages",
10
+ agent: "Agent responses",
11
+ reasoning: "Reasoning",
12
+ tools: "Tool calls",
13
+ system: "System & tools",
14
+ free: "Free space",
15
+ };
16
+
17
+ const MEASURED_KEYS: readonly RowKey[] = ["user", "agent", "reasoning", "tools", "system"];
18
+
19
+ function isAssistant(value: unknown): value is AssistantMessage {
20
+ return isRecord(value) && value.role === "assistant";
21
+ }
22
+
23
+ function readParts(accessor: CollectInput["part"], messageID: string): readonly Part[] {
24
+ if (typeof accessor !== "function") return [];
25
+ try {
26
+ const parts = accessor(messageID);
27
+ return Array.isArray(parts) ? parts : [];
28
+ } catch {
29
+ return [];
30
+ }
31
+ }
32
+
33
+ function lastReportingAssistant(messages: readonly unknown[]): AssistantMessage | null {
34
+ for (let i = messages.length - 1; i >= 0; i--) {
35
+ const message = messages[i];
36
+ if (!isAssistant(message)) continue;
37
+ const output = finiteOrNull(isRecord(message.tokens) ? message.tokens.output : undefined);
38
+ if (output !== null && output > 0) return message;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ function summaryStartIndex(messages: readonly unknown[]): number {
44
+ for (let i = messages.length - 1; i >= 0; i--) {
45
+ const message = messages[i];
46
+ if (isAssistant(message) && message.summary === true) return i + 1;
47
+ }
48
+ return 0;
49
+ }
50
+
51
+ function isUsableCapture(capture: SystemCapture | null | undefined): capture is SystemCapture {
52
+ if (!isRecord(capture)) return false;
53
+ const tokens = finiteOrNull(capture.systemTokens);
54
+ return capture.version === 1 && tokens !== null && tokens >= 0;
55
+ }
56
+
57
+ /**
58
+ * Scales `values` proportionally so they sum to at most `target`; each row is an
59
+ * estimate, so flooring may leave the five rows a few tokens short of the total.
60
+ */
61
+ function scaleToTotal(values: readonly number[], target: number, divisor: number): number[] {
62
+ if (!Number.isFinite(divisor) || divisor <= 0) return values.map(() => 0);
63
+ const total = Math.max(0, Math.round(target));
64
+ return values.map((value) => (value > 0 ? Math.floor((value * total) / divisor) : 0));
65
+ }
66
+
67
+ function makeRow(key: RowKey, tokens: number | null, limit: number | null): ContextRow {
68
+ const percent =
69
+ tokens !== null && limit !== null && limit > 0
70
+ ? Math.min(100, Math.max(0, Math.round((tokens / limit) * 100)))
71
+ : null;
72
+ return { key, label: ROW_LABELS[key], tokens, percent };
73
+ }
74
+
75
+ function emptyRows(limit: number | null): ContextRow[] {
76
+ return [...MEASURED_KEYS, "free" as RowKey].map((key) => makeRow(key, null, limit));
77
+ }
78
+
79
+ /**
80
+ * Builds a `ContextBreakdown` from session messages. Exact prompt totals come
81
+ * from the last reporting assistant message; the remaining rows are `chars/4`
82
+ * estimates normalized against that total. Never throws and never emits NaN.
83
+ */
84
+ export function collectContext(input: CollectInput): ContextBreakdown {
85
+ const source = (isRecord(input) ? input : {}) as CollectInput;
86
+ const messages: readonly unknown[] = Array.isArray(source.messages) ? source.messages : [];
87
+ const limit = finiteOrNull(source.model?.limit?.context);
88
+ const modelName = typeof source.model?.name === "string" ? source.model.name : null;
89
+ const cost = finiteOrNull(source.sessionCost) ?? 0;
90
+ const systemDerived = !isUsableCapture(source.system);
91
+ const systemCaptured = systemDerived ? null : (source.system as SystemCapture).systemTokens;
92
+ const cols = typeof source.cols === "number" && Number.isFinite(source.cols) ? source.cols : null;
93
+
94
+ const last = lastReportingAssistant(messages);
95
+ if (last === null) {
96
+ const rows = emptyRows(limit);
97
+ return {
98
+ ready: false,
99
+ modelName,
100
+ total: null,
101
+ limit,
102
+ cost,
103
+ rows,
104
+ grid: buildGrid({ rows, limit, total: null, cols }),
105
+ headroom: computeHeadroom({
106
+ limit: source.model?.limit ?? null,
107
+ total: null,
108
+ compaction: source.compaction ?? null,
109
+ }),
110
+ systemDerived,
111
+ systemCaptured,
112
+ prunedToolOutputs: 0,
113
+ };
114
+ }
115
+
116
+ const tokens: Record<string, unknown> = isRecord(last.tokens) ? last.tokens : {};
117
+ const cache: Record<string, unknown> = isRecord(tokens.cache) ? tokens.cache : {};
118
+ const total = Math.max(
119
+ 0,
120
+ (finiteOrNull(tokens.input) ?? 0) +
121
+ (finiteOrNull(cache.read) ?? 0) +
122
+ (finiteOrNull(cache.write) ?? 0),
123
+ );
124
+
125
+ let userTokens = 0;
126
+ let agentTokens = 0;
127
+ let reasoningTokens = 0;
128
+ let toolsTokens = 0;
129
+ let prunedToolOutputs = 0;
130
+
131
+ const start = summaryStartIndex(messages);
132
+ for (let i = start; i < messages.length; i++) {
133
+ const message = messages[i];
134
+ if (!isRecord(message)) continue;
135
+ const role = message.role;
136
+ if (role !== "user" && role !== "assistant") continue;
137
+ const messageID = typeof message.id === "string" ? message.id : "";
138
+
139
+ for (const part of readParts(source.part, messageID)) {
140
+ if (!isRecord(part)) continue;
141
+ const type = part.type;
142
+
143
+ if (type === "text") {
144
+ const estimated = estimateTokens(safeString(part.text));
145
+ if (role === "user") userTokens += estimated;
146
+ else agentTokens += estimated;
147
+ } else if (type === "reasoning") {
148
+ if (role === "assistant") reasoningTokens += estimateTokens(safeString(part.text));
149
+ } else if (type === "file") {
150
+ if (role === "user") {
151
+ userTokens += estimateTokens(safeString(part.filename) + safeString(part.mime));
152
+ }
153
+ } else if (type === "tool") {
154
+ const state = part.state;
155
+ const stateRecord: Record<string, unknown> = isRecord(state) ? state : {};
156
+ const timeRecord: Record<string, unknown> = isRecord(stateRecord.time)
157
+ ? stateRecord.time
158
+ : {};
159
+ if (timeRecord.compacted !== undefined && timeRecord.compacted !== null) {
160
+ prunedToolOutputs += 1;
161
+ continue;
162
+ }
163
+ toolsTokens +=
164
+ estimateJson(stateRecord.input) + estimateTokens(safeString(stateRecord.output));
165
+ }
166
+ // step-start, step-finish, snapshot and every other part type are skipped.
167
+ }
168
+ }
169
+
170
+ const estimatedSum = userTokens + agentTokens + reasoningTokens + toolsTokens;
171
+ let systemTokens = Math.max(0, total - estimatedSum);
172
+
173
+ // chars/4 is an estimate in both directions; when it overshoots the exact
174
+ // total, scale all five measured rows down so they sum to at most `total` (6.5).
175
+ if (estimatedSum > total) {
176
+ const scaled = scaleToTotal(
177
+ [userTokens, agentTokens, reasoningTokens, toolsTokens, systemTokens],
178
+ total,
179
+ estimatedSum,
180
+ );
181
+ userTokens = scaled[0];
182
+ agentTokens = scaled[1];
183
+ reasoningTokens = scaled[2];
184
+ toolsTokens = scaled[3];
185
+ systemTokens = scaled[4];
186
+ }
187
+
188
+ const measured = [userTokens, agentTokens, reasoningTokens, toolsTokens, systemTokens];
189
+ const rows = MEASURED_KEYS.map((key, index) => makeRow(key, measured[index], limit));
190
+ const freeTokens = limit !== null ? Math.max(0, limit - total) : null;
191
+ rows.push(makeRow("free", freeTokens, limit));
192
+
193
+ return {
194
+ ready: true,
195
+ modelName,
196
+ total,
197
+ limit,
198
+ cost,
199
+ rows,
200
+ grid: buildGrid({ rows, limit, total, cols }),
201
+ headroom: computeHeadroom({
202
+ limit: source.model?.limit ?? null,
203
+ total,
204
+ compaction: source.compaction ?? null,
205
+ }),
206
+ systemDerived,
207
+ systemCaptured,
208
+ prunedToolOutputs,
209
+ };
210
+ }