Show estimated thread usage in /status (#38281)

## What changed

- Extend `account/usage/read` with an optional `threadId` request and a
  backward-compatible `threadUsage` response containing estimated credits,
  optional USD cost, and model, reasoning, speed, and token breakdowns.
- Fetch thread usage for eligible Business and Enterprise plans when `/status`
  is opened, then update the rendered status card asynchronously.
- Preserve terminal scrollback when replacing a visible status-card tail, and
  append a refreshed card when the original tail can no longer be updated.

## Testing

- Add app-server coverage for authentication, canonical thread IDs,
  unavailable billing routes, and malformed IDs.
- Add TUI coverage for usage formatting, stale responses, retries, history
  updates, terminal reflow, and scrollback preservation.

GitOrigin-RevId: 49300914b3fba917c0f08e8595363e04e5ba6d37
This commit is contained in:
Felipe Coury authored and copyberry committed 2026-08-13 00:30:08 +00:00
1 parent 27a98dde4d
commit f1a1fce26a
55 files changed
+2903 -60

No files matched your search

@@ -246,6 +246,7 @@ impl ChatWidget {
next_status_line_workspace_headline_request_id: 0,
status_line_workspace_headline_last_requested_at: None,
status_line_workspace_messages_disabled: false,
thread_usage: thread_usage::ThreadUsageState::default(),
current_goal_status_indicator: None,
current_goal_status: None,
external_editor_state: ExternalEditorState::Closed,
@@ -24,6 +24,7 @@ impl ChatWidget {
.set_queue_submissions(/*queue_submissions*/ false);
if previous_thread_id != self.thread_id {
self.review.recent_auto_review_denials = RecentAutoReviewDenials::default();
self.clear_thread_usage_state();
}
self.refresh_plan_mode_nudge();
self.turn_lifecycle.reset_thread();
+1
View File
@@ -236,6 +236,7 @@ impl ChatWidget {
self.status_line_workspace_headline_pending_request_id = None;
self.status_line_workspace_headline_last_requested_at = None;
self.status_line_workspace_messages_disabled = false;
self.clear_thread_usage_state();
self.status_account_display = status_account_display;
self.plan_type = plan_type;
self.has_chatgpt_account = has_chatgpt_account;
@@ -0,0 +1,10 @@
---
source: tui/src/chatwidget/tests/status_command_tests.rs
expression: thread_usage
---
│ Thread usage: 50 credits · ~$1.82 │
│ Models: GPT-5.4 80%, GPT-5 Mini 20% │
│ Reasoning: Medium 20%, High 80% │
│ Speed: Fast mode 80%, Standard 20% │
│ Billed tokens: 120 input (25 cached) + 50 output │
╰──────────────────────────────────────────────────────────────────────╯
@@ -0,0 +1,10 @@
---
source: tui/src/chatwidget/tests/status_command_tests.rs
expression: thread_usage
---
│ Thread usage: 50 credits │
│ Models: GPT-5.4 100% │
│ Reasoning: High 100% │
│ Speed: Fast mode 100% │
│ Billed tokens: 100 input (20 cached) + 40 output │
╰──────────────────────────────────────────────────────────────────────╯
+10 -2
View File
@@ -245,9 +245,17 @@ impl ChatWidget {
refreshing_rate_limits,
);
if let Some(request_id) = request_id {
self.refreshing_status_outputs.push((request_id, handle));
self.refreshing_status_outputs
.push((request_id, handle.clone()));
}
if self.thread_usage_is_available() {
handle.reserve_thread_usage_label_width();
handle.set_thread_usage(self.estimated_thread_usage().cloned());
self.add_to_history(cell);
self.request_thread_usage_for_status(handle);
} else {
self.add_to_history(cell);
}
self.add_to_history(cell);
}
pub(crate) fn finish_status_rate_limit_refresh(
@@ -1,5 +1,8 @@
use super::*;
use crate::chatwidget::ThreadUsageOutcome;
use assert_matches::assert_matches;
use codex_app_server_protocol::ThreadUsage;
use codex_app_server_protocol::ThreadUsageBreakdownGroup;
use codex_utils_path_uri::PathUri;
#[tokio::test]
@@ -197,3 +200,417 @@ async fn account_update_rejects_stale_status_rate_limit_snapshots() {
assert!(chat.rate_limit_snapshots_by_limit_id.is_empty());
}
#[tokio::test]
async fn status_command_renders_immediately_and_updates_first_card_without_status_line() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let thread_id =
ThreadId::from_string("019fc8ab-1fb2-7000-8000-000000000001").expect("valid thread id");
chat.thread_id = Some(thread_id);
chat.plan_type = Some(PlanType::Business);
chat.dispatch_command(SlashCommand::Status);
let first_cell = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
event => panic!("expected immediate /status output, got {event:?}"),
};
let initial = lines_to_single_string(&first_cell.display_lines(/*width*/ 90));
assert!(!initial.contains("Thread usage"));
let request_id = match rx.try_recv() {
Ok(AppEvent::RefreshThreadUsage {
thread_id: requested_thread_id,
request_id,
}) => {
assert_eq!(requested_thread_id, thread_id);
request_id
}
event => panic!("expected /status to request thread usage, got {event:?}"),
};
drain_insert_history(&mut rx);
assert!(chat.finish_thread_usage_refresh(
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: Some(1_820_000),
groups: vec![
ThreadUsageBreakdownGroup {
model: Some("gpt-5.4".to_string()),
reasoning_effort: Some("high".to_string()),
speed: Some("fast".to_string()),
estimated_usage_credits_micros: 40_000_000,
net_new_input_tokens: Some(80),
cached_input_tokens: Some(20),
input_tokens: Some(100),
output_tokens: Some(40),
total_tokens: Some(140),
},
ThreadUsageBreakdownGroup {
model: Some("gpt-5-mini".to_string()),
reasoning_effort: Some("medium".to_string()),
speed: Some("standard".to_string()),
estimated_usage_credits_micros: 10_000_000,
net_new_input_tokens: Some(15),
cached_input_tokens: Some(5),
input_tokens: Some(20),
output_tokens: Some(10),
total_tokens: Some(30),
},
],
})),
));
let settled = lines_to_single_string(&first_cell.display_lines(/*width*/ 90));
assert!(settled.contains("50 credits · ~$1.82"));
assert!(settled.contains("GPT-5.4 80%, GPT-5 Mini 20%"));
assert!(drain_insert_history(&mut rx).is_empty());
chat.dispatch_command(SlashCommand::Status);
let cell = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
event => panic!("expected /status output with cached thread usage, got {event:?}"),
};
let rendered =
normalize_snapshot_paths(lines_to_single_string(&cell.display_lines(/*width*/ 90)));
assert!(rendered.contains("50 credits · ~$1.82"));
assert!(rendered.contains("GPT-5.4 80%, GPT-5 Mini 20%"));
assert!(rendered.contains("Medium 20%, High 80%"));
assert!(rendered.contains("Fast mode 80%, Standard 20%"));
assert!(rendered.contains("120 input (25 cached) + 50 output"));
assert!(
rendered.find("Limits:") < rendered.find("Thread usage:"),
"account limits should appear above the thread usage group: {rendered}"
);
let thread_usage = rendered
.lines()
.skip_while(|line| !line.contains("Thread usage:"))
.collect::<Vec<_>>()
.join("\n");
assert_chatwidget_snapshot!("status_command_estimated_thread_usage", thread_usage);
}
#[tokio::test]
async fn status_command_omits_thread_usage_for_non_cbp_enterprise_plans() {
for plan_type in [
Some(PlanType::Enterprise),
Some(PlanType::Ent26),
Some(PlanType::Pro),
Some(PlanType::Edu),
Some(PlanType::SelfServeBusinessUsageBased),
Some(PlanType::Unknown),
None,
] {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.thread_id = Some(ThreadId::new());
chat.plan_type = plan_type;
chat.dispatch_command(SlashCommand::Status);
let rendered = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => {
lines_to_single_string(&cell.display_lines(/*width*/ 90))
}
event => panic!("expected immediate /status output, got {event:?}"),
};
assert!(!rendered.contains("Thread usage"));
assert!(
!std::iter::from_fn(|| rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::RefreshThreadUsage { .. })),
"unsupported plan {plan_type:?} must not request thread usage",
);
}
}
#[tokio::test]
async fn status_command_stays_visible_and_updates_after_thread_usage_retry() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.plan_type = Some(PlanType::Business);
chat.dispatch_command(SlashCommand::Status);
let cell = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
event => panic!("expected immediate /status output, got {event:?}"),
};
assert!(!lines_to_single_string(&cell.display_lines(/*width*/ 90)).contains("Thread usage"));
let request_id = match rx.try_recv() {
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
event => panic!("expected thread usage request, got {event:?}"),
};
while rx.try_recv().is_ok() {}
assert!(chat.finish_thread_usage_refresh(
thread_id,
request_id,
Err("thread usage temporarily unavailable".to_string()),
));
assert!(drain_insert_history(&mut rx).is_empty());
chat.dispatch_command(SlashCommand::Status);
let retry_cell = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
event => panic!("expected another immediate /status output, got {event:?}"),
};
let retry_request_id = match rx.try_recv() {
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
event => panic!("expected thread usage retry, got {event:?}"),
};
assert!(chat.finish_thread_usage_refresh(
thread_id,
retry_request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: Some(1_820_000),
groups: Vec::new(),
})),
));
for status_cell in [&cell, &retry_cell] {
assert!(
lines_to_single_string(&status_cell.display_lines(/*width*/ 90))
.contains("50 credits · ~$1.82")
);
}
}
#[tokio::test]
async fn status_command_renders_credits_and_breakdowns_without_usd_estimate() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.plan_type = Some(PlanType::Business);
chat.dispatch_command(SlashCommand::Status);
let first_cell = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
event => panic!("expected immediate /status output, got {event:?}"),
};
let request_id = match rx.try_recv() {
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
event => panic!("expected thread usage request, got {event:?}"),
};
drain_insert_history(&mut rx);
assert!(chat.finish_thread_usage_refresh(
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: None,
groups: vec![ThreadUsageBreakdownGroup {
model: Some("gpt-5.4".to_string()),
reasoning_effort: Some("high".to_string()),
speed: Some("fast".to_string()),
estimated_usage_credits_micros: 50_000_000,
net_new_input_tokens: Some(80),
cached_input_tokens: Some(20),
input_tokens: Some(100),
output_tokens: Some(40),
total_tokens: Some(140),
}],
})),
));
let first_rendered = lines_to_single_string(&first_cell.display_lines(/*width*/ 90));
assert!(first_rendered.contains("50 credits"));
assert!(first_rendered.contains("GPT-5.4 100%"));
assert!(!first_rendered.contains("~$"));
drain_insert_history(&mut rx);
chat.dispatch_command(SlashCommand::Status);
let rendered = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => {
lines_to_single_string(&cell.display_lines(/*width*/ 90))
}
event => panic!("expected cached credits-only /status output, got {event:?}"),
};
assert!(rendered.contains("50 credits"));
assert!(rendered.contains("GPT-5.4 100%"));
assert!(rendered.contains("High 100%"));
assert!(rendered.contains("Fast mode 100%"));
assert!(rendered.contains("100 input (20 cached) + 40 output"));
assert!(!rendered.contains("~$"));
let thread_usage = rendered
.lines()
.skip_while(|line| !line.contains("Thread usage:"))
.collect::<Vec<_>>()
.join("\n");
assert_chatwidget_snapshot!(
"status_command_estimated_thread_usage_without_usd",
thread_usage
);
}
#[tokio::test]
async fn status_command_drops_stale_usd_when_updated_usage_has_only_credits() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.plan_type = Some(PlanType::Business);
chat.dispatch_command(SlashCommand::Status);
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
let request_id = match rx.try_recv() {
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
event => panic!("expected initial thread usage request, got {event:?}"),
};
drain_insert_history(&mut rx);
assert!(chat.finish_thread_usage_refresh(
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 40_000_000,
estimated_usage_usd_micros: Some(1_820_000),
groups: Vec::new(),
})),
));
drain_insert_history(&mut rx);
chat.dispatch_command(SlashCommand::Status);
let cached = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => {
lines_to_single_string(&cell.display_lines(/*width*/ 90))
}
event => panic!("expected cached /status output, got {event:?}"),
};
assert!(cached.contains("40 credits · ~$1.82"));
let request_id = match rx.try_recv() {
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
event => panic!("expected refreshed thread usage request, got {event:?}"),
};
drain_insert_history(&mut rx);
assert!(chat.finish_thread_usage_refresh(
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: None,
groups: Vec::new(),
})),
));
drain_insert_history(&mut rx);
chat.dispatch_command(SlashCommand::Status);
let refreshed = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => {
lines_to_single_string(&cell.display_lines(/*width*/ 90))
}
event => panic!("expected credits-only /status output, got {event:?}"),
};
assert!(refreshed.contains("50 credits"));
assert!(!refreshed.contains("~$1.82"));
}
#[tokio::test]
async fn status_command_requests_thread_usage_for_remote_connection_metadata() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.update_account_state(
/*status_account_display*/ None,
Some(PlanType::Business),
/*has_chatgpt_account*/ false,
/*has_codex_backend_auth*/ true,
);
chat.remote_connection = Some(crate::status::remote_connection::RemoteConnectionStatus {
address: "wss://remote.example.com".to_string(),
version: "v1.0.0".to_string(),
});
chat.dispatch_command(SlashCommand::Status);
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
assert_matches!(rx.try_recv(), Ok(AppEvent::RefreshThreadUsage { .. }));
}
#[tokio::test]
async fn status_command_requests_thread_usage_with_backend_only_authentication() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.update_account_state(
/*status_account_display*/ None,
Some(PlanType::Business),
/*has_chatgpt_account*/ false,
/*has_codex_backend_auth*/ true,
);
assert!(!chat.has_chatgpt_account());
assert!(chat.has_codex_backend_auth());
chat.dispatch_command(SlashCommand::Status);
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
assert_matches!(rx.try_recv(), Ok(AppEvent::RefreshThreadUsage { .. }));
}
#[tokio::test]
async fn status_command_remains_visible_when_account_changes_during_usage_refresh() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.plan_type = Some(PlanType::Business);
chat.dispatch_command(SlashCommand::Status);
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
let request_id = match rx.try_recv() {
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
event => panic!("expected thread usage request, got {event:?}"),
};
drain_insert_history(&mut rx);
chat.update_account_state(
/*status_account_display*/ None, /*plan_type*/ None,
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false,
);
assert!(!chat.finish_thread_usage_refresh(
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: Some(1_820_000),
groups: Vec::new(),
})),
));
assert!(drain_insert_history(&mut rx).is_empty());
}
#[tokio::test]
async fn status_command_remains_visible_when_thread_changes_during_usage_refresh() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.plan_type = Some(PlanType::Business);
chat.dispatch_command(SlashCommand::Status);
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
let request_id = match rx.try_recv() {
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
event => panic!("expected thread usage request, got {event:?}"),
};
drain_insert_history(&mut rx);
chat.thread_id = Some(ThreadId::new());
chat.clear_thread_usage_state();
assert!(!chat.finish_thread_usage_refresh(
thread_id,
request_id,
Ok(ThreadUsageOutcome::Available(ThreadUsage {
thread_id: thread_id.to_string(),
estimated_usage_credits_micros: 50_000_000,
estimated_usage_usd_micros: Some(1_820_000),
groups: Vec::new(),
})),
));
assert!(drain_insert_history(&mut rx).is_empty());
}
+135
View File
@@ -0,0 +1,135 @@
//! Demand-driven estimated-cost state for the currently visible enterprise thread.
use super::AppEvent;
use super::ChatWidget;
use super::PlanType;
use super::ThreadId;
use crate::status::StatusHistoryHandle;
use codex_app_server_protocol::ThreadUsage;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ThreadUsageOutcome {
Available(ThreadUsage),
Disabled,
}
#[derive(Debug, Default)]
pub(super) struct ThreadUsageState {
thread_id: Option<ThreadId>,
estimate: Option<ThreadUsage>,
pending_request_id: Option<u64>,
next_request_id: u64,
feature_disabled: bool,
status_history_handles: Vec<StatusHistoryHandle>,
}
impl ChatWidget {
pub(super) fn clear_thread_usage_state(&mut self) {
let next_request_id = self.thread_usage.next_request_id;
self.thread_usage = ThreadUsageState {
next_request_id,
..ThreadUsageState::default()
};
}
pub(super) fn request_thread_usage_for_status(&mut self, handle: StatusHistoryHandle) {
if !self.thread_usage_is_available() {
return;
}
let Some(thread_id) = self.thread_id else {
return;
};
if self.thread_usage.thread_id != Some(thread_id) {
self.clear_thread_usage_state();
self.thread_usage.thread_id = Some(thread_id);
}
self.thread_usage.status_history_handles.push(handle);
self.request_thread_usage();
}
pub(crate) fn finish_thread_usage_refresh(
&mut self,
thread_id: ThreadId,
request_id: u64,
result: Result<ThreadUsageOutcome, String>,
) -> bool {
if self.thread_id != Some(thread_id)
|| self.thread_usage.thread_id != Some(thread_id)
|| self.thread_usage.pending_request_id != Some(request_id)
{
return false;
}
self.thread_usage.pending_request_id = None;
let mut usage_updated = false;
match result {
Ok(ThreadUsageOutcome::Disabled) => {
self.thread_usage.feature_disabled = true;
self.thread_usage.estimate = None;
usage_updated = true;
}
Ok(ThreadUsageOutcome::Available(usage))
if usage.thread_id != thread_id.to_string() =>
{
tracing::warn!(
requested_thread_id = %thread_id,
returned_thread_id = %usage.thread_id,
"thread usage response referred to another thread"
);
}
Ok(ThreadUsageOutcome::Available(usage)) => {
self.thread_usage.estimate = Some(usage);
usage_updated = true;
}
Err(err) => {
tracing::debug!(error = %err, "failed to fetch estimated thread usage");
}
}
if usage_updated && !self.thread_usage.status_history_handles.is_empty() {
for handle in self.thread_usage.status_history_handles.drain(..) {
handle.set_thread_usage(self.thread_usage.estimate.clone());
}
}
self.request_redraw();
true
}
pub(super) fn estimated_thread_usage(&self) -> Option<&ThreadUsage> {
self.thread_usage.estimate.as_ref()
}
fn request_thread_usage(&mut self) {
let Some(thread_id) = self.thread_id else {
return;
};
if !self.thread_usage_is_available() || self.thread_usage.pending_request_id.is_some() {
return;
}
let request_id = self.thread_usage.next_request_id;
self.thread_usage.next_request_id =
self.thread_usage.next_request_id.wrapping_add(/*rhs*/ 1);
self.thread_usage.pending_request_id = Some(request_id);
self.thread_usage.thread_id = Some(thread_id);
self.app_event_tx.send(AppEvent::RefreshThreadUsage {
thread_id,
request_id,
});
}
pub(super) fn thread_usage_is_available(&self) -> bool {
self.has_codex_backend_auth
&& !self.thread_usage.feature_disabled
&& self.thread_id.is_some()
&& matches!(
self.plan_type,
Some(
PlanType::Business
| PlanType::EnterpriseCbpUsageBased
| PlanType::EnterpriseCbpAutomation
)
)
}
}
@@ -212,6 +212,7 @@ fn summary_snapshot_left_aligns_and_splits_when_needed() {
longest_streak_days: Some(54),
},
daily_usage_buckets: None,
thread_usage: None,
};
let rendered = |width| {
summary_lines(&response, graph_width(width))
@@ -21,6 +21,7 @@ fn loaded_state_freezes_chart_anchor_date_at_completion() {
longest_streak_days: None,
},
daily_usage_buckets: None,
thread_usage: None,
}),
today,
);