feat(tui): add usage report command

This commit is contained in:
Felipe Coury
2026-05-29 13:50:35 -03:00
parent 5b04edfda9
commit cbfa50b9ba
15 changed files with 897 additions and 5 deletions
@@ -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<UsageReadResponse> {
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,
+6
View File
@@ -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);
}
+10
View File
@@ -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<codex_app_server_protocol::UsageReadResponse, String>,
},
/// Fetch lifecycle hook inventory for the provided working directory.
FetchHooksList {
cwd: PathBuf,
@@ -297,6 +297,7 @@ mod tests {
SlashCommand::Diff,
SlashCommand::Mention,
SlashCommand::Status,
SlashCommand::Usage,
]
);
}
+3
View File
@@ -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<String, RateLimitSnapshotDisplay>,
refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>,
next_status_refresh_request_id: u64,
next_usage_request_id: u64,
active_usage_request_id: Option<u64>,
plan_type: Option<PlanType>,
codex_rate_limit_reached_type: Option<RateLimitReachedType>,
rate_limit_warnings: RateLimitWarningState,
@@ -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(),
@@ -36,6 +36,13 @@ const GOAL_USAGE: &str = "Usage: /goal <objective>";
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<codex_app_server_protocol::UsageRange> {
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
@@ -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% │
╰───────────────────────────────────────────────────╯
@@ -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. │
╰───────────────────────────────────────────────────╯
@@ -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% │
╰────────────────────────────────────────────────────────╯
+6
View File
@@ -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;
@@ -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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.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;
+531
View File
@@ -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<UsageReadResponse, String>,
) {
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<Line<'static>> {
usage_report_lines(&self.report, width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.display_lines(u16::MAX))
}
}
impl HistoryCell for UsageErrorHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
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<Line<'static>> {
plain_lines(self.display_lines(u16::MAX))
}
}
fn usage_report_lines(report: &UsageReport, width: u16) -> Vec<Line<'static>> {
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(&sections);
let inner_width = usage_content_width(report, &sections, 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<usize> {
(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<Line<'static>> {
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<String> {
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<String> {
DateTime::<Utc>::from_timestamp(seconds, /*nsecs*/ 0)
.map(|timestamp| timestamp.format("%b %-d").to_string())
}
fn push_wrapped_text(lines: &mut Vec<Line<'static>>, text: impl Into<String>, inner_width: usize) {
push_wrapped_line(lines, Line::from(text.into()), inner_width);
}
fn push_wrapped_line(
lines: &mut Vec<Line<'static>>,
line: impl Into<Line<'static>>,
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<Line<'static>>,
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",
}
}
+8 -5
View File
@@ -185,12 +185,15 @@ impl HistoryCell for DeprecationNoticeCell {
}
}
pub(crate) fn new_info_event(message: String, hint: Option<String>) -> PlainHistoryCell {
let mut line = vec!["• ".dim(), message.into()];
if let Some(hint) = hint {
line.push(" ".into());
line.push(hint.dark_gray());
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 {
lines[0].spans.push(" ".into());
lines[0].spans.push(hint.dark_gray());
}
let lines: Vec<Line<'static>> = vec![line.into()];
PlainHistoryCell { lines }
}
+7
View File
@@ -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