From cbfa50b9baffe28a6c444624358bca616cff0a22 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 22 May 2026 15:42:46 -0300 Subject: [PATCH 1/9] feat(tui): add usage report command --- codex-rs/tui/src/app/background_requests.rs | 33 ++ codex-rs/tui/src/app/event_dispatch.rs | 6 + codex-rs/tui/src/app_event.rs | 10 + .../tui/src/bottom_pane/slash_commands.rs | 1 + codex-rs/tui/src/chatwidget.rs | 3 + codex-rs/tui/src/chatwidget/constructor.rs | 2 + codex-rs/tui/src/chatwidget/slash_dispatch.rs | 15 + ..._tui__chatwidget__tests__usage_output.snap | 30 + ...get__tests__usage_output_unattributed.snap | 14 + ...hatwidget__tests__usage_output_weekly.snap | 21 + codex-rs/tui/src/chatwidget/tests.rs | 6 + .../chatwidget/tests/popups_and_settings.rs | 210 +++++++ codex-rs/tui/src/chatwidget/usage.rs | 531 ++++++++++++++++++ codex-rs/tui/src/history_cell/notices.rs | 11 +- codex-rs/tui/src/slash_command.rs | 7 + 15 files changed, 896 insertions(+), 4 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_unattributed.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_weekly.snap create mode 100644 codex-rs/tui/src/chatwidget/usage.rs diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index d90f511d6f9..f64e7adaf03 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -15,6 +15,9 @@ use codex_app_server_protocol::MarketplaceRemoveParams; use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::MarketplaceUpgradeParams; use codex_app_server_protocol::MarketplaceUpgradeResponse; +use codex_app_server_protocol::UsageRange; +use codex_app_server_protocol::UsageReadParams; +use codex_app_server_protocol::UsageReadResponse; use codex_app_server_protocol::RequestId; @@ -143,6 +146,22 @@ impl App { }); } + pub(super) fn fetch_usage( + &mut self, + app_server: &AppServerSession, + request_id: u64, + range: UsageRange, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = fetch_usage(request_handle, range) + .await + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::UsageLoaded { request_id, result }); + }); + } + pub(super) fn fetch_hooks_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) { let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); @@ -597,6 +616,20 @@ impl App { } } +async fn fetch_usage( + request_handle: AppServerRequestHandle, + range: UsageRange, +) -> Result { + let request_id = RequestId::String(format!("usage-read-{}", uuid::Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::UsageRead { + request_id, + params: UsageReadParams { range }, + }) + .await + .map_err(Into::into) +} + pub(super) async fn fetch_all_mcp_server_statuses( request_handle: AppServerRequestHandle, detail: McpServerStatusDetail, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 5af2c492335..a511fe66e26 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -436,6 +436,12 @@ impl App { AppEvent::FetchPluginsList { cwd } => { self.fetch_plugins_list(app_server, cwd); } + AppEvent::FetchUsage { request_id, range } => { + self.fetch_usage(app_server, request_id, range); + } + AppEvent::UsageLoaded { request_id, result } => { + self.chat_widget.on_usage_loaded(request_id, result); + } AppEvent::FetchHooksList { cwd } => { self.fetch_hooks_list(app_server, cwd); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index b37aee64e6d..c583084b2b2 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -371,6 +371,16 @@ pub(crate) enum AppEvent { cwd: PathBuf, }, + FetchUsage { + request_id: u64, + range: codex_app_server_protocol::UsageRange, + }, + + UsageLoaded { + request_id: u64, + result: Result, + }, + /// Fetch lifecycle hook inventory for the provided working directory. FetchHooksList { cwd: PathBuf, diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index e907b6c5e55..37790c84cd5 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -297,6 +297,7 @@ mod tests { SlashCommand::Diff, SlashCommand::Mention, SlashCommand::Status, + SlashCommand::Usage, ] ); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d3e5cf68ef8..73b55b9ef7a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -365,6 +365,7 @@ use self::skills::find_app_mentions; use self::skills::find_skill_mentions_with_tool_mentions; use self::skills::is_app_mentionable; mod plugins; +mod usage; use self::plugins::PluginInstallAuthFlowState; use self::plugins::PluginListFetchState; use self::plugins::PluginsCacheState; @@ -544,6 +545,8 @@ pub(crate) struct ChatWidget { rate_limit_snapshots_by_limit_id: BTreeMap, refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>, next_status_refresh_request_id: u64, + next_usage_request_id: u64, + active_usage_request_id: Option, plan_type: Option, codex_rate_limit_reached_type: Option, rate_limit_warnings: RateLimitWarningState, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 623016f59f0..fb4ee8b4580 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -124,6 +124,8 @@ impl ChatWidget { rate_limit_snapshots_by_limit_id: BTreeMap::new(), refreshing_status_outputs: Vec::new(), next_status_refresh_request_id: 0, + next_usage_request_id: 0, + active_usage_request_id: None, plan_type: initial_plan_type, codex_rate_limit_reached_type: None, rate_limit_warnings: RateLimitWarningState::default(), diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index b479718304e..70352b0f57f 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -36,6 +36,13 @@ const GOAL_USAGE: &str = "Usage: /goal "; const GOAL_USAGE_HINT: &str = "Example: /goal improve benchmark coverage"; const RAW_USAGE: &str = "Usage: /raw [on|off]"; +fn usage_range_from_arg(arg: &str) -> Option { + match arg.to_ascii_lowercase().as_str() { + "week" | "weekly" => Some(codex_app_server_protocol::UsageRange::Week), + _ => None, + } +} + impl ChatWidget { /// Dispatch a bare slash command and record its staged local-history entry. /// @@ -385,6 +392,9 @@ impl ChatWidget { ); } } + SlashCommand::Usage => { + self.add_usage_output(); + } SlashCommand::Ide => { self.handle_ide_command(); } @@ -617,6 +627,10 @@ impl ChatWidget { } _ => self.add_error_message(RAW_USAGE.to_string()), }, + SlashCommand::Usage => match usage_range_from_arg(trimmed) { + Some(range) => self.add_usage_output_for_range(range), + None => self.add_error_message("Usage: /usage [week|weekly]".to_string()), + }, SlashCommand::Rename if !trimmed.is_empty() => { if !self.ensure_thread_rename_allowed() { return; @@ -932,6 +946,7 @@ impl ChatWidget { match cmd { SlashCommand::Ide | SlashCommand::Status + | SlashCommand::Usage | SlashCommand::DebugConfig | SlashCommand::Ps | SlashCommand::Stop diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output.snap new file mode 100644 index 00000000000..907e95882af --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output.snap @@ -0,0 +1,30 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: rendered +--- +/usage + +╭───────────────────────────────────────────────────╮ +│ Daily usage by token share │ +│ Percent of consumed tokens in this selected range │ +│ (/usage week for weekly) │ +│ │ +│ 11% of consumed tokens came from app "testmcp" │ +│ Tool results stay in context until compaction; │ +│ compact or disable sources you do not need. │ +│ │ +│ Skills │ +│ ├─ /tmux ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 8% │ +│ └─ /babysit ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 6% │ +│ │ +│ Subagents │ +│ ├─ babysit ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 13% │ +│ └─ code-review ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 9% │ +│ │ +│ Agent tasks │ +│ ├─ guardian ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 17% │ +│ └─ review ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 5% │ +│ │ +│ Apps │ +│ └─ testmcp ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 11% │ +╰───────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_unattributed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_unattributed.snap new file mode 100644 index 00000000000..941b669bd57 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_unattributed.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: rendered +--- +/usage + +╭───────────────────────────────────────────────────╮ +│ Daily usage by token share │ +│ Percent of consumed tokens in this selected range │ +│ (/usage week for weekly) │ +│ │ +│ No attributed skills, subagents, agent tasks, │ +│ apps, MCP servers, or plugins in this range. │ +╰───────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_weekly.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_weekly.snap new file mode 100644 index 00000000000..2c3827bf643 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_weekly.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: rendered +--- +/usage week + +╭────────────────────────────────────────────────────────╮ +│ Weekly usage by token share, Nov 7 to Nov 14 │ +│ Percent of consumed tokens in this selected range │ +│ │ +│ 17% of consumed tokens came from agent task "guardian" │ +│ │ +│ Skills │ +│ └─ /tmux ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 8% │ +│ │ +│ Subagents │ +│ └─ default ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 13% │ +│ │ +│ Agent tasks │ +│ └─ guardian ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 17% │ +╰────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 5d27e0ddc59..d5536eac58a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -116,6 +116,12 @@ pub(super) use codex_app_server_protocol::TurnCompletedNotification; pub(super) use codex_app_server_protocol::TurnError as AppServerTurnError; pub(super) use codex_app_server_protocol::TurnStartedNotification; pub(super) use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; +pub(super) use codex_app_server_protocol::UsageContributorKind; +pub(super) use codex_app_server_protocol::UsageEntry; +pub(super) use codex_app_server_protocol::UsageHeadline; +pub(super) use codex_app_server_protocol::UsageRange; +pub(super) use codex_app_server_protocol::UsageReadResponse; +pub(super) use codex_app_server_protocol::UsageReport; pub(super) use codex_app_server_protocol::UserInput; pub(super) use codex_app_server_protocol::UserInput as AppServerUserInput; pub(super) use codex_app_server_protocol::WarningNotification; diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index ade74b8908f..705ff4cb816 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -111,6 +111,201 @@ async fn plugins_popup_loading_state_snapshot() { assert_chatwidget_snapshot!("plugins_popup_loading_state", popup); } +#[tokio::test] +async fn usage_output_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.add_usage_output(); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::FetchUsage { + request_id: 0, + range: UsageRange::Day, + }) + ); + chat.on_usage_loaded( + /*request_id*/ 0, + Ok(UsageReadResponse { + report: UsageReport { + range: UsageRange::Day, + generated_at: 1_700_000_000, + tracked_from: Some(/*tracked_from*/ 1_699_999_000), + total_tokens: 100, + headline: Some(UsageHeadline { + entry: usage_entry( + UsageContributorKind::App, + "testmcp", + "testmcp", + /*percent_of_usage*/ 11, + ), + note: Some( + "Tool results stay in context until compaction; compact or disable sources you do not need." + .to_string(), + ), + }), + skills: vec![ + usage_entry( + UsageContributorKind::Skill, + "/skills/tmux", + "/tmux", + /*percent_of_usage*/ 8, + ), + usage_entry( + UsageContributorKind::Skill, + "/skills/babysit", + "/babysit", + /*percent_of_usage*/ 6, + ), + ], + subagents: vec![ + usage_entry( + UsageContributorKind::Subagent, + "babysit", + "babysit", + /*percent_of_usage*/ 13, + ), + usage_entry( + UsageContributorKind::Subagent, + "code-review", + "code-review", + /*percent_of_usage*/ 9, + ), + ], + agent_tasks: vec![ + usage_entry( + UsageContributorKind::AgentTask, + "guardian", + "guardian", + /*percent_of_usage*/ 17, + ), + usage_entry( + UsageContributorKind::AgentTask, + "review", + "review", + /*percent_of_usage*/ 5, + ), + ], + apps: vec![usage_entry( + UsageContributorKind::App, + "testmcp", + "testmcp", + /*percent_of_usage*/ 11, + )], + mcp_servers: Vec::new(), + plugins: Vec::new(), + }, + }), + ); + + let rendered = drain_insert_history(&mut rx) + .into_iter() + .map(|lines| lines_to_single_string(&lines)) + .collect::>() + .join("\n\n"); + assert_chatwidget_snapshot!("usage_output", rendered); +} + +#[tokio::test] +async fn usage_output_reports_unattributed_usage() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.add_usage_output(); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::FetchUsage { + request_id: 0, + range: UsageRange::Day, + }) + ); + chat.on_usage_loaded( + /*request_id*/ 0, + Ok(UsageReadResponse { + report: UsageReport { + range: UsageRange::Day, + generated_at: 1_700_000_000, + tracked_from: Some(/*tracked_from*/ 1_699_999_000), + total_tokens: 100, + headline: None, + skills: Vec::new(), + subagents: Vec::new(), + agent_tasks: Vec::new(), + apps: Vec::new(), + mcp_servers: Vec::new(), + plugins: Vec::new(), + }, + }), + ); + + let rendered = drain_insert_history(&mut rx) + .into_iter() + .map(|lines| lines_to_single_string(&lines)) + .collect::>() + .join("\n\n"); + assert_chatwidget_snapshot!("usage_output_unattributed", rendered); +} + +#[tokio::test] +async fn usage_output_weekly_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.dispatch_command_with_args(SlashCommand::Usage, "week".to_string(), Vec::new()); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::FetchUsage { + request_id: 0, + range: UsageRange::Week, + }) + ); + chat.on_usage_loaded( + /*request_id*/ 0, + Ok(UsageReadResponse { + report: UsageReport { + range: UsageRange::Week, + generated_at: 1_700_000_000, + tracked_from: Some(/*tracked_from*/ 1_699_395_200), + total_tokens: 100, + headline: Some(UsageHeadline { + entry: usage_entry( + UsageContributorKind::AgentTask, + "guardian", + "guardian", + /*percent_of_usage*/ 17, + ), + note: None, + }), + skills: vec![usage_entry( + UsageContributorKind::Skill, + "/skills/tmux", + "/tmux", + /*percent_of_usage*/ 8, + )], + subagents: vec![usage_entry( + UsageContributorKind::Subagent, + "default", + "default", + /*percent_of_usage*/ 13, + )], + agent_tasks: vec![usage_entry( + UsageContributorKind::AgentTask, + "guardian", + "guardian", + /*percent_of_usage*/ 17, + )], + apps: Vec::new(), + mcp_servers: Vec::new(), + plugins: Vec::new(), + }, + }), + ); + + let rendered = drain_insert_history(&mut rx) + .into_iter() + .map(|lines| lines_to_single_string(&lines)) + .collect::>() + .join("\n\n"); + assert_chatwidget_snapshot!("usage_output_weekly", rendered); +} + #[tokio::test] async fn marketplace_upgrade_loading_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -131,6 +326,21 @@ async fn marketplace_upgrade_loading_popup_snapshot() { ); } +fn usage_entry( + kind: UsageContributorKind, + id: &str, + label: &str, + percent_of_usage: u8, +) -> UsageEntry { + UsageEntry { + kind, + id: id.to_string(), + label: label.to_string(), + attributed_tokens: i64::from(percent_of_usage), + percent_of_usage, + } +} + #[tokio::test] async fn marketplace_upgrade_failure_includes_backend_messages_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/usage.rs b/codex-rs/tui/src/chatwidget/usage.rs new file mode 100644 index 00000000000..adbdb30ee28 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -0,0 +1,531 @@ +use super::ChatWidget; +use crate::app_event::AppEvent; +use crate::color::blend; +use crate::color::is_light; +use crate::history_cell::CompositeHistoryCell; +use crate::history_cell::HistoryCell; +use crate::history_cell::PlainHistoryCell; +use crate::history_cell::plain_lines; +use crate::history_cell::with_border_with_inner_width; +use crate::style::accent_style; +use crate::terminal_palette::best_color; +use crate::terminal_palette::default_bg; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; +use chrono::DateTime; +use chrono::Utc; +use codex_app_server_protocol::UsageContributorKind; +use codex_app_server_protocol::UsageEntry; +use codex_app_server_protocol::UsageRange; +use codex_app_server_protocol::UsageReadResponse; +use codex_app_server_protocol::UsageReport; +use ratatui::prelude::*; +use ratatui::style::Stylize; +use unicode_width::UnicodeWidthChar; +use unicode_width::UnicodeWidthStr; + +const USAGE_CARD_MAX_INNER_WIDTH: usize = 72; +const USAGE_BAR_WIDTH: usize = 20; +const USAGE_BAR_MIN_LABEL_WIDTH: usize = 8; +const USAGE_BAR_GLYPH: &str = "▄"; +const USAGE_TITLE: &str = "Usage by token share"; +const USAGE_SUBTITLE: &str = "Percent of consumed tokens in this selected range"; + +impl ChatWidget { + pub(crate) fn add_usage_output(&mut self) { + self.add_usage_output_for_range(UsageRange::Day); + } + + pub(crate) fn add_usage_output_for_range(&mut self, range: UsageRange) { + self.request_usage(range); + } + + fn request_usage(&mut self, range: UsageRange) { + let request_id = self.next_usage_request_id; + self.next_usage_request_id = self.next_usage_request_id.saturating_add(/*rhs*/ 1); + self.active_usage_request_id = Some(request_id); + self.app_event_tx + .send(AppEvent::FetchUsage { request_id, range }); + } + + pub(crate) fn on_usage_loaded( + &mut self, + request_id: u64, + result: Result, + ) { + if self.active_usage_request_id != Some(request_id) { + return; + } + self.active_usage_request_id = None; + let cell = match result { + Ok(response) => new_usage_output(response.report), + Err(err) => new_usage_error_output(err), + }; + self.add_to_history(cell); + } +} + +#[derive(Debug)] +struct UsageHistoryCell { + report: UsageReport, +} + +#[derive(Debug)] +struct UsageErrorHistoryCell { + error: String, +} + +fn new_usage_output(report: UsageReport) -> CompositeHistoryCell { + let command = PlainHistoryCell::new(vec![usage_command_label(report.range).magenta().into()]); + CompositeHistoryCell::new(vec![ + Box::new(command), + Box::new(UsageHistoryCell { report }), + ]) +} + +fn new_usage_error_output(error: String) -> CompositeHistoryCell { + let command = PlainHistoryCell::new(vec!["/usage".magenta().into()]); + CompositeHistoryCell::new(vec![ + Box::new(command), + Box::new(UsageErrorHistoryCell { error }), + ]) +} + +impl HistoryCell for UsageHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + usage_report_lines(&self.report, width) + } + + fn raw_lines(&self) -> Vec> { + plain_lines(self.display_lines(u16::MAX)) + } +} + +impl HistoryCell for UsageErrorHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let Some(available_width) = usage_card_available_width(width) else { + return Vec::new(); + }; + let lines = vec![ + Line::from(USAGE_TITLE.bold()), + Line::from(USAGE_SUBTITLE.dim()), + Line::default(), + Line::from(format!(" Failed to load usage: {}", self.error)), + ]; + let inner_width = lines + .iter() + .map(line_display_width) + .max() + .unwrap_or(0) + .min(available_width); + with_border_with_inner_width(lines, inner_width) + } + + fn raw_lines(&self) -> Vec> { + plain_lines(self.display_lines(u16::MAX)) + } +} + +fn usage_report_lines(report: &UsageReport, width: u16) -> Vec> { + let Some(available_width) = usage_card_available_width(width) else { + return Vec::new(); + }; + let sections = [ + ("Skills", report.skills.as_slice()), + ("Subagents", report.subagents.as_slice()), + ("Agent tasks", report.agent_tasks.as_slice()), + ("Apps", report.apps.as_slice()), + ("MCP servers", report.mcp_servers.as_slice()), + ("Plugins", report.plugins.as_slice()), + ]; + let label_column_width = usage_label_column_width(§ions); + let inner_width = usage_content_width(report, §ions, label_column_width, available_width); + let mut lines = usage_header_lines(report, inner_width); + if let Some(headline) = report.headline.as_ref() { + lines.push(Line::default()); + push_wrapped_line( + &mut lines, + vec![ + Span::from(format!( + "{}% of consumed tokens came from {} \"{}\"", + headline.entry.percent_of_usage, + contributor_kind_label(headline.entry.kind), + headline.entry.label + )) + .italic() + .dim(), + ], + inner_width, + ); + if let Some(note) = headline.note.as_ref() { + push_wrapped_text(&mut lines, format!(" {note}"), inner_width); + } + } + + if report.total_tokens == 0 { + lines.push(Line::default()); + push_wrapped_text( + &mut lines, + " No tracked usage in this range yet.", + inner_width, + ); + return with_border_with_inner_width(lines, inner_width); + } + + if sections.iter().all(|(_, entries)| entries.is_empty()) { + lines.push(Line::default()); + push_wrapped_text( + &mut lines, + " No attributed skills, subagents, agent tasks, apps, MCP servers, or plugins in this range.", + inner_width, + ); + return with_border_with_inner_width(lines, inner_width); + } + + for (label, entries) in sections { + push_section(&mut lines, label, entries, label_column_width, inner_width); + } + with_border_with_inner_width(lines, inner_width) +} + +fn usage_card_available_width(width: u16) -> Option { + (width >= 4).then(|| { + usize::from(width.saturating_sub(/*rhs*/ 4)).min(USAGE_CARD_MAX_INNER_WIDTH) + }) +} + +fn usage_label_column_width(sections: &[(&'static str, &[UsageEntry])]) -> usize { + sections + .iter() + .flat_map(|(_, entries)| entries.iter()) + .map(|entry| UnicodeWidthStr::width(entry.label.as_str())) + .max() + .unwrap_or(0) + .saturating_add(/*rhs*/ 3) +} + +fn usage_content_width( + report: &UsageReport, + sections: &[(&'static str, &[UsageEntry])], + label_column_width: usize, + available_width: usize, +) -> usize { + let mut width = 0usize; + for line in usage_header_lines(report, available_width) { + width = width.max(line_display_width(&line)); + } + + if let Some(headline) = report.headline.as_ref() { + width = width.max( + text_width( + format!( + "{}% of consumed tokens came from {} \"{}\"", + headline.entry.percent_of_usage, + contributor_kind_label(headline.entry.kind), + headline.entry.label + ) + .as_str(), + ) + .min(available_width), + ); + } + + if report.total_tokens == 0 { + return width.min(available_width); + } + + if sections.iter().all(|(_, entries)| entries.is_empty()) { + return width.min(available_width); + } + + for (section_label, entries) in sections { + if entries.is_empty() { + continue; + } + width = width.max(text_width(format!(" {section_label}").as_str())); + } + + let prefix_width = text_width(" ├─ "); + let percent_width = text_width("100%"); + let row_width_with_bar = + prefix_width + label_column_width + USAGE_BAR_WIDTH + 2 + percent_width; + let can_show_bar = + available_width >= row_width_with_bar && label_column_width >= USAGE_BAR_MIN_LABEL_WIDTH; + let row_width = if can_show_bar { + row_width_with_bar + } else { + sections + .iter() + .flat_map(|(_, entries)| entries.iter()) + .map(|entry| prefix_width + text_width(entry.label.as_str()) + 1 + percent_width) + .max() + .unwrap_or(0) + }; + width.max(row_width).min(available_width) +} + +fn usage_header_lines(report: &UsageReport, inner_width: usize) -> Vec> { + match report.range { + UsageRange::Day => vec![ + Line::from(usage_title(report.range).bold()), + Line::from(USAGE_SUBTITLE.dim()), + vec![ + "(".dim(), + Span::styled("/usage week", accent_style()), + " for weekly)".dim(), + ] + .into(), + ], + UsageRange::Week => { + let Some(period) = usage_period_label(report) else { + return vec![ + Line::from(usage_title(report.range).bold()), + Line::from(USAGE_SUBTITLE.dim()), + ]; + }; + let combined_title = format!("{}, {period}", usage_title(report.range)); + if text_width(&combined_title) <= inner_width { + vec![ + Line::from(combined_title.bold()), + Line::from(USAGE_SUBTITLE.dim()), + ] + } else { + vec![ + Line::from(usage_title(report.range).bold()), + Line::from(period), + Line::from(USAGE_SUBTITLE.dim()), + ] + } + } + } +} + +fn usage_title(range: UsageRange) -> &'static str { + match range { + UsageRange::Day => "Daily usage by token share", + UsageRange::Week => "Weekly usage by token share", + } +} + +fn usage_command_label(range: UsageRange) -> &'static str { + match range { + UsageRange::Day => "/usage", + UsageRange::Week => "/usage week", + } +} + +fn usage_period_label(report: &UsageReport) -> Option { + let range_start = report + .generated_at + .saturating_sub(usage_range_seconds(report.range)); + let start = format_usage_date(range_start)?; + let end = format_usage_date(report.generated_at)?; + let label = format!("{start} to {end}"); + Some(label) +} + +fn usage_range_seconds(range: UsageRange) -> i64 { + match range { + UsageRange::Day => 24 * 60 * 60, + UsageRange::Week => 7 * 24 * 60 * 60, + } +} + +fn format_usage_date(seconds: i64) -> Option { + DateTime::::from_timestamp(seconds, /*nsecs*/ 0) + .map(|timestamp| timestamp.format("%b %-d").to_string()) +} + +fn push_wrapped_text(lines: &mut Vec>, text: impl Into, inner_width: usize) { + push_wrapped_line(lines, Line::from(text.into()), inner_width); +} + +fn push_wrapped_line( + lines: &mut Vec>, + line: impl Into>, + inner_width: usize, +) { + lines.extend(word_wrap_lines( + [line.into()], + RtOptions::new(inner_width.max(/*other*/ 1)).subsequent_indent(" ".into()), + )); +} + +fn push_section( + lines: &mut Vec>, + label: &'static str, + entries: &[UsageEntry], + label_column_width: usize, + inner_width: usize, +) { + if entries.is_empty() { + return; + } + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + format!(" {label}"), + accent_style(), + ))); + for (index, entry) in entries.iter().enumerate() { + let is_last = index + 1 == entries.len(); + lines.push(usage_entry_line( + entry, + is_last, + label_column_width, + inner_width, + )); + } +} + +fn usage_entry_line( + entry: &UsageEntry, + is_last: bool, + label_column_width: usize, + inner_width: usize, +) -> Line<'static> { + let prefix = if is_last { " └─ " } else { " ├─ " }; + let percent = format!("{:>3}%", entry.percent_of_usage); + let prefix_width = UnicodeWidthStr::width(prefix); + let percent_width = UnicodeWidthStr::width(percent.as_str()); + let trailing_bar_width = USAGE_BAR_WIDTH + 2 + percent_width; + let include_bar = inner_width >= prefix_width + label_column_width + trailing_bar_width + && label_column_width >= USAGE_BAR_MIN_LABEL_WIDTH; + let label_width = if include_bar { + label_column_width + } else { + inner_width.saturating_sub(prefix_width + percent_width + 1) + }; + let label = truncate_to_width(&entry.label, label_width); + let used_width = if include_bar { + prefix_width + label_column_width + trailing_bar_width + } else { + prefix_width + UnicodeWidthStr::width(label.as_str()) + percent_width + }; + let spacer_width = if include_bar { + 0 + } else { + inner_width.saturating_sub(used_width) + }; + + let mut spans = vec![ + Span::from(prefix).dim(), + Span::from(label), + Span::from(" ".repeat(spacer_width)).dim(), + ]; + if include_bar { + let label_padding = label_column_width + .saturating_sub(UnicodeWidthStr::width(entry.label.as_str()).min(label_width)); + spans.push(Span::from(" ".repeat(label_padding)).dim()); + let (filled, empty) = usage_bar_segments(entry.percent_of_usage); + spans.push(usage_bar_filled_span(USAGE_BAR_GLYPH.repeat(filled))); + spans.push(usage_bar_empty_span(USAGE_BAR_GLYPH.repeat(empty))); + spans.push(Span::from(" ").dim()); + } + spans.push(Span::from(percent)); + Line::from(spans) +} + +fn usage_bar_segments(percent: u8) -> (usize, usize) { + let filled = if percent == 0 { + 0 + } else { + ((usize::from(percent) * USAGE_BAR_WIDTH).saturating_add(99)) / 100 + } + .min(USAGE_BAR_WIDTH); + (filled, USAGE_BAR_WIDTH.saturating_sub(filled)) +} + +fn usage_bar_filled_span(content: String) -> Span<'static> { + Span::styled(content, usage_bar_filled_style()) +} + +fn usage_bar_empty_span(content: String) -> Span<'static> { + Span::styled(content, usage_bar_empty_style()) +} + +fn usage_bar_filled_style() -> Style { + usage_bar_filled_style_for(default_bg()) +} + +fn usage_bar_empty_style() -> Style { + usage_bar_empty_style_for(default_bg()) +} + +fn usage_bar_filled_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { + let Some(bg) = terminal_bg else { + return Style::default().fg(Color::Cyan).bold(); + }; + if is_light(bg) { + Style::default() + .fg(usage_best_color(/*target*/ (0, 110, 125), Color::Cyan)) + .bold() + } else { + Style::default() + .fg(usage_best_color( + /*target*/ (170, 210, 218), + Color::Cyan, + )) + .bold() + } +} + +fn usage_bar_empty_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { + let Some(bg) = terminal_bg else { + return Style::default().fg(Color::DarkGray); + }; + if is_light(bg) { + Style::default().fg(usage_best_color( + /*target*/ blend(/*fg*/ (0, 0, 0), bg, /*alpha*/ 0.18), + Color::Gray, + )) + } else { + Style::default().fg(usage_best_color( + /*target*/ blend(/*fg*/ (255, 255, 255), bg, /*alpha*/ 0.3), + Color::DarkGray, + )) + } +} + +fn usage_best_color(target: (u8, u8, u8), fallback: Color) -> Color { + let color = best_color(target); + if color == Color::default() { + fallback + } else { + color + } +} + +fn truncate_to_width(value: &str, width: usize) -> String { + let mut out = String::new(); + let mut used = 0usize; + for ch in value.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if used + ch_width > width { + break; + } + out.push(ch); + used += ch_width; + } + out +} + +fn line_display_width(line: &Line<'static>) -> usize { + line.iter() + .map(|span| UnicodeWidthStr::width(span.content.as_ref())) + .sum() +} + +fn text_width(value: &str) -> usize { + UnicodeWidthStr::width(value) +} + +fn contributor_kind_label(kind: UsageContributorKind) -> &'static str { + match kind { + UsageContributorKind::Skill => "skill", + UsageContributorKind::Subagent => "subagent", + UsageContributorKind::AgentTask => "agent task", + UsageContributorKind::App => "app", + UsageContributorKind::McpServer => "MCP server", + UsageContributorKind::Plugin => "plugin", + } +} diff --git a/codex-rs/tui/src/history_cell/notices.rs b/codex-rs/tui/src/history_cell/notices.rs index d9dff4f5a00..a33535d9487 100644 --- a/codex-rs/tui/src/history_cell/notices.rs +++ b/codex-rs/tui/src/history_cell/notices.rs @@ -185,12 +185,15 @@ impl HistoryCell for DeprecationNoticeCell { } } pub(crate) fn new_info_event(message: String, hint: Option) -> PlainHistoryCell { - let mut line = vec!["• ".dim(), message.into()]; + let mut lines = raw_lines_from_source(&message); + if lines.is_empty() { + lines.push(Line::from("")); + } + lines[0].spans.insert(/*index*/ 0, "• ".dim()); if let Some(hint) = hint { - line.push(" ".into()); - line.push(hint.dark_gray()); + lines[0].spans.push(" ".into()); + lines[0].spans.push(hint.dark_gray()); } - let lines: Vec> = vec![line.into()]; PlainHistoryCell { lines } } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 4a43409296a..538c86467e0 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -44,6 +44,7 @@ pub enum SlashCommand { Diff, Mention, Status, + Usage, DebugConfig, Title, Statusline, @@ -96,6 +97,9 @@ impl SlashCommand { SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", SlashCommand::Hooks => "view and manage lifecycle hooks", SlashCommand::Status => "show current session configuration and token usage", + SlashCommand::Usage => { + "show local token usage by skills, subagents, apps, MCP servers, and plugins" + } SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", SlashCommand::Title => "configure which items appear in the terminal title", SlashCommand::Statusline => "configure which items appear in the status line", @@ -155,6 +159,7 @@ impl SlashCommand { | SlashCommand::Keymap | SlashCommand::Mcp | SlashCommand::Raw + | SlashCommand::Usage | SlashCommand::Pets | SlashCommand::Side | SlashCommand::Btw @@ -172,6 +177,7 @@ impl SlashCommand { | SlashCommand::Diff | SlashCommand::Mention | SlashCommand::Status + | SlashCommand::Usage | SlashCommand::Ide ) } @@ -207,6 +213,7 @@ impl SlashCommand { | SlashCommand::Skills | SlashCommand::Hooks | SlashCommand::Status + | SlashCommand::Usage | SlashCommand::DebugConfig | SlashCommand::Ps | SlashCommand::Stop From 0671b870b00f7a60cc59308377a08424f7c3c7e9 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 28 May 2026 21:08:46 -0300 Subject: [PATCH 2/9] fix(tui): wrap usage error card text --- ...sage_error_wraps_long_backend_message.snap | 13 +++++++++ .../chatwidget/tests/popups_and_settings.rs | 27 +++++++++++++++++++ codex-rs/tui/src/chatwidget/usage.rs | 8 ++++-- 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_error_wraps_long_backend_message.snap diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_error_wraps_long_backend_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_error_wraps_long_backend_message.snap new file mode 100644 index 00000000000..f799c5caf29 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_error_wraps_long_backend_message.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: rendered +--- +/usage + +╭────────────────────────────────────────────────────────╮ +│ Usage by token share │ +│ Percent of consumed tokens in this selected range │ +│ │ +│ Failed to load usage: sqlite state database could not │ +│ be opened because the configured path is unavailable │ +╰────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 705ff4cb816..1658a647104 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -244,6 +244,33 @@ async fn usage_output_reports_unattributed_usage() { assert_chatwidget_snapshot!("usage_output_unattributed", rendered); } +#[tokio::test] +async fn usage_error_wraps_long_backend_message() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.add_usage_output(); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::FetchUsage { + request_id: 0, + range: UsageRange::Day, + }) + ); + chat.on_usage_loaded( + /*request_id*/ 0, + Err( + "sqlite state database could not be opened because the configured path is unavailable" + .to_string(), + ), + ); + + let Ok(AppEvent::InsertHistoryCell(cell)) = rx.try_recv() else { + panic!("expected usage history cell"); + }; + let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 60)); + assert_chatwidget_snapshot!("usage_error_wraps_long_backend_message", rendered); +} + #[tokio::test] async fn usage_output_weekly_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/usage.rs b/codex-rs/tui/src/chatwidget/usage.rs index adbdb30ee28..0100f2e6ad0 100644 --- a/codex-rs/tui/src/chatwidget/usage.rs +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -106,12 +106,16 @@ impl HistoryCell for UsageErrorHistoryCell { let Some(available_width) = usage_card_available_width(width) else { return Vec::new(); }; - let lines = vec![ + let mut lines = vec![ Line::from(USAGE_TITLE.bold()), Line::from(USAGE_SUBTITLE.dim()), Line::default(), - Line::from(format!(" Failed to load usage: {}", self.error)), ]; + push_wrapped_text( + &mut lines, + format!(" Failed to load usage: {}", self.error), + available_width, + ); let inner_width = lines .iter() .map(line_display_width) From f6f5729f768c8c2cb3c2660d83f49627bc7ef4db Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 29 May 2026 14:33:53 -0300 Subject: [PATCH 3/9] fix(tui): align usage card padding with status --- ...sage_error_wraps_long_backend_message.snap | 14 +++--- ..._tui__chatwidget__tests__usage_output.snap | 48 +++++++++---------- ...get__tests__usage_output_unattributed.snap | 16 +++---- ...hatwidget__tests__usage_output_weekly.snap | 30 ++++++------ codex-rs/tui/src/chatwidget/usage.rs | 29 ++++++++--- 5 files changed, 77 insertions(+), 60 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_error_wraps_long_backend_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_error_wraps_long_backend_message.snap index f799c5caf29..a8e1a7a4966 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_error_wraps_long_backend_message.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_error_wraps_long_backend_message.snap @@ -4,10 +4,10 @@ expression: rendered --- /usage -╭────────────────────────────────────────────────────────╮ -│ Usage by token share │ -│ Percent of consumed tokens in this selected range │ -│ │ -│ Failed to load usage: sqlite state database could not │ -│ be opened because the configured path is unavailable │ -╰────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────╮ +│ Usage by token share │ +│ Percent of consumed tokens in this selected range │ +│ │ +│ Failed to load usage: sqlite state database could not │ +│ be opened because the configured path is unavailable │ +╰──────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output.snap index 907e95882af..56b5d4d1cf0 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output.snap @@ -4,27 +4,27 @@ expression: rendered --- /usage -╭───────────────────────────────────────────────────╮ -│ Daily usage by token share │ -│ Percent of consumed tokens in this selected range │ -│ (/usage week for weekly) │ -│ │ -│ 11% of consumed tokens came from app "testmcp" │ -│ Tool results stay in context until compaction; │ -│ compact or disable sources you do not need. │ -│ │ -│ Skills │ -│ ├─ /tmux ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 8% │ -│ └─ /babysit ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 6% │ -│ │ -│ Subagents │ -│ ├─ babysit ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 13% │ -│ └─ code-review ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 9% │ -│ │ -│ Agent tasks │ -│ ├─ guardian ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 17% │ -│ └─ review ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 5% │ -│ │ -│ Apps │ -│ └─ testmcp ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 11% │ -╰───────────────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────╮ +│ Daily usage by token share │ +│ Percent of consumed tokens in this selected range │ +│ (/usage week for weekly) │ +│ │ +│ 11% of consumed tokens came from app "testmcp" │ +│ Tool results stay in context until compaction; │ +│ compact or disable sources you do not need. │ +│ │ +│ Skills │ +│ ├─ /tmux ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 8% │ +│ └─ /babysit ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 6% │ +│ │ +│ Subagents │ +│ ├─ babysit ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 13% │ +│ └─ code-review ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 9% │ +│ │ +│ Agent tasks │ +│ ├─ guardian ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 17% │ +│ └─ review ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 5% │ +│ │ +│ Apps │ +│ └─ testmcp ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 11% │ +╰─────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_unattributed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_unattributed.snap index 941b669bd57..f91ce72109c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_unattributed.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_unattributed.snap @@ -4,11 +4,11 @@ expression: rendered --- /usage -╭───────────────────────────────────────────────────╮ -│ Daily usage by token share │ -│ Percent of consumed tokens in this selected range │ -│ (/usage week for weekly) │ -│ │ -│ No attributed skills, subagents, agent tasks, │ -│ apps, MCP servers, or plugins in this range. │ -╰───────────────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────╮ +│ Daily usage by token share │ +│ Percent of consumed tokens in this selected range │ +│ (/usage week for weekly) │ +│ │ +│ No attributed skills, subagents, agent tasks, │ +│ apps, MCP servers, or plugins in this range. │ +╰─────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_weekly.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_weekly.snap index 2c3827bf643..803f36253bc 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_weekly.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_weekly.snap @@ -4,18 +4,18 @@ expression: rendered --- /usage week -╭────────────────────────────────────────────────────────╮ -│ Weekly usage by token share, Nov 7 to Nov 14 │ -│ Percent of consumed tokens in this selected range │ -│ │ -│ 17% of consumed tokens came from agent task "guardian" │ -│ │ -│ Skills │ -│ └─ /tmux ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 8% │ -│ │ -│ Subagents │ -│ └─ default ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 13% │ -│ │ -│ Agent tasks │ -│ └─ guardian ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 17% │ -╰────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────╮ +│ Weekly usage by token share, Nov 7 to Nov 14 │ +│ Percent of consumed tokens in this selected range │ +│ │ +│ 17% of consumed tokens came from agent task "guardian" │ +│ │ +│ Skills │ +│ └─ /tmux ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 8% │ +│ │ +│ Subagents │ +│ └─ default ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 13% │ +│ │ +│ Agent tasks │ +│ └─ guardian ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 17% │ +╰──────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/chatwidget/usage.rs b/codex-rs/tui/src/chatwidget/usage.rs index 0100f2e6ad0..f1e8e024f09 100644 --- a/codex-rs/tui/src/chatwidget/usage.rs +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -25,6 +25,7 @@ use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; const USAGE_CARD_MAX_INNER_WIDTH: usize = 72; +const USAGE_CARD_EXTRA_HORIZONTAL_PADDING: usize = 1; const USAGE_BAR_WIDTH: usize = 20; const USAGE_BAR_MIN_LABEL_WIDTH: usize = 8; const USAGE_BAR_GLYPH: &str = "▄"; @@ -122,7 +123,7 @@ impl HistoryCell for UsageErrorHistoryCell { .max() .unwrap_or(0) .min(available_width); - with_border_with_inner_width(lines, inner_width) + with_usage_border(lines, inner_width) } fn raw_lines(&self) -> Vec> { @@ -173,7 +174,7 @@ fn usage_report_lines(report: &UsageReport, width: u16) -> Vec> { " No tracked usage in this range yet.", inner_width, ); - return with_border_with_inner_width(lines, inner_width); + return with_usage_border(lines, inner_width); } if sections.iter().all(|(_, entries)| entries.is_empty()) { @@ -183,21 +184,37 @@ fn usage_report_lines(report: &UsageReport, width: u16) -> Vec> { " No attributed skills, subagents, agent tasks, apps, MCP servers, or plugins in this range.", inner_width, ); - return with_border_with_inner_width(lines, inner_width); + return with_usage_border(lines, inner_width); } for (label, entries) in sections { push_section(&mut lines, label, entries, label_column_width, inner_width); } - with_border_with_inner_width(lines, inner_width) + with_usage_border(lines, inner_width) } fn usage_card_available_width(width: u16) -> Option { - (width >= 4).then(|| { - usize::from(width.saturating_sub(/*rhs*/ 4)).min(USAGE_CARD_MAX_INNER_WIDTH) + let frame_width = 4 + USAGE_CARD_EXTRA_HORIZONTAL_PADDING * 2; + (usize::from(width) >= frame_width).then(|| { + usize::from(width) + .saturating_sub(frame_width) + .min(USAGE_CARD_MAX_INNER_WIDTH) }) } +fn with_usage_border(lines: Vec>, inner_width: usize) -> Vec> { + let lines = lines + .into_iter() + .map(|line| { + let mut spans = Vec::with_capacity(line.spans.len() + 1); + spans.push(Span::from(" ".repeat(USAGE_CARD_EXTRA_HORIZONTAL_PADDING))); + spans.extend(line.spans); + Line::from(spans) + }) + .collect(); + with_border_with_inner_width(lines, inner_width + USAGE_CARD_EXTRA_HORIZONTAL_PADDING * 2) +} + fn usage_label_column_width(sections: &[(&'static str, &[UsageEntry])]) -> usize { sections .iter() From 0f217a7a97e104284d36ad7dc7755a18c60ecedc Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 29 May 2026 14:43:41 -0300 Subject: [PATCH 4/9] fix(tui): fade unused usage bar on dark backgrounds --- codex-rs/tui/src/chatwidget/usage.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/chatwidget/usage.rs b/codex-rs/tui/src/chatwidget/usage.rs index f1e8e024f09..e391f52544f 100644 --- a/codex-rs/tui/src/chatwidget/usage.rs +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -501,7 +501,7 @@ fn usage_bar_empty_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { )) } else { Style::default().fg(usage_best_color( - /*target*/ blend(/*fg*/ (255, 255, 255), bg, /*alpha*/ 0.3), + /*target*/ blend(/*fg*/ (255, 255, 255), bg, /*alpha*/ 0.22), Color::DarkGray, )) } From 56314eb68bcd80fe0d6af082ad8ef8f1e92c88f3 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 29 May 2026 15:05:14 -0300 Subject: [PATCH 5/9] fix(tui): further fade unused usage bar --- codex-rs/tui/src/chatwidget/usage.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/usage.rs b/codex-rs/tui/src/chatwidget/usage.rs index e391f52544f..fe02a1cadb6 100644 --- a/codex-rs/tui/src/chatwidget/usage.rs +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -492,7 +492,7 @@ fn usage_bar_filled_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { fn usage_bar_empty_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { let Some(bg) = terminal_bg else { - return Style::default().fg(Color::DarkGray); + return Style::default().fg(Color::DarkGray).dim(); }; if is_light(bg) { Style::default().fg(usage_best_color( @@ -500,10 +500,12 @@ fn usage_bar_empty_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { Color::Gray, )) } else { - Style::default().fg(usage_best_color( - /*target*/ blend(/*fg*/ (255, 255, 255), bg, /*alpha*/ 0.22), - Color::DarkGray, - )) + let color = best_color(blend(/*fg*/ (255, 255, 255), bg, /*alpha*/ 0.10)); + if color == Color::default() { + Style::default().fg(Color::DarkGray).dim() + } else { + Style::default().fg(color) + } } } From 099075fa4b06911db8ce32aa23ba43c402170a2f Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 30 May 2026 13:43:25 -0300 Subject: [PATCH 6/9] fix(tui): remove usage report border --- ...sage_error_wraps_long_backend_message.snap | 12 ++--- ..._tui__chatwidget__tests__usage_output.snap | 46 +++++++++---------- ...get__tests__usage_output_unattributed.snap | 14 +++--- ...hatwidget__tests__usage_output_weekly.snap | 28 ++++++----- codex-rs/tui/src/chatwidget/usage.rs | 36 ++------------- 5 files changed, 51 insertions(+), 85 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_error_wraps_long_backend_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_error_wraps_long_backend_message.snap index a8e1a7a4966..88ab2ce044a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_error_wraps_long_backend_message.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_error_wraps_long_backend_message.snap @@ -4,10 +4,8 @@ expression: rendered --- /usage -╭──────────────────────────────────────────────────────────╮ -│ Usage by token share │ -│ Percent of consumed tokens in this selected range │ -│ │ -│ Failed to load usage: sqlite state database could not │ -│ be opened because the configured path is unavailable │ -╰──────────────────────────────────────────────────────────╯ +Usage by token share +Percent of consumed tokens in this selected range + + Failed to load usage: sqlite state database could not be + opened because the configured path is unavailable diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output.snap index 56b5d4d1cf0..580fd313b22 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output.snap @@ -4,27 +4,25 @@ expression: rendered --- /usage -╭─────────────────────────────────────────────────────╮ -│ Daily usage by token share │ -│ Percent of consumed tokens in this selected range │ -│ (/usage week for weekly) │ -│ │ -│ 11% of consumed tokens came from app "testmcp" │ -│ Tool results stay in context until compaction; │ -│ compact or disable sources you do not need. │ -│ │ -│ Skills │ -│ ├─ /tmux ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 8% │ -│ └─ /babysit ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 6% │ -│ │ -│ Subagents │ -│ ├─ babysit ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 13% │ -│ └─ code-review ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 9% │ -│ │ -│ Agent tasks │ -│ ├─ guardian ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 17% │ -│ └─ review ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 5% │ -│ │ -│ Apps │ -│ └─ testmcp ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 11% │ -╰─────────────────────────────────────────────────────╯ +Daily usage by token share +Percent of consumed tokens in this selected range +(/usage week for weekly) + +11% of consumed tokens came from app "testmcp" + Tool results stay in context until compaction; + compact or disable sources you do not need. + + Skills + ├─ /tmux ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 8% + └─ /babysit ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 6% + + Subagents + ├─ babysit ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 13% + └─ code-review ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 9% + + Agent tasks + ├─ guardian ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 17% + └─ review ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 5% + + Apps + └─ testmcp ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 11% diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_unattributed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_unattributed.snap index f91ce72109c..ba594e1c816 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_unattributed.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_unattributed.snap @@ -4,11 +4,9 @@ expression: rendered --- /usage -╭─────────────────────────────────────────────────────╮ -│ Daily usage by token share │ -│ Percent of consumed tokens in this selected range │ -│ (/usage week for weekly) │ -│ │ -│ No attributed skills, subagents, agent tasks, │ -│ apps, MCP servers, or plugins in this range. │ -╰─────────────────────────────────────────────────────╯ +Daily usage by token share +Percent of consumed tokens in this selected range +(/usage week for weekly) + + No attributed skills, subagents, agent tasks, + apps, MCP servers, or plugins in this range. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_weekly.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_weekly.snap index 803f36253bc..3e9e2ce83b0 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_weekly.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_weekly.snap @@ -4,18 +4,16 @@ expression: rendered --- /usage week -╭──────────────────────────────────────────────────────────╮ -│ Weekly usage by token share, Nov 7 to Nov 14 │ -│ Percent of consumed tokens in this selected range │ -│ │ -│ 17% of consumed tokens came from agent task "guardian" │ -│ │ -│ Skills │ -│ └─ /tmux ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 8% │ -│ │ -│ Subagents │ -│ └─ default ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 13% │ -│ │ -│ Agent tasks │ -│ └─ guardian ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 17% │ -╰──────────────────────────────────────────────────────────╯ +Weekly usage by token share, Nov 7 to Nov 14 +Percent of consumed tokens in this selected range + +17% of consumed tokens came from agent task "guardian" + + Skills + └─ /tmux ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 8% + + Subagents + └─ default ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 13% + + Agent tasks + └─ guardian ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 17% diff --git a/codex-rs/tui/src/chatwidget/usage.rs b/codex-rs/tui/src/chatwidget/usage.rs index fe02a1cadb6..c3284828641 100644 --- a/codex-rs/tui/src/chatwidget/usage.rs +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -6,7 +6,6 @@ use crate::history_cell::CompositeHistoryCell; use crate::history_cell::HistoryCell; use crate::history_cell::PlainHistoryCell; use crate::history_cell::plain_lines; -use crate::history_cell::with_border_with_inner_width; use crate::style::accent_style; use crate::terminal_palette::best_color; use crate::terminal_palette::default_bg; @@ -25,7 +24,6 @@ use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthStr; const USAGE_CARD_MAX_INNER_WIDTH: usize = 72; -const USAGE_CARD_EXTRA_HORIZONTAL_PADDING: usize = 1; const USAGE_BAR_WIDTH: usize = 20; const USAGE_BAR_MIN_LABEL_WIDTH: usize = 8; const USAGE_BAR_GLYPH: &str = "▄"; @@ -117,13 +115,7 @@ impl HistoryCell for UsageErrorHistoryCell { format!(" Failed to load usage: {}", self.error), available_width, ); - let inner_width = lines - .iter() - .map(line_display_width) - .max() - .unwrap_or(0) - .min(available_width); - with_usage_border(lines, inner_width) + lines } fn raw_lines(&self) -> Vec> { @@ -174,7 +166,7 @@ fn usage_report_lines(report: &UsageReport, width: u16) -> Vec> { " No tracked usage in this range yet.", inner_width, ); - return with_usage_border(lines, inner_width); + return lines; } if sections.iter().all(|(_, entries)| entries.is_empty()) { @@ -184,35 +176,17 @@ fn usage_report_lines(report: &UsageReport, width: u16) -> Vec> { " No attributed skills, subagents, agent tasks, apps, MCP servers, or plugins in this range.", inner_width, ); - return with_usage_border(lines, inner_width); + return lines; } for (label, entries) in sections { push_section(&mut lines, label, entries, label_column_width, inner_width); } - with_usage_border(lines, inner_width) + lines } fn usage_card_available_width(width: u16) -> Option { - let frame_width = 4 + USAGE_CARD_EXTRA_HORIZONTAL_PADDING * 2; - (usize::from(width) >= frame_width).then(|| { - usize::from(width) - .saturating_sub(frame_width) - .min(USAGE_CARD_MAX_INNER_WIDTH) - }) -} - -fn with_usage_border(lines: Vec>, inner_width: usize) -> Vec> { - let lines = lines - .into_iter() - .map(|line| { - let mut spans = Vec::with_capacity(line.spans.len() + 1); - spans.push(Span::from(" ".repeat(USAGE_CARD_EXTRA_HORIZONTAL_PADDING))); - spans.extend(line.spans); - Line::from(spans) - }) - .collect(); - with_border_with_inner_width(lines, inner_width + USAGE_CARD_EXTRA_HORIZONTAL_PADDING * 2) + (width > 0).then(|| usize::from(width).min(USAGE_CARD_MAX_INNER_WIDTH)) } fn usage_label_column_width(sections: &[(&'static str, &[UsageEntry])]) -> usize { From f21e3f5e27abe68275426cb422c6733996ea84f7 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 30 May 2026 13:59:30 -0300 Subject: [PATCH 7/9] feat(tui): use theme colors in usage report --- codex-rs/tui/src/chatwidget/usage.rs | 30 ++++++++++------------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/usage.rs b/codex-rs/tui/src/chatwidget/usage.rs index c3284828641..efa56f055e3 100644 --- a/codex-rs/tui/src/chatwidget/usage.rs +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -6,7 +6,9 @@ use crate::history_cell::CompositeHistoryCell; use crate::history_cell::HistoryCell; use crate::history_cell::PlainHistoryCell; use crate::history_cell::plain_lines; +use crate::render::highlight::foreground_style_for_scopes; use crate::style::accent_style; +use crate::style::table_separator_style; use crate::terminal_palette::best_color; use crate::terminal_palette::default_bg; use crate::wrapping::RtOptions; @@ -266,7 +268,7 @@ fn usage_header_lines(report: &UsageReport, inner_width: usize) -> Vec Span<'static> { } fn usage_bar_filled_style() -> Style { - usage_bar_filled_style_for(default_bg()) + usage_accent_style() } fn usage_bar_empty_style() -> Style { usage_bar_empty_style_for(default_bg()) } -fn usage_bar_filled_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { - let Some(bg) = terminal_bg else { - return Style::default().fg(Color::Cyan).bold(); - }; - if is_light(bg) { - Style::default() - .fg(usage_best_color(/*target*/ (0, 110, 125), Color::Cyan)) - .bold() - } else { - Style::default() - .fg(usage_best_color( - /*target*/ (170, 210, 218), - Color::Cyan, - )) - .bold() - } +fn usage_accent_style() -> Style { + foreground_style_for_scopes(&["entity.name.type", "support.type", "variable"]) + .unwrap_or_else(accent_style) + .bold() } fn usage_bar_empty_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style { From 9c4d68685a4164fae8eda6da24edbc98ee5f5b6b Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 30 May 2026 14:08:17 -0300 Subject: [PATCH 8/9] fix(usage): hide mirrored app plugin entries --- codex-rs/state/src/runtime/usage.rs | 81 ++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 6 deletions(-) diff --git a/codex-rs/state/src/runtime/usage.rs b/codex-rs/state/src/runtime/usage.rs index c4cdd03dd61..95409a74859 100644 --- a/codex-rs/state/src/runtime/usage.rs +++ b/codex-rs/state/src/runtime/usage.rs @@ -133,6 +133,14 @@ INSERT INTO usage_sample_contributors ( .bind(until) .fetch_one(self.pool.as_ref()) .await?; + let apps = self + .read_usage_contributors(since, until, UsageContributorKind::App, total_tokens) + .await?; + let plugins = suppress_app_mirror_plugins( + &apps, + self.read_usage_contributors(since, until, UsageContributorKind::Plugin, total_tokens) + .await?, + ); let mut report = UsageReport { range, generated_at: now, @@ -146,9 +154,7 @@ INSERT INTO usage_sample_contributors ( agent_tasks: self .read_agent_task_usage(since, until, total_tokens) .await?, - apps: self - .read_usage_contributors(since, until, UsageContributorKind::App, total_tokens) - .await?, + apps, mcp_servers: self .read_usage_contributors( since, @@ -157,9 +163,7 @@ INSERT INTO usage_sample_contributors ( total_tokens, ) .await?, - plugins: self - .read_usage_contributors(since, until, UsageContributorKind::Plugin, total_tokens) - .await?, + plugins, }; report.headline = usage_headline(&report); Ok(report) @@ -423,6 +427,17 @@ fn usage_headline(report: &UsageReport) -> Option { Some(UsageHeadline { entry, note }) } +fn suppress_app_mirror_plugins(apps: &[UsageEntry], plugins: Vec) -> Vec { + plugins + .into_iter() + .filter(|plugin| { + !apps.iter().any(|app| { + app.label == plugin.label && app.attributed_tokens == plugin.attributed_tokens + }) + }) + .collect() +} + fn agent_task_label(source: &str) -> String { let parsed_source = serde_json::from_str(source) .or_else(|_| serde_json::from_value::(Value::String(source.to_string()))); @@ -714,6 +729,44 @@ mod tests { ); } + #[test] + fn suppress_app_mirror_plugins_keeps_non_mirror_plugins() { + let apps = vec![usage_entry( + UsageContributorKind::App, + "connector_gmail", + "Gmail", + /*attributed_tokens*/ 340, + /*percent_of_usage*/ 10, + )]; + let plugins = vec![ + usage_entry( + UsageContributorKind::Plugin, + "Gmail", + "Gmail", + /*attributed_tokens*/ 340, + /*percent_of_usage*/ 10, + ), + usage_entry( + UsageContributorKind::Plugin, + "Workspace", + "Workspace", + /*attributed_tokens*/ 500, + /*percent_of_usage*/ 15, + ), + ]; + + assert_eq!( + suppress_app_mirror_plugins(&apps, plugins), + vec![usage_entry( + UsageContributorKind::Plugin, + "Workspace", + "Workspace", + /*attributed_tokens*/ 500, + /*percent_of_usage*/ 15, + )] + ); + } + #[tokio::test] async fn usage_report_groups_agent_tasks_by_subagent_source() { let (codex_home, runtime) = usage_runtime().await; @@ -1127,6 +1180,22 @@ mod tests { } } + fn usage_entry( + kind: UsageContributorKind, + id: &str, + label: &str, + attributed_tokens: i64, + percent_of_usage: u8, + ) -> UsageEntry { + UsageEntry { + kind, + id: id.to_string(), + label: label.to_string(), + attributed_tokens, + percent_of_usage, + } + } + async fn usage_sample_count(runtime: &StateRuntime) -> i64 { sqlx::query_scalar("SELECT COUNT(*) FROM usage_samples") .fetch_one(runtime.pool.as_ref()) From 8f645eaf061eafa8c0867eee21e673760bccb355 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 30 May 2026 14:19:03 -0300 Subject: [PATCH 9/9] feat(tui): refine usage token share bars --- ..._tui__chatwidget__tests__usage_output.snap | 14 ++++----- ...hatwidget__tests__usage_output_weekly.snap | 6 ++-- codex-rs/tui/src/chatwidget/usage.rs | 30 ++++++++++++------- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output.snap index 580fd313b22..ed6fc1d9f6a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output.snap @@ -13,16 +13,16 @@ Percent of consumed tokens in this selected range compact or disable sources you do not need. Skills - ├─ /tmux ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 8% - └─ /babysit ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 6% + ├─ /tmux 8% |██░░░░░░░░░░░░░░░░░░| + └─ /babysit 6% |██░░░░░░░░░░░░░░░░░░| Subagents - ├─ babysit ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 13% - └─ code-review ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 9% + ├─ babysit 13% |███░░░░░░░░░░░░░░░░░| + └─ code-review 9% |██░░░░░░░░░░░░░░░░░░| Agent tasks - ├─ guardian ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 17% - └─ review ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 5% + ├─ guardian 17% |████░░░░░░░░░░░░░░░░| + └─ review 5% |█░░░░░░░░░░░░░░░░░░░| Apps - └─ testmcp ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 11% + └─ testmcp 11% |███░░░░░░░░░░░░░░░░░| diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_weekly.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_weekly.snap index 3e9e2ce83b0..9855420f0b5 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_weekly.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_weekly.snap @@ -10,10 +10,10 @@ Percent of consumed tokens in this selected range 17% of consumed tokens came from agent task "guardian" Skills - └─ /tmux ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 8% + └─ /tmux 8% |██░░░░░░░░░░░░░░░░░░| Subagents - └─ default ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 13% + └─ default 13% |███░░░░░░░░░░░░░░░░░| Agent tasks - └─ guardian ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 17% + └─ guardian 17% |████░░░░░░░░░░░░░░░░| diff --git a/codex-rs/tui/src/chatwidget/usage.rs b/codex-rs/tui/src/chatwidget/usage.rs index efa56f055e3..4d18fa4b415 100644 --- a/codex-rs/tui/src/chatwidget/usage.rs +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -27,8 +27,10 @@ use unicode_width::UnicodeWidthStr; const USAGE_CARD_MAX_INNER_WIDTH: usize = 72; const USAGE_BAR_WIDTH: usize = 20; +const USAGE_BAR_BORDER_WIDTH: usize = 2; const USAGE_BAR_MIN_LABEL_WIDTH: usize = 8; -const USAGE_BAR_GLYPH: &str = "▄"; +const USAGE_BAR_FILLED_GLYPH: &str = "█"; +const USAGE_BAR_EMPTY_GLYPH: &str = "░"; const USAGE_TITLE: &str = "Usage by token share"; const USAGE_SUBTITLE: &str = "Percent of consumed tokens in this selected range"; @@ -244,8 +246,12 @@ fn usage_content_width( let prefix_width = text_width(" ├─ "); let percent_width = text_width("100%"); - let row_width_with_bar = - prefix_width + label_column_width + USAGE_BAR_WIDTH + 2 + percent_width; + let row_width_with_bar = prefix_width + + label_column_width + + percent_width + + 2 + + USAGE_BAR_BORDER_WIDTH + + USAGE_BAR_WIDTH; let can_show_bar = available_width >= row_width_with_bar && label_column_width >= USAGE_BAR_MIN_LABEL_WIDTH; let row_width = if can_show_bar { @@ -384,8 +390,8 @@ fn usage_entry_line( let percent = format!("{:>3}%", entry.percent_of_usage); let prefix_width = UnicodeWidthStr::width(prefix); let percent_width = UnicodeWidthStr::width(percent.as_str()); - let trailing_bar_width = USAGE_BAR_WIDTH + 2 + percent_width; - let include_bar = inner_width >= prefix_width + label_column_width + trailing_bar_width + let trailing_metrics_width = percent_width + 2 + USAGE_BAR_BORDER_WIDTH + USAGE_BAR_WIDTH; + let include_bar = inner_width >= prefix_width + label_column_width + trailing_metrics_width && label_column_width >= USAGE_BAR_MIN_LABEL_WIDTH; let label_width = if include_bar { label_column_width @@ -394,7 +400,7 @@ fn usage_entry_line( }; let label = truncate_to_width(&entry.label, label_width); let used_width = if include_bar { - prefix_width + label_column_width + trailing_bar_width + prefix_width + label_column_width + trailing_metrics_width } else { prefix_width + UnicodeWidthStr::width(label.as_str()) + percent_width }; @@ -413,12 +419,16 @@ fn usage_entry_line( let label_padding = label_column_width .saturating_sub(UnicodeWidthStr::width(entry.label.as_str()).min(label_width)); spans.push(Span::from(" ".repeat(label_padding)).dim()); - let (filled, empty) = usage_bar_segments(entry.percent_of_usage); - spans.push(usage_bar_filled_span(USAGE_BAR_GLYPH.repeat(filled))); - spans.push(usage_bar_empty_span(USAGE_BAR_GLYPH.repeat(empty))); + spans.push(Span::from(percent)); spans.push(Span::from(" ").dim()); + spans.push(Span::styled("|", table_separator_style())); + let (filled, empty) = usage_bar_segments(entry.percent_of_usage); + spans.push(usage_bar_filled_span(USAGE_BAR_FILLED_GLYPH.repeat(filled))); + spans.push(usage_bar_empty_span(USAGE_BAR_EMPTY_GLYPH.repeat(empty))); + spans.push(Span::styled("|", table_separator_style())); + } else { + spans.push(Span::from(percent)); } - spans.push(Span::from(percent)); Line::from(spans) }