Skip to content

feat(cli): display real-time token consumption during streaming (#2742)#3329

Open
qqqys wants to merge 7 commits intoQwenLM:mainfrom
qqqys:feat/realtime-token-display
Open

feat(cli): display real-time token consumption during streaming (#2742)#3329
qqqys wants to merge 7 commits intoQwenLM:mainfrom
qqqys:feat/realtime-token-display

Conversation

@qqqys
Copy link
Copy Markdown
Collaborator

@qqqys qqqys commented Apr 16, 2026

TLDR

Display real-time token consumption in the spinner/loading indicator during model execution. Shows ↓ N tokens when receiving output and ↑ N tokens when waiting for API response, with agent/subagent tokens included in the total. Token count accumulates across the whole turn and resets on new user queries.

Screenshots / Video Demo

token-display

Dive Deeper

Architecture

The implementation follows claude-code's approach with adaptations for Qwen Code's Ink-based UI:

Data flow:

useGeminiStream.ts                    agent.ts
  streamingResponseLengthRef (ref)      USAGE_METADATA → tokenCount
  isReceivingContent (state)                ↓
         ↓                            AgentResultDisplay.tokenCount
    AppContainer.tsx → UIState                ↓
         ↓                            pendingGeminiHistoryItems
    Composer.tsx ←────────────────────────────┘
      useAnimationFrame(ref, 50ms)
      + aggregate agentTokens
         ↓
    LoadingIndicator.tsx
      ↑/↓ arrow + token count

Key design decisions:

  1. Ref-based character countingstreamingResponseLengthRef accumulates output characters in handleContentEvent without triggering React re-renders. Tokens are estimated as chars / 4.

  2. useAnimationFrame hook — Polls the ref at 50ms intervals but only triggers setState when the value actually changes. This avoids both the flickering from per-delta state updates and the waste of unconditional 50ms re-renders.

  3. Turn-level accumulation — The character counter resets only on new user queries (submitType !== ToolResult), not on tool-result continuations. This matches claude-code's behavior where token count only increases within a turn.

  4. Phase detectionisReceivingContent is set to false when entering submitQuery (requesting) and true on the first content event (responding). This drives the / arrow direction.

  5. Agent token aggregation — The agent tool now forwards USAGE_METADATA events to AgentResultDisplay.tokenCount. Composer aggregates these from pendingGeminiHistoryItems and adds them to the streaming estimate.

Files changed

File Change
useAnimationFrame.ts New hook — polls a ref at fixed intervals, re-renders only on value change
useGeminiStream.ts Added streamingResponseLengthRef (char counter) and isReceivingContent (phase flag)
UIStateContext.tsx Added two fields to UIState interface
AppContainer.tsx Wired new values from hook to UIState
Composer.tsx Token estimation via useAnimationFrame, agent token aggregation
LoadingIndicator.tsx Added isReceivingContent prop for dynamic / arrow
tools.ts Added tokenCount to AgentResultDisplay
agent.ts Forward USAGE_METADATA events to display

Reviewer Test Plan

  1. Basic streaming — Send a simple prompt and verify ↓ N tokens appears in the spinner, increasing as output streams.

  2. Tool calls — Send a prompt that triggers tool use (e.g. "read file X"). Verify:

    • Token count keeps accumulating across the turn
    • Arrow switches to while waiting for API after tool result
    • Arrow switches back to when model resumes output
  3. New turn reset — After a response completes, send a new prompt. Verify token count resets to 0 (no stale flash from previous turn).

  4. Agent/subagent — Launch a task that uses the Agent tool. Verify the main spinner includes the subagent's token consumption.

  5. Narrow terminal — Resize terminal to < 80 columns. Verify tokens are hidden gracefully.

  6. Cancel — Press Esc during streaming. Verify no errors or stale display.

Testing Matrix

🍏 🪟 🐧
npm run
npx
Docker
Podman - -
Seatbelt - -

Linked issues / bugs

Closes #2742

…LM#2742)

Show ↓/↑ token count in the spinner during model execution:
- ↓ when receiving content, ↑ when waiting for API response
- Accumulates across the whole turn (tool calls don't reset)
- Includes agent/subagent token consumption
- Uses useAnimationFrame hook (50ms polling) to avoid flickering

Co-authored-by: Qwen-Coder <[email protected]>
@github-actions
Copy link
Copy Markdown
Contributor

📋 Review Summary

This PR implements real-time token consumption display in the loading indicator, showing ↓ N tokens when receiving model output and ↑ N tokens when waiting for API responses. The implementation is well-architected, using a ref-based character counter with a custom useAnimationFrame hook to avoid excessive re-renders. The code is clean, well-tested, and follows existing project patterns. All tests pass and type checking is successful.

🔍 General Feedback

  • Clean architecture: The separation of concerns is excellent - the useAnimationFrame hook is a reusable utility that cleanly separates polling logic from the token estimation concern
  • Performance-conscious design: Using refs for character counting to avoid re-renders on every text delta is a smart optimization
  • Good test coverage: New tests cover both arrow directions (↑/↓) and the token display logic
  • Consistent with codebase: The implementation follows the claude-code approach mentioned in the PR description and integrates well with Qwen Code's existing architecture
  • Well-documented: Code comments explain the design decisions clearly, particularly in Composer.tsx

🎯 Specific Feedback

🟢 Medium

  • File: packages/cli/src/ui/components/Composer.tsx:47-58 - The agent token aggregation logic uses a type assertion (as { tools: Array<{ resultDisplay?: { type: string; tokenCount?: number } }> }) which bypasses TypeScript's type safety. Consider extracting this into a properly typed helper function or adding a type guard to make the type narrowing explicit and safer.

  • File: packages/cli/src/ui/hooks/useAnimationFrame.ts:26 - The initial state useState(() => watchRef.current) captures the ref value at mount time, but the lastSeen ref is also initialized to the same value. If the ref changes between component mount and the first effect run, there could be a race condition. Consider initializing both from a getter function or using useLayoutEffect for the sync logic.

  • File: packages/core/src/tools/agent.ts:488-499 - The USAGE_METADATA event handler accumulates tokens via this.updateDisplay({ tokenCount: total }, updateOutput), but it's unclear if tokenCount is being accumulated or replaced. If multiple USAGE_METADATA events fire, will the display show the latest value or should it be accumulating? The logic appears to replace rather than accumulate, which may be intentional but should be clarified with a comment.

🔵 Low

  • File: packages/cli/src/ui/hooks/useAnimationFrame.ts:1 - The copyright year is 2025 while other files in the codebase use 2025 Google LLC. The license header should match the project's standard format (compare with LoadingIndicator.tsx which has "Copyright 2025 Google LLC").

  • File: packages/cli/src/ui/components/LoadingIndicator.tsx:25 - The JSDoc comment /** true = receiving content (↓), false = waiting for API (↑). Default true. */ uses inline format. For consistency with other props in the file, consider using the multi-line JSDoc format:

    /**
     * True when receiving content (shows ↓ arrow), false when waiting for API response (shows ↑).
     * @default true
     */
    isReceivingContent?: boolean;
  • File: packages/cli/src/ui/hooks/useAnimationFrame.ts:32-36 - The re-sync logic comment mentions "new turn resets ref to 0" but this is one specific use case. Consider making the comment more general: "Re-sync when the interval resumes or when the ref value changes externally".

  • File: packages/cli/src/ui/components/Composer.tsx:38-42 - The isStreaming condition checks for Responding or WaitingForConfirmation states. This logic could be extracted to a small utility function or constant for better readability and reusability, especially if this pattern appears elsewhere.

✅ Highlights

  • Excellent performance optimization: The useAnimationFrame hook is a clever solution that balances smooth UI updates with rendering efficiency - polling at 50ms but only re-rendering when values actually change
  • Thoughtful turn-level accumulation: The decision to reset the character counter only on new user queries (not tool-result continuations) matches claude-code's UX and provides a coherent token count per conversation turn
  • Comprehensive test coverage: The new tests for arrow direction ( vs ) ensure the phase detection logic works correctly
  • Clean integration: The changes are minimally invasive - adding new fields to UIState and wiring them through AppContainer without disrupting existing functionality
  • Good edge case handling: The code handles narrow terminal widths gracefully by hiding tokens, and the cancel scenario is considered in the design

- Replace unsafe type assertion with proper type guard in Composer
- Fix license header in useAnimationFrame.ts to match project standard
- Clarify tokenCount is replaced (not accumulated) per USAGE_METADATA event
- Use multi-line JSDoc format for isReceivingContent prop
- Improve re-sync comment in useAnimationFrame hook
- Revert unrelated streamingState dep change in AppContainer

Co-authored-by: Qwen-Coder <[email protected]>
@tanzhenxin tanzhenxin added the type/feature-request New feature or enhancement request label Apr 16, 2026
Copy link
Copy Markdown
Collaborator

@tanzhenxin tanzhenxin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Real-time token display in the spinner is a good feature, and the approach is sound (chars/4 estimation, 50ms ref polling to avoid re-renders). The ↑/↓ phase arrows are a nice touch. Two bugs to fix.

Issues

  1. Subagent token aggregation mixes incompatible units. The main stream estimates output-only tokens (chars/4), but subagent tokenCount comes from totalTokenCount which is input + output. These get summed together, so a subagent with large context dominates the display. Suggestion: use output-only token counts for agents (candidatesTokenCount), or display agent tokens separately.

  2. Subagent multi-round tokens overwritten instead of accumulated. USAGE_METADATA is emitted per subagent round, but the code overwrites tokenCount instead of accumulating. Multi-round subagents under-report. Suggestion: display.tokenCount = (display.tokenCount ?? 0) + event.usage.totalTokenCount.

Verdict

REQUEST_CHANGES — The token aggregation bugs need to be fixed.

Subagent token display had two bugs:
- Used totalTokenCount (input+output) instead of candidatesTokenCount
  (output-only), causing mixed units when aggregated with main stream
- Overwrote tokenCount per round instead of accumulating, so multi-round
  subagents only showed the last round's count

Co-Authored-By: Qwen-Coder <[email protected]>
@qqqys
Copy link
Copy Markdown
Collaborator Author

qqqys commented Apr 17, 2026

Both issues fixed in d393f23:

  1. Mixed units — Switched from totalTokenCount to candidatesTokenCount (output-only), consistent with the main stream's chars/4 estimation.
  2. Overwrite vs accumulate — Added accumulatedOutputTokens counter that sums candidatesTokenCount across rounds, so multi-round subagents report correct totals.

Copy link
Copy Markdown
Collaborator

@tanzhenxin tanzhenxin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick turnaround — both High-severity issues are correctly fixed end-to-end: the unit-mismatch via candidatesTokenCount in agent.ts:497, and multi-round accumulation via the per-invocation accumulatedOutputTokens closure. The commit message on d393f23 is clear about the rationale.

A couple of things I'd still tidy up before merging:

  1. The jsdoc on AgentResultDisplay.tokenCount in packages/core/src/tools/tools.ts:502 still says "(input + output)" — that's now stale since the value is output-only. Worth fixing in this PR since the commit that landed the accurate semantics is the one that stranded the doc.

  2. useAnimationFrame can briefly render the previous turn's count at the start of a new turn. The ref is reset to 0 in useGeminiStream.ts:1367 when a non-ToolResult submit begins, but the hook's useState still holds the previous value until the 50ms tick fires. Simplest fix is to read watchRef.current synchronously on render, or key/reset the hook on new top-level turns so the first render starts at 0.

One design thought for later (not a blocker): the spinner now always shows chars/4 during streaming, whereas the old code used server-reported candidatesTokenCount from sessionStats.metrics. chars/4 is the right call during streaming since usage metadata isn't available yet, but once the first response's usage lands it'd be strictly better to swap to the real count. Happy to track this as a follow-up.

Verdict: comment — fine to merge once (1) and (2) are in, (3) can ship separately.

qqqys and others added 3 commits April 17, 2026 16:26
Interpolate displayed token count toward the real value (3/frame for
small gaps, ~20% for medium, 50 for large) so chunked arrivals like
tool-call args no longer cause visible jumps. Also accumulate tool
call args JSON length into the streaming estimate, matching Claude
Code's input_json_delta handling.

Co-Authored-By: Qwen-Coder <[email protected]>
The 50ms useAnimationFrame poll lived in Composer, causing its entire
subtree (InputPrompt, Footer, KeyboardShortcuts) to reconcile 20×/sec
during streaming. Combined with the spinner and streamed text deltas,
ink redrew enough lines to produce visible terminal flicker.

Move the animation hook into LoadingIndicator so only that component
re-renders per frame, and slow polling to 100ms to match the spinner
cadence.

Co-Authored-By: Qwen-Coder <[email protected]>
1. AgentResultDisplay.tokenCount jsdoc said "(input + output)" but the
   value has been output-only since d393f23 — update the comment so it
   matches the implementation.
2. useAnimationFrame held the previous turn's count in state until the
   next interval tick, briefly flashing stale numbers when a new turn
   reset the ref to 0. Snap displayRef down synchronously on render and
   return Math.min(displayValue, ref.current) so the reset is reflected
   immediately; the interval tick still catches state up afterward.

Co-Authored-By: Qwen-Coder <[email protected]>
@qqqys
Copy link
Copy Markdown
Collaborator Author

qqqys commented Apr 17, 2026

Thanks for the careful review! Both blockers addressed in e8eaa7c:

(1) Stale jsdoc on AgentResultDisplay.tokenCount (packages/core/src/tools/tools.ts:502) — updated to Real-time output-token count during execution, accumulated across subagent rounds, matching the semantics introduced in d393f23.

(2) Previous turn's count flashing on new turn (packages/cli/src/ui/hooks/useAnimationFrame.ts) — went with the synchronous-read approach over keying so the spinner doesn't restart:

  • During render, snap displayRef/targetRef down whenever watchRef.current < displayRef.current (idempotent, StrictMode-safe).
  • Return Math.min(displayValue, watchRef.current) so the reset is reflected on the very same render the ref drops; the next interval tick then brings the displayValue state in line.

This way new turns start at 0 immediately, with no dependency on tick cadence (which became 100ms in e0147e3 to scope token-animation re-renders to LoadingIndicator and stop input-area flicker — separate fix from a different review thread).

(3) Swap to server-reported candidatesTokenCount once usage metadata lands — agreed, tracking as follow-up rather than scoping into this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/feature-request New feature or enhancement request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Display real-time token consumption during task input/output phases

2 participants