Skip to content

ai: Show the running turn's token usage in the chat TUI - #311

Merged
hogan-yuan merged 6 commits into
mainfrom
feat/ai-token-usage
Aug 27, 2026
Merged

ai: Show the running turn's token usage in the chat TUI#311
hogan-yuan merged 6 commits into
mainfrom
feat/ai-token-usage

Conversation

@hogan-yuan

Copy link
Copy Markdown
Member

What

Surfaces the token consumption the LongbridgeAI agent reports for a conversation round, in the longbridge ai chat TUI — rendered inline with the timer on the running turn's status row, rolling up as the server reports it (the same place Claude Code keeps it):

⠋  Generating…   · ↑ 11,975 tokens                                      [stop]
╭──────────────────────────────────────────────────────────────────────────────╮
│❯ Ask anything — press / for commands                                         │
╰──────────────────────────────────────────────────────────────────────────────╯

Follows the backend contract in token-usage-client-integration.md.

How

  • New token_usage SSE event — the SDK has no typed variant, so it arrives as ConversationStreamEvent::Other. Map it to a typed AgentEvent::TokenUsage in both the live SDK path (client.rs) and the reconnect parser (events.rs).
  • Overwrite, not accumulate — each frame is the round's running total, so ChatAggregator/ChatState replace the value rather than sum it; a replay on reconnect can never double the count. Frames are also cleared on StreamInterrupted.
  • No "0 tokens" placeholder — an all-zero frame is dropped at parse time; a round that consumed no tokens (cache hit, etc.) shows nothing.
  • HistoryChatMessage reads the optional top-level token_usage field back (omitted from serialized output when absent).
  • CLIagent chat gains the round total on its footer and in --format json (ChatOutcome.token_usage).

Verification

  • cargo fmt clean; cargo clippy delta vs main is zero new findings.
  • cargo test: 801 pass, including new coverage for SSE parsing, overwrite semantics, empty-frame dropping, history read-back, thousands grouping, and a full-frame TestBackend render asserting the count appears on the running turn row.
  • Confirmed live against the API: a real agent chat turn returns token_usage {prompt: 11939, completion: 36, total: 11975}.

🤖 Generated with Claude Code

Surface the token consumption the LongbridgeAI agent reports for a
conversation round. The SDK carries the new `token_usage` SSE event as an
`Other` frame, so map it to a typed `AgentEvent::TokenUsage` (in both the
live SDK path and the reconnect parser), fold it into `ChatOutcome` with
overwrite — not accumulate — semantics so a replay on reconnect never
doubles the count, and thread it through the TUI's `ChatEvent`/`ChatState`.

The count renders inline with the timer on the running turn's status row —
`⠋ Generating…  · ↑ 11,975 tokens  [stop]` — rolling up as frames arrive, the
same place Claude Code keeps it. A round that burned no tokens shows nothing
(never a "0 tokens" placeholder).

Also read `token_usage` back from chat history (`ChatMessage`) and expose the
round total on `agent chat`'s footer and JSON output.
The blank row that lifts the prompt box off the transcript is redundant when
the status row above it already draws something — the scrolled-up hint, a
notice, a folded-paste chip, or the find bar. It left too tall a gap between,
e.g., the "Scrolled up" line and the box. Reuse the running-turn `hug` path
for those cases too: drop the blank and shrink the footer by the same row, so
the box sits directly under the populated status row. Idle with a committed
transcript, the row is blank and the blank-above is kept.
Four fixes from the branch review:

- The slash-command palette anchored one row above the prompt box on the
  assumption of a blank row between them; in the hugged layout (populated
  status row — scrolled up, notice, attachments, find bar) that row is the
  box's top border, so the palette overwrote it. Anchor to the border's real
  row instead.
- Restoring a session mid-turn left the old turn's task streaming into the
  restored conversation, committing conversation A's answer (and token count)
  into conversation B's transcript. Abort the turn on restore, and bump the
  generation in `restore` (like `reset`) so events the aborted task already
  queued are discarded; also drop the live token count with the rest of the
  transient turn state.
