revamp /status (#4196)
<img width="543" height="520" alt="image" src="https://github.com/user-attachments/assets/bbc0eec0-e40b-45e7-bcd0-a997f8eeffa2" />
This commit is contained in:
@@ -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<Line<'static>>,
|
||||
}
|
||||
|
||||
impl PlainHistoryCell {
|
||||
pub(crate) fn new(lines: Vec<Line<'static>>) -> Self {
|
||||
Self { lines }
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for PlainHistoryCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
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<usize> {
|
||||
pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option<usize> {
|
||||
if width < 4 {
|
||||
return None;
|
||||
}
|
||||
@@ -647,8 +638,28 @@ fn card_inner_width(width: u16, max_inner_width: usize) -> Option<usize> {
|
||||
Some(inner_width)
|
||||
}
|
||||
|
||||
fn with_border(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
|
||||
let content_width = lines
|
||||
/// Render `lines` inside a border sized to the widest span in the content.
|
||||
pub(crate) fn with_border(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
|
||||
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<Line<'static>>,
|
||||
inner_width: usize,
|
||||
) -> Vec<Line<'static>> {
|
||||
with_border_internal(lines, Some(inner_width))
|
||||
}
|
||||
|
||||
fn with_border_internal(
|
||||
lines: Vec<Line<'static>>,
|
||||
forced_inner_width: Option<usize>,
|
||||
) -> Vec<Line<'static>> {
|
||||
let max_line_width = lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.iter()
|
||||
@@ -657,6 +668,9 @@ fn with_border(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
|
||||
})
|
||||
.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<Line<'static>>) -> Vec<Line<'static>> {
|
||||
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::<String>() + &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<Box<dyn HistoryCell>>,
|
||||
}
|
||||
|
||||
impl CompositeHistoryCell {
|
||||
pub(crate) fn new(parts: Vec<Box<dyn HistoryCell>>) -> Self {
|
||||
Self { parts }
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for CompositeHistoryCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut out: Vec<Line<'static>> = 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<String>,
|
||||
}
|
||||
|
||||
impl RateLimitWindowDisplay {
|
||||
fn from_window(window: &RateLimitWindow, captured_at: DateTime<Local>) -> 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<RateLimitWindowDisplay>,
|
||||
pub secondary: Option<RateLimitWindowDisplay>,
|
||||
}
|
||||
|
||||
pub(crate) fn rate_limit_snapshot_display(
|
||||
snapshot: &RateLimitSnapshot,
|
||||
captured_at: DateTime<Local>,
|
||||
) -> 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<ConversationId>,
|
||||
rate_limits: Option<&RateLimitSnapshotDisplay>,
|
||||
) -> PlainHistoryCell {
|
||||
let mut lines: Vec<Line<'static>> = 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<String> = 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: <input> [+ <cached> cached]
|
||||
let mut input_line_spans: Vec<Span<'static>> = 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: <output>
|
||||
lines.push(Line::from(vec![
|
||||
" • Output: ".into(),
|
||||
format_with_separators(usage.output_tokens).into(),
|
||||
]));
|
||||
// Total: <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<Line<'static>> = 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<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> =
|
||||
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<Span<'static>> = Vec::with_capacity(5);
|
||||
let padded_label = format!("{label:<label_width$}");
|
||||
spans.push(format!(" • {padded_label}: ").into());
|
||||
spans.push(progress.into());
|
||||
spans.push(" ".into());
|
||||
spans.push(summary.into());
|
||||
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn build_status_reset_line(resets_at: &str) -> 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::*;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 : <none> │
|
||||
│ │
|
||||
│ Token Usage : 1.9K total (1K input + 900 output) │
|
||||
│ 5h limit : [███████████████░░░░░] 72% used · resets 03:14 │
|
||||
│ Weekly limit : [█████████░░░░░░░░░░░] 45% used · resets 03:24 │
|
||||
╰──────────────────────────────────────────────────────────────────╯
|
||||
@@ -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 : <none> │
|
||||
│ │
|
||||
│ Token Usage : 1.9K total (1K input + 900 │
|
||||
│ 5h limit : [███████████████░░░░░] 72% │
|
||||
│ · resets 03:14 │
|
||||
╰────────────────────────────────────────────╯
|
||||
8
codex-rs/tui/src/status/account.rs
Normal file
8
codex-rs/tui/src/status/account.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum StatusAccountDisplay {
|
||||
ChatGpt {
|
||||
email: Option<String>,
|
||||
plan: Option<String>,
|
||||
},
|
||||
ApiKey,
|
||||
}
|
||||
280
codex-rs/tui/src/status/card.rs
Normal file
280
codex-rs/tui/src/status/card.rs
Normal file
@@ -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<String>,
|
||||
directory: PathBuf,
|
||||
approval: String,
|
||||
sandbox: String,
|
||||
agents_summary: String,
|
||||
account: Option<StatusAccountDisplay>,
|
||||
session_id: Option<String>,
|
||||
token_usage: StatusTokenUsageData,
|
||||
rate_limits: StatusRateLimitData,
|
||||
}
|
||||
|
||||
pub(crate) fn new_status_output(
|
||||
config: &Config,
|
||||
usage: &TokenUsage,
|
||||
session_id: &Option<ConversationId>,
|
||||
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<ConversationId>,
|
||||
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(|| "<unknown>".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<Span<'static>> {
|
||||
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<Line<'static>> {
|
||||
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<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> = 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::<Span<'static>>::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::<Span<'static>>::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<Line<'static>> = lines
|
||||
.into_iter()
|
||||
.map(|line| truncate_line_to_width(line, inner_width))
|
||||
.collect();
|
||||
|
||||
with_border_with_inner_width(truncated_lines, inner_width)
|
||||
}
|
||||
}
|
||||
144
codex-rs/tui/src/status/format.rs
Normal file
144
codex-rs/tui/src/status/format.rs
Normal file
@@ -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<Item = &'static str>) -> 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<Span<'static>>,
|
||||
) -> Line<'static> {
|
||||
Line::from(self.full_spans(label, value_spans))
|
||||
}
|
||||
|
||||
pub(crate) fn continuation(&self, mut spans: Vec<Span<'static>>) -> 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<Span<'static>>,
|
||||
) -> Vec<Span<'static>> {
|
||||
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::<Span<'static>>::new());
|
||||
}
|
||||
|
||||
let mut used = 0usize;
|
||||
let mut spans_out: Vec<Span<'static>> = 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)
|
||||
}
|
||||
180
codex-rs/tui/src/status/helpers.rs
Normal file
180
codex-rs/tui/src/status/helpers.rs
Normal file
@@ -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<String>) {
|
||||
let mut details: Vec<String> = 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<String> = 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() {
|
||||
"<none>".to_string()
|
||||
} else {
|
||||
rels.join(", ")
|
||||
}
|
||||
}
|
||||
Err(_) => "<none>".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn compose_account_display(config: &Config) -> Option<StatusAccountDisplay> {
|
||||
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<usize>) -> 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<Local>, captured_at: DateTime<Local>) -> 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::<String>() + &rest
|
||||
}
|
||||
12
codex-rs/tui/src/status/mod.rs
Normal file
12
codex-rs/tui/src/status/mod.rs
Normal file
@@ -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;
|
||||
117
codex-rs/tui/src/status/rate_limits.rs
Normal file
117
codex-rs/tui/src/status/rate_limits.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum StatusRateLimitData {
|
||||
Available(Vec<StatusRateLimitRow>),
|
||||
Missing,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct RateLimitWindowDisplay {
|
||||
pub used_percent: f64,
|
||||
pub resets_at: Option<String>,
|
||||
}
|
||||
|
||||
impl RateLimitWindowDisplay {
|
||||
fn from_window(window: &RateLimitWindow, captured_at: DateTime<Local>) -> 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<RateLimitWindowDisplay>,
|
||||
pub secondary: Option<RateLimitWindowDisplay>,
|
||||
}
|
||||
|
||||
pub(crate) fn rate_limit_snapshot_display(
|
||||
snapshot: &RateLimitSnapshot,
|
||||
captured_at: DateTime<Local>,
|
||||
) -> 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")
|
||||
}
|
||||
@@ -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: <none> │
|
||||
│ │
|
||||
│ Token Usage: 1.9K total (1K input + 900 output) │
|
||||
│ 5h limit: [███████████████░░░░░] 72% used · resets 03:14 │
|
||||
│ Weekly limit: [█████████░░░░░░░░░░░] 45% used · resets 03:24 │
|
||||
╰───────────────────────────────────────────────────────────────────╯
|
||||
@@ -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: <none> │
|
||||
│ │
|
||||
│ Token Usage: 1.9K total (1K input + 90 │
|
||||
│ 5h limit: [███████████████░░░░░] 72% │
|
||||
│ · resets 03:14 │
|
||||
╰────────────────────────────────────────────╯
|
||||
183
codex-rs/tui/src/status/tests.rs
Normal file
183
codex-rs/tui/src/status/tests.rs
Normal file
@@ -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<String> {
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sanitize_directory(lines: Vec<String>) -> Vec<String> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user