See what every turn actually costs — right under the reply.
A zero-config GUI plugin for OpenCode Desktop V2 (2.2.3+): per-turn tokens, cache hit rate and RMB cost. Requires the V2 desktop app; it does not work in the TUI.
Install · Features · Configuration · How it works · FAQ · 中文
opencode plugin add github:AquariusCZ/opencode-token-usage-guiThen reload the window (Ctrl/Cmd + R) or run opencode service restart. Full options are under Install.
Illustrative English mock of the footer and details card. Not a real session screenshot. The illustration shows
popoverSide: "right"; the default is left.
OpenCode Desktop shows the latest request and the session total. It does not show what this turn cost. This plugin adds the missing line.
This turn · Input 15,733 · Output 6,250 · Cache read 10,300,544 · Cache hit 99.8% · Total 10,332,638 · ¥0.288
Hover the line — or focus its info button — for a readable breakdown:
Turn breakdown · 10,332,638 tokens · ¥0.288 (≈ $0.0431)
[■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■] 99.7% cache read
Input 15,733 Output 6,250
Reasoning 10,111 Cache read 10,300,544
Cache write 0 Cache hit 99.8%
21 model requests · 2m 15s · Exchange rate 6.68 · manual
Click the info button to pin the card while you read or copy it. The preview stays open when you move onto it — an invisible 8 px bridge covers the gap — so there is nothing to chase; the pinned card closes with Escape or a click outside. Escape closes it from any non-editing surface; text fields, editors, selects, menus/listboxes and dialogs keep Escape for the app.
The built-in panel answers “how big is the context right now?” This plugin answers “what did this turn cost, and why?”
| Built-in context panel | This plugin | CLI debug.turn_tokens |
|
|---|---|---|---|
| Latest request | ✅ | ❌ (per-turn) | — |
| Per-turn total | ❌ | ✅ | ✅ |
| Cache hit rate | ❌ | ✅ | — |
| Per-turn cost | ❌ | ✅ CNY / USD | ❌ |
| Inline under every reply | ❌ | ✅ | ✅ |
| Works in the desktop GUI | ✅ | ✅ | ❌ |
A turn is not one request. In a long agent loop it can contain 20+ assistant requests and tool calls. Showing only the last assistant message hides most of the cost. This plugin sums the whole turn, then shows the numbers that actually change the bill.
- Per-turn, not per-message. One footer per turn, aggregating every assistant request and tool loop.
- A details card that is actually readable. Hover or focus the footer for a theme-aware card with a token composition bar, a six-cell metric grid, request count, wall time, and the exchange-rate source. The card stays open while your pointer is over it (an invisible bridge covers the gap) and can be pinned with the info button for persistent reading and copying. It opens on the configured side of the footer (left by default), then below it, then above, so it does not cover the turn's step rows on a wide window; it clamps to the viewport and repositions on scroll and resize.
- One capsule per turn, live in place. Each turn's capsule is rendered under that turn's reply and updates while the turn is thinking or running tools; completed turns keep their capsule. The capsule appears with the first usage sample — a queued or steered prompt never creates a zero-value "This turn" line.
- One session capsule, always in the dock. The composer dock above the input always shows one session-wide
capsule labeled "Session" once the session has usage — while a turn runs, while it is idle, and while messages
are queued. Token and cost totals come from the app's session ledger —
session.get().tokensplussession.cost(), including title/compaction and, when the API is available, subagent usage — so they are lifetime billed totals, not the sum of the loaded transcript. The inline capsule shows tokens, cost and cache hit; hover it for the full session card, which adds requests and active time. Turn, request and active-time counts cover the loaded transcript and are marked "loaded" when older message pages exist. The dock never switches to a per-turn capsule: the two capsule types are separate. - Above the app's normal layers. The details card is rendered in a fixed body portal at z-index 2147483000, above the timeline, later messages and the composer, so normal stacking contexts cannot cover or clip it. An app-level modal/dialog may still sit above it.
- A footer you can hit. The visible line is a padded pill with a dedicated info button whose hit area extends past the glyph; no need to land on a single 12 px line.
- Keyboard accessible. Tab to the info button, press Enter or Space to pin, Escape or an outside click to close.
Escape closes the card from any non-editing surface; text fields, contenteditable editors, selects, menus/listboxes
and dialogs keep Escape for the app.
prefers-reduced-motiondisables the card transition. - Live count-up. Continuous mode samples the store at 100 ms while running and follows the target with a
60 Hz first-order lag; a new sample changes the target without restarting the animation, so the counter reads
as one continuous motion. An
intervalmode keeps the old discrete refresh + tween behavior. Respectsprefers-reduced-motion. - Cache-aware. Shows cache reads and the cache hit rate — the metric that explains most of the cost difference on DeepSeek and Anthropic models.
- RMB by default. Converts the app's USD cost with a manual or daily-cached rate; the original USD amount stays
in the card.
"currency": "USD"switches back,"auto"follows the UI language. - Revert-aware. Messages hidden by an undo/revert boundary are excluded from totals.
- Stale-proof. Stale footers are pruned on every sync, so a session switch, a revert, or a row the app removed cannot leave a ghost line behind.
- Bilingual. Follows the app locale: 中文 or English.
- Compact or detailed. Default shows the decision-making numbers;
detailedadds reasoning tokens and cache writes to the visible line. The card always shows the full breakdown. - Zero runtime dependencies. One built CJS file. No telemetry, no API keys, and no network requests unless you
explicitly opt into
usdToCny: "auto". - Safe failure. If the app's internal DOM changes, the plugin logs one warning and stays quiet instead of breaking the timeline.
- Fast. Aggregating 2,000 messages takes about 3 ms in the test suite; the sampler runs at 100 ms while a session is running (1000 ms idle), and the card only writes DOM when its data changes.
opencode plugin add github:AquariusCZ/opencode-token-usage-guiThen reload the desktop window (Ctrl/Cmd + R) or restart the service:
opencode service restartgit clone https://github.com/AquariusCZ/opencode-token-usage-gui.git \
~/.config/opencode/plugins/opencode-token-usage-guiWindows PowerShell:
git clone https://github.com/AquariusCZ/opencode-token-usage-gui.git `
"$env:USERPROFILE\.config\opencode\plugins\opencode-token-usage-gui"OpenCode discovers immediate plugin directories under ~/.config/opencode/plugins/. If the footer does not appear,
open Settings → Extensions → Plugins and use Reload, or run opencode service restart.
- A capsule appears under each turn and updates in place while the turn streams. The composer dock always shows one Session summary.
- Settings → Extensions → Plugins lists
token-usage-guiand its switch is on. opencode plugin listshowstoken-usage-gui.- If something is off, check
~/.local/share/opencode/log/opencode.logfortoken-usage-gui.
Remove the directory and reload. To disable only the desktop UI, use the plugin's switch in Settings → Extensions → Plugins. To remove a package install:
opencode plugin remove opencode-token-usage-guiOptions go in the plugin entry of opencode.json(c):
| Option | Default | Description |
|---|---|---|
compact |
false |
Show only input, output, total and cost. |
detailed |
false |
Also show reasoning tokens and cache writes on the visible line. |
showCost |
true |
Show the per-turn cost. |
popoverSide |
"left" |
Side for the details card: "left" or "right". The chosen side is tried first; if it does not fit, the card falls back below, then above. It never switches to the other side. |
animate |
true |
Animate the numbers. Set false to snap in both modes. |
refreshMode |
"continuous" |
"continuous" (fluid counter) or "interval" (discrete refresh + tween). |
sampleMs |
100 |
Continuous mode: data sample interval while the session is running, clamped to 50–1000 ms. Idle sessions sample at 1000 ms. |
smoothMs |
250 |
Continuous mode: first-order lag time constant, clamped to 50–2000 ms. Larger = softer/slower. |
refreshMs |
500 |
Interval mode: refresh interval, clamped to 16–5000 ms. 16.67 = 60 Hz; intended for experiments. |
tweenMs |
550 |
Interval mode: tween duration, clamped to 0–2000 ms. |
currency |
"CNY" |
"CNY", "USD", or "auto". auto uses CNY for a Chinese UI and USD otherwise. |
usdToCny |
6.68 |
A number (manual rate, no network) or "auto" (daily cached rate; see below). |
A local directory works the same way:
{
"plugins": [
{
"package": "./plugins/opencode-token-usage-gui",
"options": { "compact": true }
}
]
}The details card opens to the left of the footer by default. Set "popoverSide": "right" to prefer the right gutter.
The chosen side is tried first. On a narrow window where it does not fit, the card falls back below the footer, then above it; it does not switch to the opposite side. This keeps the configured direction predictable.
message.cost is always USD from the provider. The footer is a display conversion, not a second billing source.
currency: "CNY"(default) shows¥.currency: "USD"shows the native$amount.currency: "auto"uses CNY in a Chinese UI and USD otherwise.usdToCny: 6.68is a manual rate. It is deterministic and makes no network request.usdToCny: "auto"fetches the daily USD→CNY rate fromopen.er-api.com, caches it for 24 hours, and falls back to the last cached rate or the manual6.68when offline. Only this opt-in mode uses the network.
The details card always tells you which rate was used:
Exchange rate 6.68 · manual
Exchange rate 6.72 · auto (2026-10-30)
Exchange rate 6.68 · fallback
Set usdToCny to your actual billing rate if you want the number to match your invoice exactly.
refreshMode is the switch:
"continuous"(default) — the sampler reads the store everysampleMs(100 ms) while the session is running, and every 1000 ms when idle. The display runs at 60 Hz and follows the latest target with a first-order lag (smoothMs, 250 ms). A new sample only changes the target; it never restarts the animation, so the counter reads as one continuous motion from the first token to the final total."interval"— the old behavior: read everyrefreshMs(500 ms), then tween to the new value overtweenMs(550 ms). Use this if you prefer visible step changes or a fixed sampling cadence.
animate: false disables both filters and snaps to the target.
Why this split: the display can update at 60 Hz, but the data can only change when the session store changes. Polling the store at 60 Hz is wasteful (see the measurement below); sampling at 100 ms and filtering at 60 Hz gives the same visual continuity at a fraction of the CPU.
Measured on a ~200-message session (Windows 11, OpenCode Desktop 2.2.3): interval at 500 ms used about 1.6% of
one CPU core; polling at 16.67 ms (60 Hz) used about 12.7% over the same 20-second window; continuous with
100 ms sampling used about 2.7%. The count-up animation was already 60 Hz via requestAnimationFrame, so 60 Hz
data polling bought no extra smoothness. Continuous mode keeps the 60 Hz animation but samples at 100 ms, so it
gets the smooth motion without the 60 Hz polling cost.
The timer pauses while the window is hidden and refreshes immediately when it becomes visible again. A debounced
MutationObserver still reattaches the footer when the app re-renders the timeline.
| Mode | Visible line |
|---|---|
| Default | input · output · cache read · cache hit · total · cost |
compact: true |
input · output · total · cost |
detailed: true |
input · output · reasoning · cache read · cache write · cache hit · total · cost |
The details card always shows the full breakdown, including the composition bar, request count and wall time.
The total follows OpenCode's own definition:
input + output + reasoning + cache.read + cache.write
- Turn is delimited by the app's idle markers: it opens at the first user prompt of an execution and closes at the next idle marker. Prompts steered in or queued during the turn belong to it; synthetic/system/compaction notices never split a running turn. An orphan notice (for example a subagent result with no user prompt) owns the assistant usage that follows. Transcripts recorded before idle markers existed fall back to user-message boundaries.
- Cache hit rate is
cache.read / (input + cache.read + cache.write). It describes how much of the prompt was served from cache. - Cost comes from the app's per-request
message.cost(USD). CNY is a display conversion; it is not a second billing source. - Requests counts assistant messages with a usage record, not individual tool calls.
- Duration runs from the first assistant request to the last completed one in the turn.
If a turn has no reported cost (free models, some providers), the cost is hidden and the token counts still show.
user message
│
├─ assistant request 1 ─┐
├─ tool call │
├─ assistant request 2 ├─ one turn
├─ tool call │
└─ assistant request 3 ─┘
│
▼
input + output + reasoning + cache.read + cache.write
- The plugin reads the active session's message list from the app's client store.
- It groups assistant messages into turns — idle-delimited in current sessions, user-delimited in older transcripts — and sums
tokensandcost. - It finds the rendered assistant content container (
[data-slot="session-turn-assistant-content"]) and appends a footer node. - A 100 ms sampler (1000 ms when idle) keeps values fresh; a
MutationObserverre-attaches the footer when the app re-renders the timeline;requestAnimationFrameanimates changes. - Each turn's footer is appended to its assistant content and updates in place while the app reports
session.statusasrunning; it never moves into the composer dock. Hovering opens the fixed body-portal card at z-index 2147483000, preferring the configured side (left by default), then below, then above. The follow timer skips a hidden window. - The composer dock always shows a synthetic "Session" footer — one per session — once the session has usage.
Token and cost totals come from the app's session ledger:
session.get(id).tokensandsession.cost(id)(family-aware for a root, own usage for a child), folded over the session and, when the API exists, its family. The ledger includes title/compaction usage, reverted usage (a revert does not refund) and subagent usage, so the summary is a lifetime billed total and can exceed the sum of the per-turn footers. Turn count, requests and active time still come fromsummarizeTurns(turns)over the loaded transcript, and are labeled "loaded" whenmessage.more(id)reports older pages.message.sync/loadMoreare never called, so reading the summary never mutates the transcript or the scroll position. There is no zero-value placeholder before the first usage, and the summary is never replaced by a per-turn capsule.
There is no official message-footer slot in the GUI plugin API, so the last step is DOM injection. That is the
intentionally fragile part: if a future OpenCode release renames the slot, the footer disappears (the plugin logs
one warning) rather than corrupting the timeline. The selector, footer, placement and aggregation logic are isolated in
src/gui.cjs, src/footer.cjs, src/placement.cjs and src/core.cjs, so adapting is usually a one-line change.
- Tested with OpenCode Desktop 2.2.3 on Windows 11.
- Requires the V2 desktop app; it does not work in the TUI.
- The GUI plugin API is marked experimental by OpenCode and may change between releases.
- The DOM-based rendering should work on macOS and Linux, but those platforms have not been tested.
For the TUI, use the CLI's built-in debug.turn_tokens or one of the terminal-only token plugins.
- No network requests by default, no telemetry, no API keys.
- Reads only the local session state that the desktop app already has in memory.
- The default exchange rate is a manual constant; it is not fetched at runtime.
usdToCny: "auto"is the only network path: one request per day toopen.er-api.com, cached for 24 hours. Leave it on the default manual value for a fully offline plugin.
Does it send my prompts or session data anywhere?
No session data is sent anywhere. The only network request is the opt-in daily USD→CNY rate fetch
(usdToCny: "auto"); the default plugin is fully offline.
Why per-turn instead of per-message?
A turn can contain many assistant requests and tool loops. Per-message numbers look smaller but are misleading.
Use detailed if you want more metrics on the visible line.
Where is the total for the whole session? The composer dock always shows it. The "Session" capsule is the session ledger total; per-turn capsules in the conversation show attribution.
Can I see reasoning tokens?
Yes: set detailed: true, or open the details card. Reasoning is always in the card.
Can I use USD instead of RMB?
Yes: set "currency": "USD". The card still shows the original USD amount when CNY is selected.
How do I keep the card open? Click the small info button at the end of the footer. The pinned card is selectable, so you can copy numbers; click the button again, press Escape, or click outside to close it. Escape closes it from any non-editing surface; text fields, editors, selects, menus/listboxes and dialogs keep Escape for the app.
Can I choose which side the card opens on?
Yes: set "popoverSide": "left" (default) or "popoverSide": "right". The chosen side is tried first, then below,
then above; it does not switch to the other side.
Why is the card never cut off by later messages?
It is rendered into a fixed portal on document.body at a very high z-index (2147483000), above the app's
normal stacking contexts, not inside the scrolling timeline row. Later messages, row clipping and the composer
cannot paint over it.
Why is the cost zero? The provider did not report a cost for that request. Free models and some local providers do not report one.
Will it survive an OpenCode update? Usually yes; the selector is the fragile part. If the footer disappears after an update, open an issue with your OpenCode version and a screenshot.
How is the exchange rate determined?
By default it is the manual usdToCny number (6.68). With "usdToCny": "auto" it fetches the daily rate, caches
it for 24 hours, and falls back to the last cache or 6.68. The details card shows the rate, its source, and the
date when it was automatic.
Can I change how often the footer refreshes?
Yes: sampleMs (50–1000, default 100) controls continuous sampling and smoothMs (50–2000, default 250) controls
the follow speed; interval mode uses refreshMs (16–5000, default 500) and tweenMs (0–2000, default 550). The
timer pauses while the window is hidden.
Does it work in the TUI?
No. The TUI has debug.turn_tokens; this plugin is for the desktop GUI.
- Per-message mode for people who want a footer under every assistant message.
- Context usage percentage in the details card.
- CSV/JSON export of turn usage.
- Provider-specific price tables instead of a single USD→CNY rate.
- A small settings panel instead of JSON options.
- A selector health check that reports when the app's DOM changes.
Ideas and PRs are welcome. If the plugin saves you a scroll, a star helps other people find it.
npm install
npm test # pure-logic tests, Node's built-in runner
npm run build # bundles src/gui.cjs -> dist/gui-experimental.cjs
npm run check # build + testLayout:
src/core.cjs pure aggregation and formatting
src/footer.cjs footer DOM, details card and interactions
src/placement.cjs DOM-free card placement math
src/gui.cjs plugin lifecycle, DOM discovery, animation
dist/gui-experimental.cjs built single-file CJS entry (committed)
test/core.test.cjs aggregation and formatter tests
test/placement.test.cjs card placement edge cases
scripts/build.mjs esbuild bundle
assets/mock.html interactive illustrative mock
For local development, copy dist/gui-experimental.cjs into your plugin directory after each build, or point the
plugin at the repo directory and reload. The app watches the built file and reloads the GUI for local plugin
directories.
Questions / launch feedback: OpenCode Discord · Issues
- Open an issue for bugs, selector breakage, or feature requests.
- Keep
dist/gui-experimental.cjsin sync withsrc/; CI checks it. - Run
npm testbefore opening a PR. - If an OpenCode update breaks the footer, include the app version and a screenshot.
MIT © AquariusCZ

{ "plugins": [ { "package": "opencode-token-usage-gui", "options": { "compact": false, "detailed": false, "showCost": true, "popoverSide": "left", "animate": true, "refreshMode": "continuous", // default; "interval" switches to refreshMs/tweenMs "sampleMs": 100, "smoothMs": 250, "refreshMs": 500, // interval mode only "tweenMs": 550, // interval mode only "currency": "CNY", "usdToCny": 6.68 } } ] }