Skip to content

feat(tui): fleet token meter in the ambient panel - #1057

Merged
edwin-zvs merged 6 commits into
mainfrom
feat-token-meter
Aug 1, 2026
Merged

feat(tui): fleet token meter in the ambient panel#1057
edwin-zvs merged 6 commits into
mainfrom
feat-token-meter

Conversation

@edwin-zvs

@edwin-zvs edwin-zvs commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

What

The ambient panel (Matrix rain) gains a second body mode: a realtime, fleet-wide token-throughput history, stacked by model — a CPU-usage-history meter for token spend.

Switch with the header control, or /tokens; /rain switches back. The choice persists across launches. Switching modes never collapses the panel, and the operator widget viewport (spec 0019) still overlays whichever mode is selected.

 ─⏸ operator ──────────── tokens ⇄ ─ −
                                  █
       ▂                          █
       █ ▇                     ▅  ▄▃
       ▂ █       ▃           ▁ █  ██ ▅
   ▇   █ ▅    ▄  █   ▆      ▄█▆▃ ▃▁███
   █   █ █ ▃ ▂█▄▄█   █▆ ▁▇  ████ ██▅██
   ▆ ▄▇▄▂▁▄▅▅▆█▅█▃  ▅▁█ ██  █▂▄█▇▄█▃▇▄
 ▃▄█▅█▆█▇███▃█▇█▁██ █▁▂▂▂█▂ ▇██▃██▂███
 █████████▇█▆█████▄▂▇████▆█ ██▇█▄███▇█
 ● claude-opus-5 3.7k/s
 ● gpt-5.5-codex 2.8k/s
 ● kimi-k3 1.4k/s
 ● gemini-3-pro 1.1k/s         Σ 9.1k/s

Rendered from the actual widget via TestBackend — four models, ~58 minutes of synthetic history, at the default (narrow) list width. Each series has its own color; the ASCII here loses that, so the legend dots are what identify them on screen. Hovering a column shows that minute's per-model split.

One column is one minute. The switch names the mode currently showing and carries the same glyph as the session list's full ⇄ / compact ⇄ toggle. The legend wraps onto as many rows as it needs to name every series, bounded to a third of the panel.

Where the data comes from

SessionEvent::Cost — the per-model-call usage reports adapters already emit for the lifetime tally (spec 0103) and context gauge (spec 0104). Those are broadcast to every connected client for every session, so the meter is fleet-wide with no new capture path, no polling, and no daemon change beyond the protocol field.

Explicitly not the model router. Its default transport is a blind byte splice that spec 0113 forbids parsing for observability, and it only ever sees the fraction of sessions with an armed route.

Protocol change

Cost gains model: Option<String>, #[serde(default, skip_serializing_if)]:

