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()) 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_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..88ab2ce044a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_error_wraps_long_backend_message.snap @@ -0,0 +1,11 @@ +--- +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/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..ed6fc1d9f6a --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output.snap @@ -0,0 +1,28 @@ +--- +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..ba594e1c816 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_unattributed.snap @@ -0,0 +1,12 @@ +--- +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..9855420f0b5 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_output_weekly.snap @@ -0,0 +1,19 @@ +--- +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..1658a647104 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,228 @@ 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_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; + + 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 +353,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..4d18fa4b415 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -0,0 +1,528 @@ +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::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; +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_BORDER_WIDTH: usize = 2; +const USAGE_BAR_MIN_LABEL_WIDTH: usize = 8; +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"; + +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 mut lines = vec![ + Line::from(USAGE_TITLE.bold()), + Line::from(USAGE_SUBTITLE.dim()), + Line::default(), + ]; + push_wrapped_text( + &mut lines, + format!(" Failed to load usage: {}", self.error), + available_width, + ); + lines + } + + 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 lines; + } + + 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 lines; + } + + for (label, entries) in sections { + push_section(&mut lines, label, entries, label_column_width, inner_width); + } + lines +} + +fn usage_card_available_width(width: u16) -> Option { + (width > 0).then(|| usize::from(width).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 + + 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 { + 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", usage_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}"), + usage_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_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 + } 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_metrics_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::styled(prefix, table_separator_style()), + 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()); + 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)); + } + 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_accent_style() +} + +fn usage_bar_empty_style() -> Style { + usage_bar_empty_style_for(default_bg()) +} + +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 { + let Some(bg) = terminal_bg else { + return Style::default().fg(Color::DarkGray).dim(); + }; + if is_light(bg) { + Style::default().fg(usage_best_color( + /*target*/ blend(/*fg*/ (0, 0, 0), bg, /*alpha*/ 0.18), + Color::Gray, + )) + } else { + 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) + } + } +} + +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