ai: Show the running turn's token usage in the chat TUI - #311
Merged
Conversation
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.
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
Surfaces the token consumption the LongbridgeAI agent reports for a conversation round, in the
longbridge aichat 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):Follows the backend contract in
token-usage-client-integration.md.How
token_usageSSE event — the SDK has no typed variant, so it arrives asConversationStreamEvent::Other. Map it to a typedAgentEvent::TokenUsagein both the live SDK path (client.rs) and the reconnect parser (events.rs).ChatAggregator/ChatStatereplace the value rather than sum it; a replay on reconnect can never double the count. Frames are also cleared onStreamInterrupted.ChatMessagereads the optional top-leveltoken_usagefield back (omitted from serialized output when absent).agent chatgains the round total on its footer and in--format json(ChatOutcome.token_usage).Verification
cargo fmtclean;cargo clippydelta vsmainis 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-frameTestBackendrender asserting the count appears on the running turn row.agent chatturn returnstoken_usage {prompt: 11939, completion: 36, total: 11975}.🤖 Generated with Claude Code