mirror of
https://github.com/openai/codex.git
synced 2026-09-16 12:13:30 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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(¬ification);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
199
codex-rs/tui/src/app/agents_overview_usage.rs
Normal file
199
codex-rs/tui/src/app/agents_overview_usage.rs
Normal 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
|
||||
}
|
||||
236
codex-rs/tui/src/app/agents_overview_usage_tests.rs
Normal file
236
codex-rs/tui/src/app/agents_overview_usage_tests.rs
Normal 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(¬ification);
|
||||
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"]);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user