- The history read-back fallbacks (`read_back_answer`, `finalize_from_history`)
  now emit the stored `token_usage` — the value equals the stream's final
  frame, which is exactly what the dead connection lost — instead of ending
  every recovered run with none.
- The `agent chat` footer concatenated optional stats around fixed separators,
  printing a dangling "· ·" when elapsed time was absent; the segments are
  collected and joined instead.
Five fixes from the follow-up review:

- restore() left state.thinking and turn_started intact, so resuming a
  conversation mid-reasoning leaked the abandoned turn's live reasoning block
  into the new transcript (and Esc then committed it there). Extract a shared
  ChatState::clear_turn_state() — generation bump plus every in-flight field —
  used by both reset() and restore(), so the two teardown lists can't drift.
- `agent chat --schema` hand-enumerates the outcome fields but was missing the
  new token_usage; add it so the machine-readable contract matches --format json.
- The resume path aborted the running turn but, unlike new_session and
  cancel_turn, never reported interrupt_answered(false) for a question left
  pending. Factor the shared teardown into abandon_turn() and call it from all
  three, so no path forgets the analytics the others do.
- On a narrow status row the token span pushed the [stop] button off the edge;
  the count now yields to the cancel affordance, added only when it and the
  reserved stop label both still fit.

The Cancelled-vs-outcome race on a resume that lands as a turn finishes is left
as-is: analytics deliberately prefers a mislabelled finish over a missing one,
so recording the abandoned turn as Cancelled is the intended conservative
behavior rather than a bug to gate away.
Third-round review fixes:

- Sign-out and `/agent` switch dropped the running turn without reporting it:
  sign-out hand-rolled abort + cancel, and switch_agent had no turn handle at
  all, so a turn abandoned by either left an open turn_start in analytics (and
  an unanswered question unrecorded). Route sign-out through cancel_turn and
  thread the turn handle into switch_agent so it calls abandon_turn — the
  helper's contract ("shared by every path that drops the running turn") now
  actually holds. The quit-time cleanup goes through it too.
- The live token span labelled the round TOTAL with `↑`, which by the
  Claude-Code convention it borrows means input/sent only; drop the arrow so
  the figure isn't misread as prompt tokens.
- Split bump_generation() out of clear_turn_state(): clearing fields and
  invalidating the concurrency generation are different concerns, and fusing
  them hid a load-bearing side effect behind a tidy name.
- Collapse status_row_populated's if-chain (its `&& find.is_none()` guard was
  dead) to one disjunction, and share the stop-button width bar between the
  token gate and the button placement via a single fits_with_stop closure so
  the two can't drift.
Fourth-round review fixes:

- Sign-out ran the turn teardown (cancel_turn → state.cancel) before
  clear_token(). A failed logout therefore left the user signed in but had
  already wiped a paused conversation's pending_interrupt and folded its
  partial answer, so the next prompt was misrouted as a fresh turn. Move the
  teardown into the Ok branch: a failed clear_token now leaves the
  conversation untouched.
- `/retry` selected from the command palette (or clicked) silently did
  nothing: run_slash only special-cased new/agent, so retry fell through to
  exec_slash, which has no retry arm. Thread the event sender into
  run_slash/run_slash_selected and dispatch retry to retry_last, matching what
  submit already does for the typed path.
- Delete exec_slash's dead `new` arm (both dispatchers intercept new before it,
  and the arm reset without abandon_turn/session_new — a latent analytics
  leak), and drop the now-duplicated per-command dispatch comments.
- Scope the token-row test's `↑` assertion to the row carrying the count, so an
  unrelated chrome glyph elsewhere can't false-fail it.
@hogan-yuan
hogan-yuan merged commit 7b64641 into main Aug 27, 2026
1 check passed
@hogan-yuan
hogan-yuan deleted the feat/ai-token-usage branch August 27, 2026 09:05
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