adapter source
claude message.model on the same record the usage comes from
pi the provider-qualified label built from the same message
hermes session.model — same field its ModelChanged reads
antigravity the model just announced on that generation row
opencode its model file, read alongside the usage file each tick (catches mid-session switches through OpenCode's own picker)
codex, kimi the watcher's tracked model — their usage records state none
smith its live spec string, tracked across /model switches and lazy re-resolve
codex PTY footer, claude run-level result None — no model to read, and the latter carries no tokens anyway

Every adapter uses the same spelling as its own ModelChanged. A divergence would split one model into two series and two colors. Attribution falls back: report → session's tracked model → an explicit unattributed series. Samples are never dropped for want of a label.

Additive and backward compatible — covered by tests: a legacy Cost without the field parses, and a None model serializes to the same bytes as before.

Two deliberate choices worth reviewing

Autoscale, no percentage. Token throughput has no capacity to divide by, so the ceiling is the tallest visible column and the legend states it as a per-second rate (peak 22k/s), normalized out of the column span so retuning that span never silently changes the number. The floor is itself a rate, so it means the same thing at any column width, and the ceiling descends over the next few columns after a burst scrolls off rather than snapping — but never below a column still on screen.

Buckets are report-arrival time, not generation time. A call reports once at the end, so its whole payload lands in the column that was filling when it landed. Smearing it backwards over the interval it was produced in would look smoother and would be a fabrication; spec 0103's no-estimating rule applies.

Stack order is series order, not rank. Every column stacks its series in first-seen order, which is also palette order. Ordering by size would reshuffle the bands whenever the leader changed, and a stacked graph whose layers swap places column-to-column can't be read as layers — the eye follows a band's continuity, not its rank.

Stacking has to survive the cell grid. A terminal cell holds one glyph, so a series boundary inside a cell can't be two glyphs — the first version drew them as two and the upper series overwrote the lower, meaning a dominant model erased everything under it. Boundary cells now draw the lower series as the partial block's filled part over the upper series as background. Only the partially-filled top cell, whose empty part must stay panel background, still goes to a single series.

History survives a restart

The meter's history can't live only in the client: the fleet keeps burning tokens while a TUI is closed, so a client remembering only its own samples came back showing a hole exactly where work happened.

The daemon now keeps a 12-hour rolling window of samples and clients seed from it through a new usage.token_history method. Nothing new is persisted for it — the window is recovered at boot from the same transcripts the daemon already walks end-to-end to self-heal each session's tally (spec 0103). No extra I/O, no second copy to keep consistent, and it survives a daemon restart as well as a client one.

Recovered samples are attributed to the model in effect at that point in the transcript, so a session that switched models doesn't credit its later model for earlier work.

Verified against a live daemon booted over a hand-written transcript:

3 samples
   8.7 min ago  model='claude-opus-5'    tokens=32000   ← pre-switch, model inherited from the transcript
   4.7 min ago  model='gpt-5.5-codex'    tokens=10500   ← post-switch, not credited to claude
   2.7 min ago  model='kimi-k3'          tokens=7100    ← model stated on the report itself

A 20-hour-old sample was dropped by the window, and cached input stayed excluded (30k + 2k = 32k, not 57k). The live-append path is covered by unit tests only — an isolated daemon has no harness to produce real usage reports.

Coverage, honestly

grok reports only the context gauge and no Cost, and shell reports nothing — those sessions contribute no columns, and the meter does not estimate any for them. An empty meter says no token usage reported yet rather than rendering a blank grid.

Specs

  • New: specs/0167-fleet-token-meter.md
  • Amended: specs/0019-… — the panel body is now a set of named built-in modes; the widget viewport is an overlay on top of the selected mode, not a mode itself.

Tests

cargo test --workspace green. New coverage: history seeding + age bucketing + out-of-ring drops, daemon window eviction/cap/ordering, stable stack order, legend rate + sub-unit rounding, bucketing/arrival-time, autoscale floor + decay + never-below-visible, per-second rate normalization, first-seen color stability, legend collapse past the palette, right-aligned partial history, zero-token samples ignored, cached-not-double-counted, largest-remainder stacking, boundary-cell fg/bg encoding, legend wrapping + row budget, label truncation marking, protocol back-compat, and five render/click tests through the real draw path.

Binaries

Only crates/cli is user-visible here (plus the protocol field and adapter stamps) → the binary to run is construct.

/Users/moon/agentd/.claude/worktrees/feat-token-meter/target/debug/construct

The Matrix-rain panel now hosts named body modes. The new one is a
realtime token-throughput history across the whole fleet: one column per
second, stacked by model, autoscaled, with a legend and per-column hover
detail. Switch with the header control or `/tokens`; `/rain` switches
back. The selected mode persists across launches.

It is built on the usage reports sessions already broadcast (spec 0103),
not on the model router — the router's default transport is a blind byte
splice that must never be parsed for observability (spec 0113), and it
would only ever see the fraction of sessions with an armed route.

`SessionEvent::Cost` gains an optional `model`, stamped by every adapter
that can read it from the same report it already reads usage from, using
the same spelling as that adapter's `ModelChanged`. The field is additive:
records written without it keep loading, and a report that omits it
serializes exactly as before. Clients attribute a sample to the reported
model, then the session's tracked model, then an explicit `unattributed`
series — a sample is never dropped for want of a label.

Deliberately not a percentage gauge: token throughput has no capacity to
divide by, so the ceiling is the visible peak and the meter states it.
Buckets are report-arrival time, so the graph is bursty by nature —
smearing a sample over the interval it was produced in would look smoother
and would be a fabrication.

Adds spec 0167; amends 0019 for the panel's mode set.
Follow-ups from review of the meter:

- Mode switch now names the mode currently showing and carries the same
  `⇄` swap glyph as the session list's `full ⇄ / compact ⇄` toggle, so the
  two controls read as one convention instead of two.

- Columns are one minute wide (was one second). The autoscale floor is
  expressed as a rate so it means the same thing at any column width, and
  the ceiling's decay is retuned in columns rather than wall-clock.

- Fix stacking: a series boundary landing inside a terminal cell was drawn
  as two foreground glyphs, so the upper series overwrote the lower one and
  a dominant model erased everything under it. Boundary cells now draw the
  lower series as the partial block's filled part over the upper series as
  background. Only the partially-filled top cell, whose empty part must
  stay panel background, still goes to a single series.

- The legend wraps onto as many rows as it needs to name every series,
  bounded to a third of the panel, with a `+N` marker when even that can't
  hold them all. On a narrow panel a single row only ever fit one model,
  which left the other colors unidentifiable.
The meter's history can't live only in the client: the fleet keeps burning
tokens while a TUI is closed, so a client that remembered just its own
samples came back showing a hole exactly where work happened. The daemon
sees every session's usage report whether or not anyone is attached, so it
keeps a 12-hour rolling window and a starting client seeds from it via a
new `usage.token_history` method.

Nothing new is persisted for it. The window is recovered at boot from the
same transcripts the daemon already walks end-to-end to self-heal each
session's tally (spec 0103) — no extra I/O, no second copy to keep
consistent, and it survives a daemon restart too. Recovered samples are
attributed to the model in effect at that point in the transcript, so a
session that switched models doesn't credit its later model for earlier
work.

Verified against a live daemon: booted over a hand-written transcript, the
window came back with the pre-switch sample on the pre-switch model, the
post-switch one on the new model, a report-stated model overriding both,
and a sample outside the window dropped. The live-append path is exercised
by unit tests only — an isolated daemon has no harness to produce real
usage reports.

Also from review:

- Legend entries state each series' throughput over the visible window
  (`● claude-opus-5 3.7k/s`) instead of a raw token total, and the `peak
  x/s` readout is gone. A total changes with panel width for the same
  actual throughput; the rates now carry magnitude, and the bars carry
  shape and mix.

- Stack order is stable across columns — series order (first-seen), not
  per-column rank. A stacked graph is read by following a band across the
  graph, which is impossible when the bands swap places whenever the
  leader changes.
The comment claimed the rates 'stay comparable as the panel is resized'.
They don't: the divisor is the visible window, so a narrower panel averages
a shorter, more recent span and reports a different number for unchanged
throughput. What the divisor guarantees is only that the figure is per
second however the column width is tuned.
The legend's throughput figure now divides by the time sessions on that
model actually spent computing, over a sliding one-minute window, instead
of by the whole visible graph's wall-clock span.

Dividing by wall-clock answered "how much of the window did this model
spend generating" — a number that falls as the fleet idles even though
nothing about the model changed, and that moves when the panel is resized.
Dividing by compute answers "how fast is this model when it runs", which is
a property of the model and stays comparable across a busy fleet and a
quiet one.

The denominator comes from the daemon's own per-session compute-time
accumulator, differenced per tick rather than sampled from a state flag, so
a busy span that starts and ends between two ticks still counts. Only
model-backed sessions contribute: a shell session is busy most of its life
with no model behind it, and counting that would understate every rate on
the fleet.

The rate window slides in one-second slots rather than reusing the
one-minute columns, so rates don't flick to zero at each column boundary.

A model with no compute time in the window reads as `idle`, not `0/s` —
"did no work" and "worked slowly" are different claims. The fleet figure
uses the same basis, so concurrent sessions can't push it above what any
of them achieved.

Known limitation, recorded in the spec: this is turn time, so tool
execution inside a turn counts toward the denominator. Pure generation
latency isn't reported per call by any wrapped harness, and synthesizing
it would be a guess.
It pooled instead: total tokens over total compute. That is a weighted
average of the per-model rates, so it could never exceed the fastest single
model however many ran at once — blind to the parallelism a fleet exists to
provide, and visibly inconsistent with the rates listed directly above it
under a Σ.

Two models at 2k/s and 1k/s now read 3k/s, not 1.5k/s. The collapsed
"other" row sums its members for the same reason. A fleet where nothing
computed has no rate rather than zero, matching the per-model rule.
@edwin-zvs
edwin-zvs merged commit 5e80ac4 into main Aug 1, 2026
1 check passed
@edwin-zvs
edwin-zvs deleted the feat-token-meter branch August 1, 2026 01:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant