From e363dac2493c2a9d9d1969ee5bbaf069c0d8894c Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 25 Sep 2025 08:38:50 -0700 Subject: [PATCH] revamp /status (#4196) image --- codex-rs/tui/src/history_cell.rs | 384 ++---------------- codex-rs/tui/src/lib.rs | 1 + ...s_snapshot_includes_reasoning_details.snap | 19 + ...snapshot_truncates_in_narrow_terminal.snap | 19 + codex-rs/tui/src/status/account.rs | 8 + codex-rs/tui/src/status/card.rs | 280 +++++++++++++ codex-rs/tui/src/status/format.rs | 144 +++++++ codex-rs/tui/src/status/helpers.rs | 180 ++++++++ codex-rs/tui/src/status/mod.rs | 12 + codex-rs/tui/src/status/rate_limits.rs | 117 ++++++ ...s_snapshot_includes_reasoning_details.snap | 19 + ...snapshot_truncates_in_narrow_terminal.snap | 19 + codex-rs/tui/src/status/tests.rs | 183 +++++++++ 13 files changed, 1044 insertions(+), 341 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap create mode 100644 codex-rs/tui/src/status/account.rs create mode 100644 codex-rs/tui/src/status/card.rs create mode 100644 codex-rs/tui/src/status/format.rs create mode 100644 codex-rs/tui/src/status/helpers.rs create mode 100644 codex-rs/tui/src/status/mod.rs create mode 100644 codex-rs/tui/src/status/rate_limits.rs create mode 100644 codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap create mode 100644 codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap create mode 100644 codex-rs/tui/src/status/tests.rs diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index a63da9ea..3ae0d177 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -5,36 +5,26 @@ use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; +pub(crate) use crate::status::RateLimitSnapshotDisplay; +pub(crate) use crate::status::new_status_output; +pub(crate) use crate::status::rate_limit_snapshot_display; use crate::text_formatting::format_and_truncate_tool_result; use crate::ui_consts::LIVE_PREFIX_COLS; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; use crate::wrapping::word_wrap_lines; use base64::Engine; -use chrono::DateTime; -use chrono::Duration as ChronoDuration; -use chrono::Local; use codex_ansi_escape::ansi_escape_line; -use codex_common::create_config_summary_entries; use codex_common::elapsed::format_duration; -use codex_core::auth::get_auth_file; -use codex_core::auth::try_read_auth_json; use codex_core::config::Config; use codex_core::config_types::ReasoningSummaryFormat; use codex_core::plan_tool::PlanItemArg; use codex_core::plan_tool::StepStatus; use codex_core::plan_tool::UpdatePlanArgs; -use codex_core::project_doc::discover_project_doc_paths; use codex_core::protocol::FileChange; use codex_core::protocol::McpInvocation; -use codex_core::protocol::RateLimitSnapshot; -use codex_core::protocol::RateLimitWindow; -use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; -use codex_core::protocol::TokenUsage; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::mcp_protocol::ConversationId; -use codex_protocol::num_format::format_with_separators; use codex_protocol::parse_command::ParsedCommand; use image::DynamicImage; use image::ImageReader; @@ -51,7 +41,6 @@ use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use std::any::Any; use std::collections::HashMap; -use std::convert::TryFrom; use std::io::Cursor; use std::path::Path; use std::path::PathBuf; @@ -60,10 +49,6 @@ use std::time::Instant; use tracing::error; use unicode_width::UnicodeWidthStr; -const STATUS_LIMIT_BAR_SEGMENTS: usize = 20; -const STATUS_LIMIT_BAR_FILLED: &str = "โ–ˆ"; -const STATUS_LIMIT_BAR_EMPTY: &str = " "; - #[derive(Clone, Debug)] pub(crate) struct CommandOutput { pub(crate) exit_code: i32, @@ -229,6 +214,12 @@ pub(crate) struct PlainHistoryCell { lines: Vec>, } +impl PlainHistoryCell { + pub(crate) fn new(lines: Vec>) -> Self { + Self { lines } + } +} + impl HistoryCell for PlainHistoryCell { fn display_lines(&self, _width: u16) -> Vec> { self.lines.clone() @@ -637,9 +628,9 @@ impl HistoryCell for CompletedMcpToolCallWithImageOutput { } const TOOL_CALL_MAX_LINES: usize = 5; -const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value +pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value -fn card_inner_width(width: u16, max_inner_width: usize) -> Option { +pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option { if width < 4 { return None; } @@ -647,8 +638,28 @@ fn card_inner_width(width: u16, max_inner_width: usize) -> Option { Some(inner_width) } -fn with_border(lines: Vec>) -> Vec> { - let content_width = lines +/// Render `lines` inside a border sized to the widest span in the content. +pub(crate) fn with_border(lines: Vec>) -> Vec> { + with_border_internal(lines, None) +} + +/// Render `lines` inside a border whose inner width is at least `inner_width`. +/// +/// This is useful when callers have already clamped their content to a +/// specific width and want the border math centralized here instead of +/// duplicating padding logic in the TUI widgets themselves. +pub(crate) fn with_border_with_inner_width( + lines: Vec>, + inner_width: usize, +) -> Vec> { + with_border_internal(lines, Some(inner_width)) +} + +fn with_border_internal( + lines: Vec>, + forced_inner_width: Option, +) -> Vec> { + let max_line_width = lines .iter() .map(|line| { line.iter() @@ -657,6 +668,9 @@ fn with_border(lines: Vec>) -> Vec> { }) .max() .unwrap_or(0); + let content_width = forced_inner_width + .unwrap_or(max_line_width) + .max(max_line_width); let mut out = Vec::with_capacity(lines.len() + 2); let border_inner_width = content_width + 2; @@ -683,30 +697,10 @@ fn with_border(lines: Vec>) -> Vec> { out } -fn title_case(s: &str) -> String { - if s.is_empty() { - return String::new(); - } - let mut chars = s.chars(); - let first = match chars.next() { - Some(c) => c, - None => return String::new(), - }; - let rest: String = chars.as_str().to_ascii_lowercase(); - first.to_uppercase().collect::() + &rest -} - -fn pretty_provider_name(id: &str) -> String { - if id.eq_ignore_ascii_case("openai") { - "OpenAI".to_string() - } else { - title_case(id) - } -} /// Return the emoji followed by a hair space (U+200A). /// Using only the hair space avoids excessive padding after the emoji while /// still providing a small visual gap across terminals. -fn padded_emoji(emoji: &str) -> String { +pub(crate) fn padded_emoji(emoji: &str) -> String { format!("{emoji}\u{200A}") } @@ -925,6 +919,12 @@ pub(crate) struct CompositeHistoryCell { parts: Vec>, } +impl CompositeHistoryCell { + pub(crate) fn new(parts: Vec>) -> Self { + Self { parts } + } +} + impl HistoryCell for CompositeHistoryCell { fn display_lines(&self, width: u16) -> Vec> { let mut out: Vec> = Vec::new(); @@ -1184,228 +1184,6 @@ pub(crate) fn new_warning_event(message: String) -> PlainHistoryCell { } } -#[derive(Debug, Clone)] -pub(crate) struct RateLimitWindowDisplay { - pub used_percent: f64, - pub resets_at: Option, -} - -impl RateLimitWindowDisplay { - fn from_window(window: &RateLimitWindow, captured_at: DateTime) -> Self { - let resets_at = window - .resets_in_seconds - .and_then(|seconds| i64::try_from(seconds).ok()) - .and_then(|secs| captured_at.checked_add_signed(ChronoDuration::seconds(secs))) - .map(|dt| dt.format("%b %-d, %Y %-I:%M %p").to_string()); - - Self { - used_percent: window.used_percent, - resets_at, - } - } -} - -#[derive(Debug, Clone)] -pub(crate) struct RateLimitSnapshotDisplay { - pub primary: Option, - pub secondary: Option, -} - -pub(crate) fn rate_limit_snapshot_display( - snapshot: &RateLimitSnapshot, - captured_at: DateTime, -) -> RateLimitSnapshotDisplay { - RateLimitSnapshotDisplay { - primary: snapshot - .primary - .as_ref() - .map(|window| RateLimitWindowDisplay::from_window(window, captured_at)), - secondary: snapshot - .secondary - .as_ref() - .map(|window| RateLimitWindowDisplay::from_window(window, captured_at)), - } -} - -pub(crate) fn new_status_output( - config: &Config, - usage: &TokenUsage, - session_id: &Option, - rate_limits: Option<&RateLimitSnapshotDisplay>, -) -> PlainHistoryCell { - let mut lines: Vec> = Vec::new(); - lines.push("/status".magenta().into()); - - let config_entries = create_config_summary_entries(config); - let lookup = |k: &str| -> String { - config_entries - .iter() - .find(|(key, _)| *key == k) - .map(|(_, v)| v.clone()) - .unwrap_or_default() - }; - - // ๐Ÿ“‚ Workspace - lines.push(vec![padded_emoji("๐Ÿ“‚").into(), "Workspace".bold()].into()); - // Path (home-relative, e.g., ~/code/project) - let cwd_str = match relativize_to_home(&config.cwd) { - Some(rel) if !rel.as_os_str().is_empty() => { - let sep = std::path::MAIN_SEPARATOR; - format!("~{sep}{}", rel.display()) - } - Some(_) => "~".to_string(), - None => config.cwd.display().to_string(), - }; - lines.push(vec![" โ€ข Path: ".into(), cwd_str.into()].into()); - // Approval mode (as-is) - lines.push(vec![" โ€ข Approval Mode: ".into(), lookup("approval").into()].into()); - // Sandbox (simplified name only) - let sandbox_name = match &config.sandbox_policy { - SandboxPolicy::DangerFullAccess => "danger-full-access", - SandboxPolicy::ReadOnly => "read-only", - SandboxPolicy::WorkspaceWrite { .. } => "workspace-write", - }; - lines.push(vec![" โ€ข Sandbox: ".into(), sandbox_name.into()].into()); - - // AGENTS.md files discovered via core's project_doc logic - let agents_list = { - match discover_project_doc_paths(config) { - Ok(paths) => { - let mut rels: Vec = Vec::new(); - for p in paths { - let display = if let Some(parent) = p.parent() { - if parent == config.cwd { - "AGENTS.md".to_string() - } else { - let mut cur = config.cwd.as_path(); - let mut ups = 0usize; - let mut reached = false; - while let Some(c) = cur.parent() { - if cur == parent { - reached = true; - break; - } - cur = c; - ups += 1; - } - if reached { - let up = format!("..{}", std::path::MAIN_SEPARATOR); - format!("{}AGENTS.md", up.repeat(ups)) - } else if let Ok(stripped) = p.strip_prefix(&config.cwd) { - stripped.display().to_string() - } else { - p.display().to_string() - } - } - } else { - p.display().to_string() - }; - rels.push(display); - } - rels - } - Err(_) => Vec::new(), - } - }; - if agents_list.is_empty() { - lines.push(" โ€ข AGENTS files: (none)".into()); - } else { - lines.push(vec![" โ€ข AGENTS files: ".into(), agents_list.join(", ").into()].into()); - } - lines.push("".into()); - - // ๐Ÿ‘ค Account (only if ChatGPT tokens exist), shown under the first block - let auth_file = get_auth_file(&config.codex_home); - let auth = try_read_auth_json(&auth_file).ok(); - let is_chatgpt_auth = auth - .as_ref() - .and_then(|auth| auth.tokens.as_ref()) - .is_some(); - if is_chatgpt_auth - && let Some(auth) = auth.as_ref() - && let Some(tokens) = auth.tokens.clone() - { - lines.push(vec![padded_emoji("๐Ÿ‘ค").into(), "Account".bold()].into()); - lines.push(" โ€ข Signed in with ChatGPT".into()); - - let info = tokens.id_token; - if let Some(email) = &info.email { - lines.push(vec![" โ€ข Login: ".into(), email.clone().into()].into()); - } - - match auth.openai_api_key.as_deref() { - Some(key) if !key.is_empty() => { - lines.push(" โ€ข Using API key. Run codex login to use ChatGPT plan".into()); - } - _ => { - let plan_text = info - .get_chatgpt_plan_type() - .map(|s| title_case(&s)) - .unwrap_or_else(|| "Unknown".to_string()); - lines.push(vec![" โ€ข Plan: ".into(), plan_text.into()].into()); - } - } - - lines.push("".into()); - } - - // ๐Ÿง  Model - lines.push(vec![padded_emoji("๐Ÿง ").into(), "Model".bold()].into()); - lines.push(vec![" โ€ข Name: ".into(), config.model.clone().into()].into()); - let provider_disp = pretty_provider_name(&config.model_provider_id); - lines.push(vec![" โ€ข Provider: ".into(), provider_disp.into()].into()); - // Only show Reasoning fields if present in config summary - let reff = lookup("reasoning effort"); - if !reff.is_empty() { - lines.push(vec![" โ€ข Reasoning Effort: ".into(), title_case(&reff).into()].into()); - } - let rsum = lookup("reasoning summaries"); - if !rsum.is_empty() { - lines.push(vec![" โ€ข Reasoning Summaries: ".into(), title_case(&rsum).into()].into()); - } - - lines.push("".into()); - - // ๐Ÿ’ป Client - let cli_version = crate::version::CODEX_CLI_VERSION; - lines.push(vec![padded_emoji("๐Ÿ’ป").into(), "Client".bold()].into()); - lines.push(vec![" โ€ข CLI Version: ".into(), cli_version.into()].into()); - lines.push("".into()); - - // ๐Ÿ“Š Token Usage - lines.push(vec!["๐Ÿ“Š ".into(), "Token Usage".bold()].into()); - if let Some(session_id) = session_id { - lines.push(vec![" โ€ข Session ID: ".into(), session_id.to_string().into()].into()); - } - // Input: [+ cached] - let mut input_line_spans: Vec> = vec![ - " โ€ข Input: ".into(), - format_with_separators(usage.non_cached_input()).into(), - ]; - if usage.cached_input_tokens > 0 { - let cached = usage.cached_input_tokens; - input_line_spans.push(format!(" (+ {cached} cached)").into()); - } - lines.push(Line::from(input_line_spans)); - // Output: - lines.push(Line::from(vec![ - " โ€ข Output: ".into(), - format_with_separators(usage.output_tokens).into(), - ])); - // Total: - lines.push(Line::from(vec![ - " โ€ข Total: ".into(), - format_with_separators(usage.blended_total()).into(), - ])); - - if is_chatgpt_auth { - lines.push("".into()); - lines.extend(build_status_limit_lines(rate_limits)); - } - - PlainHistoryCell { lines } -} - /// Render a summary of configured MCP servers from the current `Config`. pub(crate) fn empty_mcp_output() -> PlainHistoryCell { let lines: Vec> = vec![ @@ -1760,82 +1538,6 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { invocation_spans.into() } -fn build_status_limit_lines(snapshot: Option<&RateLimitSnapshotDisplay>) -> Vec> { - let mut lines: Vec> = - vec![vec![padded_emoji("โฑ๏ธ").into(), "Usage Limits".bold()].into()]; - - match snapshot { - Some(snapshot) => { - let mut windows: Vec<(&str, &RateLimitWindowDisplay)> = Vec::new(); - if let Some(primary) = snapshot.primary.as_ref() { - windows.push(("5h limit", primary)); - } - if let Some(secondary) = snapshot.secondary.as_ref() { - windows.push(("Weekly limit", secondary)); - } - - if windows.is_empty() { - lines.push(" โ€ข No rate limit data available.".into()); - } else { - let label_width = windows - .iter() - .map(|(label, _)| UnicodeWidthStr::width(*label)) - .max() - .unwrap_or(0); - - for (label, window) in windows { - lines.push(build_status_limit_line( - label, - window.used_percent, - label_width, - )); - if let Some(resets_at) = window.resets_at.as_deref() { - lines.push(build_status_reset_line(resets_at)); - } - } - } - } - None => lines.push(" โ€ข Send a message to load usage data.".into()), - } - - lines -} - -fn build_status_limit_line(label: &str, percent_used: f64, label_width: usize) -> Line<'static> { - let clamped_percent = percent_used.clamp(0.0, 100.0); - let progress = render_status_limit_progress_bar(clamped_percent); - let summary = format_status_limit_summary(clamped_percent); - - let mut spans: Vec> = Vec::with_capacity(5); - let padded_label = format!("{label: Line<'static> { - vec![" ".into(), format!("Resets at: {resets_at}").dim()].into() -} - -fn render_status_limit_progress_bar(percent_used: f64) -> String { - let ratio = (percent_used / 100.0).clamp(0.0, 1.0); - let filled = (ratio * STATUS_LIMIT_BAR_SEGMENTS as f64).round() as usize; - let filled = filled.min(STATUS_LIMIT_BAR_SEGMENTS); - let empty = STATUS_LIMIT_BAR_SEGMENTS.saturating_sub(filled); - format!( - "[{}{}]", - STATUS_LIMIT_BAR_FILLED.repeat(filled), - STATUS_LIMIT_BAR_EMPTY.repeat(empty) - ) -} - -fn format_status_limit_summary(percent_used: f64) -> String { - format!("{percent_used:.0}% used") -} - #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 0791a1cc..1453971c 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -60,6 +60,7 @@ mod resume_picker; mod session_log; mod shimmer; mod slash_command; +mod status; mod status_indicator_widget; mod streaming; mod text_formatting; diff --git a/codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap b/codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap new file mode 100644 index 00000000..1615305f --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/status.rs +expression: sanitized +--- +/status + +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ >_ OpenAI Codex (v0.0.0) โ”‚ +โ”‚ โ”‚ +โ”‚ Model : gpt-5-codex (reasoning high, summaries detailed) โ”‚ +โ”‚ Directory : /workspace/tests โ”‚ +โ”‚ Approval : on-request โ”‚ +โ”‚ Sandbox : workspace-write โ”‚ +โ”‚ Agents.md : โ”‚ +โ”‚ โ”‚ +โ”‚ Token Usage : 1.9K total (1K input + 900 output) โ”‚ +โ”‚ 5h limit : [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘] 72% used ยท resets 03:14 โ”‚ +โ”‚ Weekly limit : [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] 45% used ยท resets 03:24 โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ diff --git a/codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap b/codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap new file mode 100644 index 00000000..44cad152 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/status.rs +expression: sanitized +--- +/status + +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ >_ OpenAI Codex (v0.0.0) โ”‚ +โ”‚ โ”‚ +โ”‚ Model : gpt-5-codex (reasoning high โ”‚ +โ”‚ Directory : /workspace/tests โ”‚ +โ”‚ Approval : on-request โ”‚ +โ”‚ Sandbox : read-only โ”‚ +โ”‚ Agents.md : โ”‚ +โ”‚ โ”‚ +โ”‚ Token Usage : 1.9K total (1K input + 900 โ”‚ +โ”‚ 5h limit : [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘] 72% โ”‚ +โ”‚ ยท resets 03:14 โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ diff --git a/codex-rs/tui/src/status/account.rs b/codex-rs/tui/src/status/account.rs new file mode 100644 index 00000000..23255f43 --- /dev/null +++ b/codex-rs/tui/src/status/account.rs @@ -0,0 +1,8 @@ +#[derive(Debug, Clone)] +pub(crate) enum StatusAccountDisplay { + ChatGpt { + email: Option, + plan: Option, + }, + ApiKey, +} diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs new file mode 100644 index 00000000..a977240b --- /dev/null +++ b/codex-rs/tui/src/status/card.rs @@ -0,0 +1,280 @@ +use crate::history_cell::CompositeHistoryCell; +use crate::history_cell::HistoryCell; +use crate::history_cell::PlainHistoryCell; +use crate::history_cell::with_border_with_inner_width; +use crate::version::CODEX_CLI_VERSION; +use codex_common::create_config_summary_entries; +use codex_core::config::Config; +use codex_core::protocol::SandboxPolicy; +use codex_core::protocol::TokenUsage; +use codex_protocol::mcp_protocol::ConversationId; +use ratatui::prelude::*; +use ratatui::style::Stylize; +use std::collections::BTreeSet; +use std::path::PathBuf; + +use super::account::StatusAccountDisplay; +use super::format::FieldFormatter; +use super::format::line_display_width; +use super::format::push_label; +use super::format::truncate_line_to_width; +use super::helpers::compose_account_display; +use super::helpers::compose_agents_summary; +use super::helpers::compose_model_display; +use super::helpers::format_directory_display; +use super::helpers::format_tokens_compact; +use super::rate_limits::RESET_BULLET; +use super::rate_limits::RateLimitSnapshotDisplay; +use super::rate_limits::StatusRateLimitData; +use super::rate_limits::compose_rate_limit_data; +use super::rate_limits::format_status_limit_summary; +use super::rate_limits::render_status_limit_progress_bar; + +#[derive(Debug, Clone)] +pub(crate) struct StatusTokenUsageData { + total: u64, + input: u64, + output: u64, +} + +#[derive(Debug)] +struct StatusHistoryCell { + model_name: String, + model_details: Vec, + directory: PathBuf, + approval: String, + sandbox: String, + agents_summary: String, + account: Option, + session_id: Option, + token_usage: StatusTokenUsageData, + rate_limits: StatusRateLimitData, +} + +pub(crate) fn new_status_output( + config: &Config, + usage: &TokenUsage, + session_id: &Option, + rate_limits: Option<&RateLimitSnapshotDisplay>, +) -> CompositeHistoryCell { + let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); + let card = StatusHistoryCell::new(config, usage, session_id, rate_limits); + + CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) +} + +impl StatusHistoryCell { + fn new( + config: &Config, + usage: &TokenUsage, + session_id: &Option, + rate_limits: Option<&RateLimitSnapshotDisplay>, + ) -> Self { + let config_entries = create_config_summary_entries(config); + let (model_name, model_details) = compose_model_display(config, &config_entries); + let approval = config_entries + .iter() + .find(|(k, _)| *k == "approval") + .map(|(_, v)| v.clone()) + .unwrap_or_else(|| "".to_string()); + let sandbox = match &config.sandbox_policy { + SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), + SandboxPolicy::ReadOnly => "read-only".to_string(), + SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(), + }; + let agents_summary = compose_agents_summary(config); + let account = compose_account_display(config); + let session_id = session_id.as_ref().map(std::string::ToString::to_string); + let token_usage = StatusTokenUsageData { + total: usage.blended_total(), + input: usage.non_cached_input(), + output: usage.output_tokens, + }; + let rate_limits = compose_rate_limit_data(rate_limits); + + Self { + model_name, + model_details, + directory: config.cwd.clone(), + approval, + sandbox, + agents_summary, + account, + session_id, + token_usage, + rate_limits, + } + } + + fn token_usage_spans(&self) -> Vec> { + let total_fmt = format_tokens_compact(self.token_usage.total); + let input_fmt = format_tokens_compact(self.token_usage.input); + let output_fmt = format_tokens_compact(self.token_usage.output); + + vec![ + Span::from(total_fmt), + Span::from(" total "), + Span::from(" (").dim(), + Span::from(input_fmt).dim(), + Span::from(" input").dim(), + Span::from(" + ").dim(), + Span::from(output_fmt).dim(), + Span::from(" output").dim(), + Span::from(")").dim(), + ] + } + + fn rate_limit_lines( + &self, + available_inner_width: usize, + formatter: &FieldFormatter, + ) -> Vec> { + match &self.rate_limits { + StatusRateLimitData::Available(rows_data) => { + if rows_data.is_empty() { + return vec![ + formatter.line("Limits", vec![Span::from("data not available yet").dim()]), + ]; + } + + let mut lines = Vec::with_capacity(rows_data.len() * 2); + + for row in rows_data { + let value_spans = vec![ + Span::from(render_status_limit_progress_bar(row.percent_used)), + Span::from(" "), + Span::from(format_status_limit_summary(row.percent_used)), + ]; + let base_spans = formatter.full_spans(row.label, value_spans); + let base_line = Line::from(base_spans.clone()); + + if let Some(resets_at) = row.resets_at.as_ref() { + let resets_span = + Span::from(format!("{RESET_BULLET} resets {resets_at}")).dim(); + let mut inline_spans = base_spans.clone(); + inline_spans.push(Span::from(" ").dim()); + inline_spans.push(resets_span.clone()); + + if line_display_width(&Line::from(inline_spans.clone())) + <= available_inner_width + { + lines.push(Line::from(inline_spans)); + } else { + lines.push(base_line); + lines.push(formatter.continuation(vec![resets_span])); + } + } else { + lines.push(base_line); + } + } + + lines + } + StatusRateLimitData::Missing => { + vec![formatter.line("Limits", vec![Span::from("data not available yet").dim()])] + } + } + } + + fn collect_rate_limit_labels( + &self, + seen: &mut BTreeSet<&'static str>, + labels: &mut Vec<&'static str>, + ) { + match &self.rate_limits { + StatusRateLimitData::Available(rows) => { + if rows.is_empty() { + push_label(labels, seen, "Limits"); + } else { + for row in rows { + push_label(labels, seen, row.label); + } + } + } + StatusRateLimitData::Missing => push_label(labels, seen, "Limits"), + } + } +} + +impl HistoryCell for StatusHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let mut lines: Vec> = Vec::new(); + lines.push(Line::from(vec![ + Span::from(format!("{}>_ ", FieldFormatter::INDENT)).dim(), + Span::from("OpenAI Codex").bold(), + Span::from(" ").dim(), + Span::from(format!("(v{CODEX_CLI_VERSION})")).dim(), + ])); + lines.push(Line::from(Vec::>::new())); + + let available_inner_width = usize::from(width.saturating_sub(4)); + if available_inner_width == 0 { + return Vec::new(); + } + + let account_value = self.account.as_ref().map(|account| match account { + StatusAccountDisplay::ChatGpt { email, plan } => match (email, plan) { + (Some(email), Some(plan)) => format!("{email} ({plan})"), + (Some(email), None) => email.clone(), + (None, Some(plan)) => plan.clone(), + (None, None) => "ChatGPT".to_string(), + }, + StatusAccountDisplay::ApiKey => { + "API key configured (run codex login to use ChatGPT)".to_string() + } + }); + + let mut labels: Vec<&'static str> = + vec!["Model", "Directory", "Approval", "Sandbox", "Agents.md"]; + let mut seen: BTreeSet<&'static str> = labels.iter().copied().collect(); + + if account_value.is_some() { + push_label(&mut labels, &mut seen, "Account"); + } + if self.session_id.is_some() { + push_label(&mut labels, &mut seen, "Session"); + } + push_label(&mut labels, &mut seen, "Token Usage"); + self.collect_rate_limit_labels(&mut seen, &mut labels); + + let formatter = FieldFormatter::from_labels(labels.iter().copied()); + let value_width = formatter.value_width(available_inner_width); + + let mut model_spans = vec![Span::from(self.model_name.clone())]; + if !self.model_details.is_empty() { + model_spans.push(Span::from(" (").dim()); + model_spans.push(Span::from(self.model_details.join(", ")).dim()); + model_spans.push(Span::from(")").dim()); + } + + let directory_value = format_directory_display(&self.directory, Some(value_width)); + + lines.push(formatter.line("Model", model_spans)); + lines.push(formatter.line("Directory", vec![Span::from(directory_value)])); + lines.push(formatter.line("Approval", vec![Span::from(self.approval.clone())])); + lines.push(formatter.line("Sandbox", vec![Span::from(self.sandbox.clone())])); + lines.push(formatter.line("Agents.md", vec![Span::from(self.agents_summary.clone())])); + + if let Some(account_value) = account_value { + lines.push(formatter.line("Account", vec![Span::from(account_value)])); + } + + if let Some(session) = self.session_id.as_ref() { + lines.push(formatter.line("Session", vec![Span::from(session.clone())])); + } + + lines.push(Line::from(Vec::>::new())); + lines.push(formatter.line("Token Usage", self.token_usage_spans())); + + lines.extend(self.rate_limit_lines(available_inner_width, &formatter)); + + let content_width = lines.iter().map(line_display_width).max().unwrap_or(0); + let inner_width = content_width.min(available_inner_width); + let truncated_lines: Vec> = lines + .into_iter() + .map(|line| truncate_line_to_width(line, inner_width)) + .collect(); + + with_border_with_inner_width(truncated_lines, inner_width) + } +} diff --git a/codex-rs/tui/src/status/format.rs b/codex-rs/tui/src/status/format.rs new file mode 100644 index 00000000..556e5bd1 --- /dev/null +++ b/codex-rs/tui/src/status/format.rs @@ -0,0 +1,144 @@ +use ratatui::prelude::*; +use ratatui::style::Stylize; +use std::collections::BTreeSet; +use unicode_width::UnicodeWidthChar; +use unicode_width::UnicodeWidthStr; + +#[derive(Debug, Clone)] +pub(crate) struct FieldFormatter { + indent: &'static str, + label_width: usize, + value_offset: usize, + value_indent: String, +} + +impl FieldFormatter { + pub(crate) const INDENT: &'static str = " "; + + pub(crate) fn from_labels(labels: impl IntoIterator) -> Self { + let label_width = labels + .into_iter() + .map(UnicodeWidthStr::width) + .max() + .unwrap_or(0); + let indent_width = UnicodeWidthStr::width(Self::INDENT); + let value_offset = indent_width + label_width + 1 + 3; + + Self { + indent: Self::INDENT, + label_width, + value_offset, + value_indent: " ".repeat(value_offset), + } + } + + pub(crate) fn line( + &self, + label: &'static str, + value_spans: Vec>, + ) -> Line<'static> { + Line::from(self.full_spans(label, value_spans)) + } + + pub(crate) fn continuation(&self, mut spans: Vec>) -> Line<'static> { + let mut all_spans = Vec::with_capacity(spans.len() + 1); + all_spans.push(Span::from(self.value_indent.clone()).dim()); + all_spans.append(&mut spans); + Line::from(all_spans) + } + + pub(crate) fn value_width(&self, available_inner_width: usize) -> usize { + available_inner_width.saturating_sub(self.value_offset) + } + + pub(crate) fn full_spans( + &self, + label: &'static str, + mut value_spans: Vec>, + ) -> Vec> { + let mut spans = Vec::with_capacity(value_spans.len() + 1); + spans.push(self.label_span(label)); + spans.append(&mut value_spans); + spans + } + + fn label_span(&self, label: &str) -> Span<'static> { + let mut buf = String::with_capacity(self.value_offset); + buf.push_str(self.indent); + + buf.push_str(label); + buf.push(':'); + + let label_width = UnicodeWidthStr::width(label); + let padding = 3 + self.label_width.saturating_sub(label_width); + for _ in 0..padding { + buf.push(' '); + } + + Span::from(buf).dim() + } +} + +pub(crate) fn push_label( + labels: &mut Vec<&'static str>, + seen: &mut BTreeSet<&'static str>, + label: &'static str, +) { + if seen.insert(label) { + labels.push(label); + } +} + +pub(crate) fn line_display_width(line: &Line<'static>) -> usize { + line.iter() + .map(|span| UnicodeWidthStr::width(span.content.as_ref())) + .sum() +} + +pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> Line<'static> { + if max_width == 0 { + return Line::from(Vec::>::new()); + } + + let mut used = 0usize; + let mut spans_out: Vec> = Vec::new(); + + for span in line.spans { + let text = span.content.into_owned(); + let style = span.style; + let span_width = UnicodeWidthStr::width(text.as_str()); + + if span_width == 0 { + spans_out.push(Span::styled(text, style)); + continue; + } + + if used >= max_width { + break; + } + + if used + span_width <= max_width { + used += span_width; + spans_out.push(Span::styled(text, style)); + continue; + } + + let mut truncated = String::new(); + for ch in text.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if used + ch_width > max_width { + break; + } + truncated.push(ch); + used += ch_width; + } + + if !truncated.is_empty() { + spans_out.push(Span::styled(truncated, style)); + } + + break; + } + + Line::from(spans_out) +} diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs new file mode 100644 index 00000000..46185e1c --- /dev/null +++ b/codex-rs/tui/src/status/helpers.rs @@ -0,0 +1,180 @@ +use crate::exec_command::relativize_to_home; +use crate::text_formatting; +use chrono::DateTime; +use chrono::Local; +use codex_core::auth::get_auth_file; +use codex_core::auth::try_read_auth_json; +use codex_core::config::Config; +use codex_core::project_doc::discover_project_doc_paths; +use std::path::Path; +use unicode_width::UnicodeWidthStr; + +use super::account::StatusAccountDisplay; + +pub(crate) fn compose_model_display( + config: &Config, + entries: &[(&str, String)], +) -> (String, Vec) { + let mut details: Vec = Vec::new(); + if let Some((_, effort)) = entries.iter().find(|(k, _)| *k == "reasoning effort") { + details.push(format!("reasoning {}", effort.to_ascii_lowercase())); + } + if let Some((_, summary)) = entries.iter().find(|(k, _)| *k == "reasoning summaries") { + let summary = summary.trim(); + if summary.eq_ignore_ascii_case("none") || summary.eq_ignore_ascii_case("off") { + details.push("summaries off".to_string()); + } else if !summary.is_empty() { + details.push(format!("summaries {}", summary.to_ascii_lowercase())); + } + } + + (config.model.clone(), details) +} + +pub(crate) fn compose_agents_summary(config: &Config) -> String { + match discover_project_doc_paths(config) { + Ok(paths) => { + let mut rels: Vec = Vec::new(); + for p in paths { + let display = if let Some(parent) = p.parent() { + if parent == config.cwd { + "AGENTS.md".to_string() + } else { + let mut cur = config.cwd.as_path(); + let mut ups = 0usize; + let mut reached = false; + while let Some(c) = cur.parent() { + if cur == parent { + reached = true; + break; + } + cur = c; + ups += 1; + } + if reached { + let up = format!("..{}", std::path::MAIN_SEPARATOR); + format!("{}AGENTS.md", up.repeat(ups)) + } else if let Ok(stripped) = p.strip_prefix(&config.cwd) { + stripped.display().to_string() + } else { + p.display().to_string() + } + } + } else { + p.display().to_string() + }; + rels.push(display); + } + if rels.is_empty() { + "".to_string() + } else { + rels.join(", ") + } + } + Err(_) => "".to_string(), + } +} + +pub(crate) fn compose_account_display(config: &Config) -> Option { + let auth_file = get_auth_file(&config.codex_home); + let auth = try_read_auth_json(&auth_file).ok()?; + + if let Some(tokens) = auth.tokens.as_ref() { + let info = &tokens.id_token; + let email = info.email.clone(); + let plan = info.get_chatgpt_plan_type().map(|plan| title_case(&plan)); + return Some(StatusAccountDisplay::ChatGpt { email, plan }); + } + + if let Some(key) = auth.openai_api_key + && !key.is_empty() + { + return Some(StatusAccountDisplay::ApiKey); + } + + None +} + +pub(crate) fn format_tokens_compact(value: u64) -> String { + if value == 0 { + return "0".to_string(); + } + if value < 1_000 { + return value.to_string(); + } + + let (scaled, suffix) = if value >= 1_000_000_000_000 { + (value as f64 / 1_000_000_000_000.0, "T") + } else if value >= 1_000_000_000 { + (value as f64 / 1_000_000_000.0, "B") + } else if value >= 1_000_000 { + (value as f64 / 1_000_000.0, "M") + } else { + (value as f64 / 1_000.0, "K") + }; + + let decimals = if scaled < 10.0 { + 2 + } else if scaled < 100.0 { + 1 + } else { + 0 + }; + + let mut formatted = format!("{scaled:.decimals$}"); + if formatted.contains('.') { + while formatted.ends_with('0') { + formatted.pop(); + } + if formatted.ends_with('.') { + formatted.pop(); + } + } + + format!("{formatted}{suffix}") +} + +pub(crate) fn format_directory_display(directory: &Path, max_width: Option) -> String { + let formatted = if let Some(rel) = relativize_to_home(directory) { + if rel.as_os_str().is_empty() { + "~".to_string() + } else { + format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display()) + } + } else { + directory.display().to_string() + }; + + if let Some(max_width) = max_width { + if max_width == 0 { + return String::new(); + } + if UnicodeWidthStr::width(formatted.as_str()) > max_width { + return text_formatting::center_truncate_path(&formatted, max_width); + } + } + + formatted +} + +pub(crate) fn format_reset_timestamp(dt: DateTime, captured_at: DateTime) -> String { + let time = dt.format("%H:%M").to_string(); + if dt.date_naive() == captured_at.date_naive() { + time + } else { + format!("{} ({time})", dt.format("%-d %b")) + } +} + +pub(crate) fn title_case(s: &str) -> String { + if s.is_empty() { + return String::new(); + } + let mut chars = s.chars(); + let first = match chars.next() { + Some(c) => c, + None => return String::new(), + }; + let rest: String = chars.as_str().to_ascii_lowercase(); + first.to_uppercase().collect::() + &rest +} diff --git a/codex-rs/tui/src/status/mod.rs b/codex-rs/tui/src/status/mod.rs new file mode 100644 index 00000000..eccb6b72 --- /dev/null +++ b/codex-rs/tui/src/status/mod.rs @@ -0,0 +1,12 @@ +mod account; +mod card; +mod format; +mod helpers; +mod rate_limits; + +pub(crate) use card::new_status_output; +pub(crate) use rate_limits::RateLimitSnapshotDisplay; +pub(crate) use rate_limits::rate_limit_snapshot_display; + +#[cfg(test)] +mod tests; diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs new file mode 100644 index 00000000..17d2f170 --- /dev/null +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -0,0 +1,117 @@ +use super::helpers::format_reset_timestamp; +use chrono::DateTime; +use chrono::Duration as ChronoDuration; +use chrono::Local; +use codex_core::protocol::RateLimitSnapshot; +use codex_core::protocol::RateLimitWindow; +use std::convert::TryFrom; + +const STATUS_LIMIT_BAR_SEGMENTS: usize = 20; +const STATUS_LIMIT_BAR_FILLED: &str = "โ–ˆ"; +const STATUS_LIMIT_BAR_EMPTY: &str = "โ–‘"; +pub(crate) const RESET_BULLET: &str = "ยท"; + +#[derive(Debug, Clone)] +pub(crate) struct StatusRateLimitRow { + pub label: &'static str, + pub percent_used: f64, + pub resets_at: Option, +} + +#[derive(Debug, Clone)] +pub(crate) enum StatusRateLimitData { + Available(Vec), + Missing, +} + +#[derive(Debug, Clone)] +pub(crate) struct RateLimitWindowDisplay { + pub used_percent: f64, + pub resets_at: Option, +} + +impl RateLimitWindowDisplay { + fn from_window(window: &RateLimitWindow, captured_at: DateTime) -> Self { + let resets_at = window + .resets_in_seconds + .and_then(|seconds| i64::try_from(seconds).ok()) + .and_then(|secs| captured_at.checked_add_signed(ChronoDuration::seconds(secs))) + .map(|dt| format_reset_timestamp(dt, captured_at)); + + Self { + used_percent: window.used_percent, + resets_at, + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct RateLimitSnapshotDisplay { + pub primary: Option, + pub secondary: Option, +} + +pub(crate) fn rate_limit_snapshot_display( + snapshot: &RateLimitSnapshot, + captured_at: DateTime, +) -> RateLimitSnapshotDisplay { + RateLimitSnapshotDisplay { + primary: snapshot + .primary + .as_ref() + .map(|window| RateLimitWindowDisplay::from_window(window, captured_at)), + secondary: snapshot + .secondary + .as_ref() + .map(|window| RateLimitWindowDisplay::from_window(window, captured_at)), + } +} + +pub(crate) fn compose_rate_limit_data( + snapshot: Option<&RateLimitSnapshotDisplay>, +) -> StatusRateLimitData { + match snapshot { + Some(snapshot) => { + let mut rows = Vec::with_capacity(2); + + if let Some(primary) = snapshot.primary.as_ref() { + rows.push(StatusRateLimitRow { + label: "5h limit", + percent_used: primary.used_percent, + resets_at: primary.resets_at.clone(), + }); + } + + if let Some(secondary) = snapshot.secondary.as_ref() { + rows.push(StatusRateLimitRow { + label: "Weekly limit", + percent_used: secondary.used_percent, + resets_at: secondary.resets_at.clone(), + }); + } + + if rows.is_empty() { + StatusRateLimitData::Missing + } else { + StatusRateLimitData::Available(rows) + } + } + None => StatusRateLimitData::Missing, + } +} + +pub(crate) fn render_status_limit_progress_bar(percent_used: f64) -> String { + let ratio = (percent_used / 100.0).clamp(0.0, 1.0); + let filled = (ratio * STATUS_LIMIT_BAR_SEGMENTS as f64).round() as usize; + let filled = filled.min(STATUS_LIMIT_BAR_SEGMENTS); + let empty = STATUS_LIMIT_BAR_SEGMENTS.saturating_sub(filled); + format!( + "[{}{}]", + STATUS_LIMIT_BAR_FILLED.repeat(filled), + STATUS_LIMIT_BAR_EMPTY.repeat(empty) + ) +} + +pub(crate) fn format_status_limit_summary(percent_used: f64) -> String { + format!("{percent_used:.0}% used") +} diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap new file mode 100644 index 00000000..fc929e04 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ >_ OpenAI Codex (v0.0.0) โ”‚ +โ”‚ โ”‚ +โ”‚ Model: gpt-5-codex (reasoning high, summaries detailed) โ”‚ +โ”‚ Directory: [[workspace]] โ”‚ +โ”‚ Approval: on-request โ”‚ +โ”‚ Sandbox: workspace-write โ”‚ +โ”‚ Agents.md: โ”‚ +โ”‚ โ”‚ +โ”‚ Token Usage: 1.9K total (1K input + 900 output) โ”‚ +โ”‚ 5h limit: [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘] 72% used ยท resets 03:14 โ”‚ +โ”‚ Weekly limit: [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] 45% used ยท resets 03:24 โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap new file mode 100644 index 00000000..9f7e1a45 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ >_ OpenAI Codex (v0.0.0) โ”‚ +โ”‚ โ”‚ +โ”‚ Model: gpt-5-codex (reasoning hig โ”‚ +โ”‚ Directory: [[workspace]] โ”‚ +โ”‚ Approval: on-request โ”‚ +โ”‚ Sandbox: read-only โ”‚ +โ”‚ Agents.md: โ”‚ +โ”‚ โ”‚ +โ”‚ Token Usage: 1.9K total (1K input + 90 โ”‚ +โ”‚ 5h limit: [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘] 72% โ”‚ +โ”‚ ยท resets 03:14 โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs new file mode 100644 index 00000000..467b9dd7 --- /dev/null +++ b/codex-rs/tui/src/status/tests.rs @@ -0,0 +1,183 @@ +use super::new_status_output; +use super::rate_limit_snapshot_display; +use crate::history_cell::HistoryCell; +use chrono::TimeZone; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; +use codex_core::config::ConfigToml; +use codex_core::protocol::RateLimitSnapshot; +use codex_core::protocol::RateLimitWindow; +use codex_core::protocol::SandboxPolicy; +use codex_core::protocol::TokenUsage; +use codex_protocol::config_types::ReasoningEffort; +use codex_protocol::config_types::ReasoningSummary; +use insta::assert_snapshot; +use ratatui::prelude::*; +use std::path::PathBuf; +use tempfile::TempDir; + +fn test_config(temp_home: &TempDir) -> Config { + Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + temp_home.path().to_path_buf(), + ) + .expect("load config") +} + +fn render_lines(lines: &[Line<'static>]) -> Vec { + lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect() +} + +fn sanitize_directory(lines: Vec) -> Vec { + lines + .into_iter() + .map(|line| { + if let (Some(dir_pos), Some(pipe_idx)) = (line.find("Directory: "), line.rfind('โ”‚')) { + let prefix = &line[..dir_pos + "Directory: ".len()]; + let suffix = &line[pipe_idx..]; + let content_width = pipe_idx.saturating_sub(dir_pos + "Directory: ".len()); + let replacement = "[[workspace]]"; + let mut rebuilt = prefix.to_string(); + rebuilt.push_str(replacement); + if content_width > replacement.len() { + rebuilt.push_str(&" ".repeat(content_width - replacement.len())); + } + rebuilt.push_str(suffix); + rebuilt + } else { + line + } + }) + .collect() +} + +#[test] +fn status_snapshot_includes_reasoning_details() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home); + config.model = "gpt-5-codex".to_string(); + config.model_provider_id = "openai".to_string(); + config.model_reasoning_effort = Some(ReasoningEffort::High); + config.model_reasoning_summary = ReasoningSummary::Detailed; + config.sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + + config.cwd = PathBuf::from("/workspace/tests"); + + let usage = TokenUsage { + input_tokens: 1_200, + cached_input_tokens: 200, + output_tokens: 900, + reasoning_output_tokens: 150, + total_tokens: 2_250, + }; + + let snapshot = RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 72.5, + window_minutes: Some(300), + resets_in_seconds: Some(600), + }), + secondary: Some(RateLimitWindow { + used_percent: 45.0, + window_minutes: Some(1_440), + resets_in_seconds: Some(1_200), + }), + }; + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + + let composite = new_status_output(&config, &usage, &None, Some(&rate_display)); + let mut rendered_lines = render_lines(&composite.display_lines(80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[test] +fn status_card_token_usage_excludes_cached_tokens() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home); + config.model = "gpt-5-codex".to_string(); + config.cwd = PathBuf::from("/workspace/tests"); + + let usage = TokenUsage { + input_tokens: 1_200, + cached_input_tokens: 200, + output_tokens: 900, + reasoning_output_tokens: 0, + total_tokens: 2_100, + }; + + let composite = new_status_output(&config, &usage, &None, None); + let rendered = render_lines(&composite.display_lines(120)); + + assert!( + rendered.iter().all(|line| !line.contains("cached")), + "cached tokens should not be displayed, got: {rendered:?}" + ); +} + +#[test] +fn status_snapshot_truncates_in_narrow_terminal() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home); + config.model = "gpt-5-codex".to_string(); + config.model_provider_id = "openai".to_string(); + config.model_reasoning_effort = Some(ReasoningEffort::High); + config.model_reasoning_summary = ReasoningSummary::Detailed; + config.cwd = PathBuf::from("/workspace/tests"); + + let usage = TokenUsage { + input_tokens: 1_200, + cached_input_tokens: 200, + output_tokens: 900, + reasoning_output_tokens: 150, + total_tokens: 2_250, + }; + + let snapshot = RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 72.5, + window_minutes: Some(300), + resets_in_seconds: Some(600), + }), + secondary: None, + }; + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + + let composite = new_status_output(&config, &usage, &None, Some(&rate_display)); + let mut rendered_lines = render_lines(&composite.display_lines(46)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + + assert_snapshot!(sanitized); +}