feat(tui): fleet token meter in the ambient panel - #1057
Merged
Conversation
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.
This was referenced Aug 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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;/rainswitches 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.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'sfull ⇄ / 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
Costgainsmodel: Option<String>,#[serde(default, skip_serializing_if)]:message.modelon the same record the usage comes fromsession.model— same field itsModelChangedreads/modelswitches and lazy re-resolveresultNone— no model to read, and the latter carries no tokens anywayEvery 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 explicitunattributedseries. Samples are never dropped for want of a label.Additive and backward compatible — covered by tests: a legacy
Costwithout the field parses, and aNonemodel 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_historymethod. 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:
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 saysno token usage reported yetrather than rendering a blank grid.Specs
specs/0167-fleet-token-meter.mdspecs/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 --workspacegreen. 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/cliis user-visible here (plus the protocol field and adapter stamps) → the binary to run isconstruct./Users/moon/agentd/.claude/worktrees/feat-token-meter/target/debug/construct