Show task tokens and usage estimates in the agent command center (#44970)

## What changed

- Display input/output token counts and estimated credits and USD cost in task details. Prefer live token totals, falling back to complete totals from usage breakdowns.
- Fetch estimates for the selected task on a one-minute cadence for supported Business and Enterprise plans. Cache results, preserve prior nonzero estimates when a response reports zero, and stop fetching when the capability is unavailable.
- Clear cached usage and discard pending results on account changes, reconnects, or missed server events. Prioritize activity and usage over the original prompt when space is limited.

## Testing

Add a rendering snapshot and tests covering caching, stale results, unavailable usage, token resets, and incomplete usage breakdowns.

GitOrigin-RevId: b72c96053557389fdc723a70fab0dab841a52e49
This commit is contained in:
Eric Traut
2026-09-12 04:17:56 +00:00
committed by copyberry
parent 7efb0262d6
commit aee8a55ab6
15 changed files with 533 additions and 4 deletions

View File

@@ -204,6 +204,7 @@ mod agents_overview;
mod agents_overview_actions;
mod agents_overview_details;
mod agents_overview_threads;
mod agents_overview_usage;
mod agents_overview_view;
pub(crate) use agents_overview::AGENTS_OVERVIEW_VIEW_ID;
mod app_server_event_targets;
@@ -953,6 +954,7 @@ impl App {
// Allow widgets to process any pending timers before rendering.
let had_active_view = self.chat_widget.has_active_view();
self.chat_widget.pre_draw_tick();
self.refresh_agents_overview_usage(app_server, tui.frame_requester());
let rendered_area = self.render_chat_widget_frame(tui, screen_size)?;
if !had_active_view
&& self.chat_widget.has_active_view()

View File

@@ -40,6 +40,9 @@ pub(super) struct AgentsOverviewState {
/// Local visibility only; activity and metadata refreshes never reveal hidden roots.
pub(super) hidden_threads: HashSet<ThreadId>,
pub(super) last_messages: HashMap<ThreadId, String>,
pub(super) usage: HashMap<ThreadId, super::agents_overview_usage::AgentsOverviewUsage>,
pub(super) pending_usage: Option<(ThreadId, Uuid)>,
pub(super) usage_disabled: bool,
pub(super) activity: HashMap<ThreadId, super::agents_overview_details::AgentsOverviewActivity>,
pub(super) initialized: bool,
pub(super) unsent_prompt: Option<String>,
@@ -175,6 +178,7 @@ impl App {
self.agents_overview.threads.remove(&thread_id);
self.agents_overview.last_messages.remove(&thread_id);
self.agents_overview.activity.remove(&thread_id);
self.agents_overview.usage.remove(&thread_id);
continue;
}
thread.turns.clear();
@@ -197,6 +201,9 @@ impl App {
{
// Discard stale read results without clearing activity received after the revert.
self.agents_overview.last_messages.remove(&thread_id);
if let Some(usage) = self.agents_overview.usage.get_mut(&thread_id) {
usage.tokens = None;
}
continue;
}
self.track_agents_overview_notification(&notification);

View File

@@ -266,6 +266,7 @@ impl App {
self.agents_overview.threads.remove(&removed_id);
self.agents_overview.activity.remove(&removed_id);
self.agents_overview.last_messages.remove(&removed_id);
self.agents_overview.usage.remove(&removed_id);
self.agents_overview.refresh_thread_ids.remove(&removed_id);
self.agents_overview.input_states.remove(&removed_id);
self.agents_overview.dispatched_requests.remove(&removed_id);

View File

@@ -36,6 +36,7 @@ pub(super) fn preview_markdown(text: &str) -> String {
#[derive(Clone, Default)]
pub(super) struct AgentsOverviewDetails {
pub(super) lines: Vec<Line<'static>>,
pub(super) usage_lines: Vec<Line<'static>>,
pub(super) last_message: Option<(String, AbsolutePathBuf)>,
}
@@ -216,6 +217,11 @@ impl App {
.map(|message| (message.clone(), source.cwd.clone()));
AgentsOverviewDetails {
lines,
usage_lines: ThreadId::from_string(&root.id)
.ok()
.and_then(|id| self.agents_overview.usage.get(&id))
.map(super::agents_overview_usage::usage_lines)
.unwrap_or_default(),
last_message,
}
}

View File

@@ -2995,3 +2995,6 @@ fn trust_fixture_folders(app: &mut App) {
toml::Value::try_from(projects).expect("trust fixture"),
));
}
#[path = "agents_overview_usage_tests.rs"]
mod usage;

View File

@@ -48,6 +48,16 @@ impl App {
.get_mut(&thread_id)
.and_then(Option::as_mut);
match notification {
ServerNotification::ThreadTokenUsageUpdated(usage) => {
if self.agents_overview.threads.contains_key(&thread_id) {
self.agents_overview
.usage
.entry(thread_id)
.or_default()
.tokens = Some(usage.token_usage.total.clone());
self.repaint_agents_overview();
}
}
ServerNotification::ThreadStarted(started) => {
if started.thread.ephemeral {
return;
@@ -59,11 +69,15 @@ impl App {
ServerNotification::ThreadArchived(_) | ServerNotification::ThreadDeleted(_) => {
self.agents_overview.activity.remove(&thread_id);
self.agents_overview.last_messages.remove(&thread_id);
self.agents_overview.usage.remove(&thread_id);
self.agents_overview.threads.remove(&thread_id);
self.agents_overview.refresh_thread_ids.remove(&thread_id);
}
ServerNotification::ThreadClosed(_) => {
self.agents_overview.activity.remove(&thread_id);
if let Some(usage) = self.agents_overview.usage.get_mut(&thread_id) {
usage.tokens = None;
}
if let Some(thread) = thread {
thread.status = ThreadStatus::NotLoaded;
}
@@ -71,6 +85,9 @@ impl App {
ServerNotification::ThreadReverted(_) => {
self.agents_overview.activity.remove(&thread_id);
self.agents_overview.last_messages.remove(&thread_id);
if let Some(usage) = self.agents_overview.usage.get_mut(&thread_id) {
usage.tokens = None;
}
self.repaint_agents_overview();
}
ServerNotification::ThreadStatusChanged(status) => {
@@ -94,8 +111,10 @@ impl App {
}
_ => return,
}
if !matches!(notification, ServerNotification::ThreadReverted(_))
&& self.agents_overview.threads.contains_key(&thread_id)
if !matches!(
notification,
ServerNotification::ThreadReverted(_) | ServerNotification::ThreadTokenUsageUpdated(_)
) && self.agents_overview.threads.contains_key(&thread_id)
{
self.agents_overview.refresh_thread_ids.insert(thread_id);
}

View File

@@ -0,0 +1,199 @@
//! Demand-driven dashboard usage. Only the selected task is fetched on a one-minute
//! refresh cadence, including after failures. Account changes and reconnects invalidate
//! pending billing results and disabled capability state.
use super::App;
use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
use super::background_requests::THREAD_USAGE_FETCH_TIMEOUT;
use super::background_requests::fetch_thread_usage;
use crate::app_event::AppEvent;
use crate::app_server_session::AppServerSession;
use crate::chatwidget::ThreadUsageOutcome;
use crate::status::format_credit_micros;
use crate::status::format_estimated_usd_micros;
use crate::status::format_tokens_compact;
use crate::tui::FrameRequester;
use codex_app_server_protocol::ThreadUsage;
use codex_app_server_protocol::ThreadUsageBreakdownGroup;
use codex_app_server_protocol::TokenUsageBreakdown;
use codex_protocol::ThreadId;
use codex_protocol::account::PlanType;
use ratatui::style::Stylize;
use ratatui::text::Line;
use std::time::Duration;
use std::time::Instant;
use uuid::Uuid;
const REFRESH_INTERVAL: Duration = Duration::from_secs(/*secs*/ 60);
#[derive(Default)]
pub(super) struct AgentsOverviewUsage {
pub(super) tokens: Option<TokenUsageBreakdown>,
pub(super) estimate: Option<ThreadUsage>,
fetched_at: Option<Instant>,
}
impl App {
pub(super) fn refresh_agents_overview_usage(
&mut self,
app_server: &AppServerSession,
frame_requester: FrameRequester,
) {
if self.reconnect.offline
|| self.agents_overview.usage_disabled
|| self.agents_overview.pending_usage.is_some()
|| !self.chat_widget.has_codex_backend_auth()
|| !matches!(
self.chat_widget.current_plan_type(),
Some(
PlanType::Business
| PlanType::EnterpriseCbpUsageBased
| PlanType::EnterpriseCbpAutomation
)
)
{
return;
}
let Some(thread_id) = self
.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.and_then(|index| self.agents_overview.visible_thread_ids.get(index))
.copied()
else {
return;
};
if let Some(age) = self
.agents_overview
.usage
.get(&thread_id)
.and_then(|usage| usage.fetched_at)
.map(|fetched| fetched.elapsed())
&& age < REFRESH_INTERVAL
{
frame_requester.schedule_frame_in(REFRESH_INTERVAL - age);
return;
}
let request_id = Uuid::new_v4();
self.agents_overview.pending_usage = Some((thread_id, request_id));
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = tokio::time::timeout(
THREAD_USAGE_FETCH_TIMEOUT,
fetch_thread_usage(request_handle, thread_id),
)
.await
.map_err(|_| "dashboard usage request timed out".to_string())
.and_then(|result| result.map_err(|error| error.to_string()));
app_event_tx.send(AppEvent::AgentsOverviewUsageLoaded {
thread_id,
request_id,
result,
});
});
}
pub(super) fn finish_agents_overview_usage(
&mut self,
thread_id: ThreadId,
request_id: Uuid,
result: Result<ThreadUsageOutcome, String>,
) {
if self.agents_overview.pending_usage != Some((thread_id, request_id)) {
return;
}
self.agents_overview.pending_usage = None;
if self.agents_overview.threads.contains_key(&thread_id) {
let usage = self.agents_overview.usage.entry(thread_id).or_default();
usage.fetched_at = Some(Instant::now());
match result {
Ok(ThreadUsageOutcome::Available(mut estimate))
if estimate.thread_id == thread_id.to_string() =>
{
if let Some(previous) = &usage.estimate {
let zero_credits = estimate.estimated_usage_credits_micros == 0
&& previous.estimated_usage_credits_micros > 0;
let zero_cost = estimate.estimated_usage_usd_micros == Some(0)
&& previous
.estimated_usage_usd_micros
.is_some_and(|cost| cost > 0);
if zero_credits {
estimate.estimated_usage_credits_micros =
previous.estimated_usage_credits_micros;
}
if zero_cost {
estimate.estimated_usage_usd_micros =
previous.estimated_usage_usd_micros;
}
if (zero_credits || zero_cost) && estimate.groups.is_empty() {
estimate.groups.clone_from(&previous.groups);
}
}
usage.estimate = Some(estimate);
}
Ok(ThreadUsageOutcome::Disabled) => {
self.agents_overview.usage_disabled = true;
for usage in self.agents_overview.usage.values_mut() {
usage.estimate = None;
}
}
Ok(ThreadUsageOutcome::Available(_)) | Err(_) => {}
}
}
self.repaint_agents_overview();
}
}
pub(super) fn usage_lines(usage: &AgentsOverviewUsage) -> Vec<Line<'static>> {
let sum = |count: fn(&ThreadUsageBreakdownGroup) -> Option<i64>| {
let groups = &usage.estimate.as_ref()?.groups;
if groups.is_empty() {
return None;
}
groups.iter().try_fold(/*init*/ 0_i64, |total, group| {
count(group)
.filter(|tokens| *tokens >= 0)
.map(|tokens| total.saturating_add(tokens))
})
};
let input = usage
.tokens
.as_ref()
.map(|tokens| tokens.input_tokens)
.or_else(|| sum(|group| group.input_tokens));
let output = usage
.tokens
.as_ref()
.map(|tokens| tokens.output_tokens)
.or_else(|| sum(|group| group.output_tokens));
let mut lines = Vec::new();
let mut tokens = Vec::new();
if let Some(input) = input {
tokens.push(format!("{} in", format_tokens_compact(input)));
}
if let Some(output) = output {
tokens.push(format!("{} out", format_tokens_compact(output)));
}
if !tokens.is_empty() {
lines.push(vec!["Tokens: ".dim(), tokens.join(" · ").into()].into());
}
if let Some(estimate) = &usage.estimate {
let mut values = Vec::new();
if estimate.estimated_usage_credits_micros >= 0 {
values.push(format!(
"{} credits",
format_credit_micros(estimate.estimated_usage_credits_micros)
));
}
if let Some(cost) = estimate
.estimated_usage_usd_micros
.and_then(format_estimated_usd_micros)
{
values.push(cost);
}
if !values.is_empty() {
lines.push(vec!["Est. usage: ".dim(), values.join(" · ").into()].into());
}
}
lines
}

View File

@@ -0,0 +1,236 @@
use super::*;
use crate::app::agents_overview_usage::AgentsOverviewUsage;
use crate::app::agents_overview_usage::usage_lines;
use crate::chatwidget::ThreadUsageOutcome;
use codex_app_server_protocol::AccountUpdatedNotification;
use codex_app_server_protocol::ThreadTokenUsage;
use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification;
use codex_app_server_protocol::ThreadUsage;
use codex_app_server_protocol::TokenUsageBreakdown;
use codex_protocol::account::PlanType;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn selected_usage_is_cached_and_account_changes_discard_old_results() -> Result<()> {
let mut app = make_test_app().await;
let app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
app.chat_widget.update_account_state(
/*status_account_display*/ None,
Some(PlanType::Business),
/*has_chatgpt_account*/ false,
/*has_codex_backend_auth*/ true,
);
let selected = ThreadId::from_u128(/*value*/ 1);
let other = ThreadId::new();
let threads = [(selected, "Review parser"), (other, "Other task")].map(|(id, name)| {
overview_thread(id, /*parent_thread_id*/ None, name, ThreadStatus::Idle)
});
app.agents_overview.threads = threads
.iter()
.cloned()
.map(|thread| (ThreadId::from_string(&thread.id).unwrap(), Some(thread)))
.collect();
let view = app.agents_overview_view(threads.to_vec(), Some(selected));
app.agents_overview.visible_thread_ids = view.thread_ids();
app.chat_widget.show_bottom_pane_view(Box::new(view));
let thread_id = selected;
let request_id = Uuid::new_v4();
let result = ThreadUsageOutcome::Available(serde_json::from_value(serde_json::json!({
"threadId": selected.to_string(),
"estimatedUsageCreditsMicros": 3_400_000,
"estimatedUsageUsdMicros": 140_000,
"groups": [{"estimatedUsageCreditsMicros": 3_400_000, "inputTokens": 12_000, "outputTokens": 3_000}]
})).unwrap());
let tui = crate::tui::test_support::make_test_tui()?;
app.agents_overview.pending_usage = Some((thread_id, request_id));
app.refresh_agents_overview_usage(&app_server, tui.frame_requester());
assert_eq!(
app.agents_overview.pending_usage,
Some((thread_id, request_id))
);
app.finish_agents_overview_usage(thread_id, request_id, Ok(result.clone()));
app.refresh_agents_overview_usage(&app_server, tui.frame_requester());
assert_eq!(app.agents_overview.pending_usage, None);
let ThreadUsageOutcome::Available(mut expected) = result.clone() else {
panic!("expected usage estimate")
};
for (credits, cost, expected_credits) in [(0, 200_000, 3_400_000), (5_000_000, 0, 5_000_000)] {
let mut settlement = expected.clone();
settlement.estimated_usage_credits_micros = credits;
settlement.estimated_usage_usd_micros = Some(cost);
settlement.groups.clear();
expected.estimated_usage_credits_micros = expected_credits;
expected.estimated_usage_usd_micros = Some(200_000);
app.agents_overview.pending_usage = Some((thread_id, request_id));
app.finish_agents_overview_usage(
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(settlement)),
);
assert_eq!(
app.agents_overview.usage[&selected].estimate,
Some(expected.clone())
);
}
let tokens = TokenUsageBreakdown {
total_tokens: 17_000,
input_tokens: 13_000,
cached_input_tokens: 1_000,
cache_write_input_tokens: 0,
output_tokens: 4_000,
reasoning_output_tokens: 0,
};
app.agents_overview.threads.remove(&selected);
app.agents_overview.request_id = Some(request_id);
app.track_agents_overview_notification(&ServerNotification::ThreadTokenUsageUpdated(
ThreadTokenUsageUpdatedNotification {
thread_id: selected.to_string(),
turn_id: "turn".into(),
token_usage: ThreadTokenUsage {
total: tokens.clone(),
last: tokens.clone(),
model_context_window: None,
},
},
));
app.apply_agents_overview_thread_refresh(
&app_server,
request_id,
Ok(AgentsOverviewThreadRefresh {
threads: HashMap::from([(selected, Some(threads[0].clone()))]),
last_messages: HashMap::new(),
recent_seed_complete: true,
}),
);
assert_eq!(
app.agents_overview.usage[&selected].tokens,
Some(tokens.clone())
);
assert!(
render_bottom_popup(&app.chat_widget, /*width*/ 96).contains("Tokens: 13K in · 4K out")
);
let mut view = app.agents_overview_view(threads.to_vec(), Some(selected));
let row = view
.rows
.iter_mut()
.find(|row| row.thread_id == selected)
.unwrap();
row.group = AgentsOverviewGroup::NeedsYou;
row.details.lines = vec![
Line::default(),
"Needs attention".into(),
"Which dependency version should I use?".into(),
"Open task to review.".into(),
];
app.chat_widget.show_bottom_pane_view(Box::new(view));
let project = test_path_display("/tmp/project");
let group = format!(
"/tmp/project 2{}",
" ".repeat(project.len().saturating_sub("/tmp/project".len()))
);
insta::assert_snapshot!(
"agents_overview_usage",
render_bottom_popup(&app.chat_widget, /*width*/ 96)
.replace(&format!("{project} 2"), &group)
.replace(&project, "/tmp/project")
.replace("fwd del", "del")
);
for notification in [
ServerNotification::ThreadReverted(codex_app_server_protocol::ThreadRevertedNotification {
thread_id: selected.to_string(),
}),
ServerNotification::ThreadClosed(ThreadClosedNotification {
thread_id: selected.to_string(),
}),
] {
app.agents_overview.usage.get_mut(&selected).unwrap().tokens = Some(tokens.clone());
app.track_agents_overview_notification(&notification);
assert_eq!(app.agents_overview.usage[&selected].tokens, None);
}
assert_eq!(
app.agents_overview.usage[&selected].estimate,
Some(expected.clone())
);
let other_usage = app.agents_overview.usage.entry(other).or_default();
other_usage.estimate = Some(expected.clone());
other_usage.tokens = Some(tokens.clone());
app.agents_overview.pending_usage = Some((thread_id, request_id));
app.finish_agents_overview_usage(thread_id, request_id, Ok(ThreadUsageOutcome::Disabled));
assert!(
app.agents_overview
.usage
.values()
.all(|usage| usage.estimate.is_none())
);
assert_eq!(
app.agents_overview.usage[&other].tokens,
Some(tokens.clone())
);
// A different selection must not retry an unavailable account-wide capability.
let view = app.agents_overview_view(threads.to_vec(), Some(other));
app.agents_overview.visible_thread_ids = view.thread_ids();
app.chat_widget.show_bottom_pane_view(Box::new(view));
app.refresh_agents_overview_usage(&app_server, tui.frame_requester());
assert!(app.agents_overview.usage_disabled);
assert_eq!(app.agents_overview.pending_usage, None);
for event in [
AppServerEvent::Lagged { skipped: 1 },
AppServerEvent::ServerNotification(Box::new(ServerNotification::AccountUpdated(
AccountUpdatedNotification {
auth_mode: None,
plan_type: None,
},
))),
] {
app.agents_overview
.usage
.entry(selected)
.or_default()
.tokens = Some(tokens.clone());
app.agents_overview.pending_usage = Some((thread_id, request_id));
app.agents_overview.usage_disabled = true;
app.handle_app_server_event(&app_server, event).await;
app.finish_agents_overview_usage(thread_id, request_id, Ok(result.clone()));
assert!(app.agents_overview.usage.is_empty());
assert_eq!(app.agents_overview.pending_usage, None);
assert!(!app.agents_overview.usage_disabled);
}
app.agents_overview.usage_disabled = true;
app.agents_overview.pending_usage = Some((thread_id, request_id));
app.app_server_target = AppServerTarget::LocalDaemon {
endpoint: crate::RemoteAppServerEndpoint::UnixSocket {
socket_path: test_path_buf("/tmp/test.sock").abs(),
},
};
assert!(app.begin_reconnect());
app.finish_agents_overview_usage(
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(expected)),
);
assert!(app.agents_overview.usage.is_empty());
assert_eq!(app.agents_overview.pending_usage, None);
assert!(!app.agents_overview.usage_disabled);
Ok(())
}
#[test]
fn incomplete_billing_groups_do_not_display_partial_token_totals() {
let estimate: ThreadUsage = serde_json::from_value(serde_json::json!({
"threadId": "thread", "estimatedUsageCreditsMicros": 0, "estimatedUsageUsdMicros": null,
"groups": [
{"estimatedUsageCreditsMicros": 0, "inputTokens": 100, "outputTokens": 10},
{"estimatedUsageCreditsMicros": 0, "inputTokens": null, "outputTokens": 20}
]
}))
.unwrap();
let mut usage = AgentsOverviewUsage::default();
usage.estimate = Some(estimate);
let text = usage_lines(&usage)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>();
assert_eq!(text, vec!["Tokens: 30 out", "Est. usage: 0 credits"]);
}

View File

@@ -480,6 +480,7 @@ impl AgentsOverviewView {
model_name(&row.thread).to_string().into(),
]),
];
lines.extend(row.details.usage_lines.clone());
if let Some(branch) = row
.thread
.git_info
@@ -491,6 +492,7 @@ impl AgentsOverviewView {
lines.push(branch.clone().into());
}
let preview = super::agents_overview_details::preview_markdown(&row.thread.preview);
let prompt_start = crate::wrapping::word_wrap_lines(lines.clone(), width).len();
lines.extend([Line::default(), Line::from("Prompt".dim())]);
let prompt = crate::markdown_render::render_markdown_text_with_width_and_cwd(
match preview.as_str() {
@@ -521,6 +523,12 @@ impl AgentsOverviewView {
);
}
let mut details = crate::wrapping::word_wrap_lines(details, width);
if !row.details.usage_lines.is_empty()
&& details.len() > usize::from(area.height).saturating_sub(lines.len())
{
// Activity and usage take precedence over repeating the original prompt.
lines.truncate(prompt_start);
}
let available = usize::from(area.height).saturating_sub(lines.len());
if details.len() > available {
details.truncate(available);

View File

@@ -78,6 +78,9 @@ impl App {
self.agents_overview.refresh_notifications.clear();
self.agents_overview.activity.clear();
self.agents_overview.last_messages.clear();
self.agents_overview.usage.clear();
self.agents_overview.pending_usage = None;
self.agents_overview.usage_disabled = false;
self.repaint_agents_overview();
self.refresh_agents_overview_threads(app_server_client);
}
@@ -227,6 +230,10 @@ impl App {
return;
}
ServerNotification::AccountUpdated(notification) => {
self.agents_overview.usage.clear();
self.agents_overview.pending_usage = None;
self.agents_overview.usage_disabled = false;
self.repaint_agents_overview();
self.chat_widget.cyber_policy_notice = Default::default();
self.rate_limit_hard_stop_generation =
self.rate_limit_hard_stop_generation.wrapping_add(1);

View File

@@ -32,7 +32,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
const TOKEN_ACTIVITY_FETCH_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(/*secs*/ 15);
const THREAD_USAGE_FETCH_TIMEOUT: std::time::Duration =
pub(super) const THREAD_USAGE_FETCH_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(/*secs*/ 65);
const RATE_LIMIT_RESET_REQUEST_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(/*secs*/ 15);
@@ -844,7 +844,7 @@ pub(super) async fn fetch_account_token_activity(
.wrap_err("account/usage/read failed in TUI")
}
async fn fetch_thread_usage(
pub(super) async fn fetch_thread_usage(
request_handle: AppServerRequestHandle,
thread_id: ThreadId,
) -> Result<ThreadUsageOutcome> {

View File

@@ -1699,6 +1699,9 @@ impl App {
} => {
self.finish_thread_usage_refresh(tui, thread_id, request_id, result)?;
}
AppEvent::AgentsOverviewUsageLoaded { thread_id, request_id, result } => {
self.finish_agents_overview_usage(thread_id, request_id, result);
}
AppEvent::CommitPendingUsageOutput => {
self.insert_pending_usage_output_if_ready(tui);
}

View File

@@ -200,6 +200,9 @@ impl App {
self.agents_overview.request_id = None;
self.agents_overview.refresh_pending = false;
self.agents_overview.refresh_notifications.clear();
self.agents_overview.pending_usage = None;
self.agents_overview.usage_disabled = false;
self.agents_overview.usage.clear();
self.agents_overview.activity.clear();
self.agents_overview.last_messages.clear();
self.reconnect.presentation = if self

View File

@@ -0,0 +1,28 @@
---
source: tui/src/app/agents_overview_usage_tests.rs
expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{project} 2\"),\n&group).replace(&project, \"/tmp/project\").replace(\"fwd del\", \"del\")"
---
Agent command center
1 need input 0 working 1 ready
────────────────────────────────────────────────────────────────────────────────────────────
/tmp/project 2 │ Task details
● Review parser Needs input │
○ Other task Ready │ Review parser
│ ● Needs input
│ Needs attention
│ Which dependency version should I use?
│ Open task to review.
│ Project
│ /tmp/project
│ Model: Unknown
│ Tokens: 13K in · 4K out
│ Est. usage: 5 credits · ~$0.20
New task
Describe a new task
enter create task ctrl+j newline esc tasks → open task

View File

@@ -726,6 +726,13 @@ pub(crate) enum AppEvent {
result: Result<ThreadUsageOutcome, String>,
},
/// Result of fetching usage for the selected dashboard task.
AgentsOverviewUsageLoaded {
thread_id: ThreadId,
request_id: Uuid,
result: Result<ThreadUsageOutcome, String>,
},
/// Fetch workspace messages for the status-line headline item.
RefreshStatusLineWorkspaceHeadline {
request_id: u64,