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::line_to_static;
|
||||||
use crate::render::line_utils::prefix_lines;
|
use crate::render::line_utils::prefix_lines;
|
||||||
use crate::render::line_utils::push_owned_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::text_formatting::format_and_truncate_tool_result;
|
||||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||||
use crate::wrapping::RtOptions;
|
use crate::wrapping::RtOptions;
|
||||||
use crate::wrapping::word_wrap_line;
|
use crate::wrapping::word_wrap_line;
|
||||||
use crate::wrapping::word_wrap_lines;
|
use crate::wrapping::word_wrap_lines;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use chrono::DateTime;
|
|
||||||
use chrono::Duration as ChronoDuration;
|
|
||||||
use chrono::Local;
|
|
||||||
use codex_ansi_escape::ansi_escape_line;
|
use codex_ansi_escape::ansi_escape_line;
|
||||||
use codex_common::create_config_summary_entries;
|
|
||||||
use codex_common::elapsed::format_duration;
|
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::Config;
|
||||||
use codex_core::config_types::ReasoningSummaryFormat;
|
use codex_core::config_types::ReasoningSummaryFormat;
|
||||||
use codex_core::plan_tool::PlanItemArg;
|
use codex_core::plan_tool::PlanItemArg;
|
||||||
use codex_core::plan_tool::StepStatus;
|
use codex_core::plan_tool::StepStatus;
|
||||||
use codex_core::plan_tool::UpdatePlanArgs;
|
use codex_core::plan_tool::UpdatePlanArgs;
|
||||||
use codex_core::project_doc::discover_project_doc_paths;
|
|
||||||
use codex_core::protocol::FileChange;
|
use codex_core::protocol::FileChange;
|
||||||
use codex_core::protocol::McpInvocation;
|
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::SessionConfiguredEvent;
|
||||||
use codex_core::protocol::TokenUsage;
|
|
||||||
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
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 codex_protocol::parse_command::ParsedCommand;
|
||||||
use image::DynamicImage;
|
use image::DynamicImage;
|
||||||
use image::ImageReader;
|
use image::ImageReader;
|
||||||
@@ -51,7 +41,6 @@ use ratatui::widgets::WidgetRef;
|
|||||||
use ratatui::widgets::Wrap;
|
use ratatui::widgets::Wrap;
|
||||||
use std::any::Any;
|
use std::any::Any;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::convert::TryFrom;
|
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -60,10 +49,6 @@ use std::time::Instant;
|
|||||||
use tracing::error;
|
use tracing::error;
|
||||||
use unicode_width::UnicodeWidthStr;
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct CommandOutput {
|
pub(crate) struct CommandOutput {
|
||||||
pub(crate) exit_code: i32,
|
pub(crate) exit_code: i32,
|
||||||
@@ -229,6 +214,12 @@ pub(crate) struct PlainHistoryCell {
|
|||||||
lines: Vec<Line<'static>>,
|
lines: Vec<Line<'static>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PlainHistoryCell {
|
||||||
|
pub(crate) fn new(lines: Vec<Line<'static>>) -> Self {
|
||||||
|
Self { lines }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl HistoryCell for PlainHistoryCell {
|
impl HistoryCell for PlainHistoryCell {
|
||||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||||
self.lines.clone()
|
self.lines.clone()
|
||||||
@@ -637,9 +628,9 @@ impl HistoryCell for CompletedMcpToolCallWithImageOutput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TOOL_CALL_MAX_LINES: usize = 5;
|
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 {
|
if width < 4 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -647,8 +638,28 @@ fn card_inner_width(width: u16, max_inner_width: usize) -> Option<usize> {
|
|||||||
Some(inner_width)
|
Some(inner_width)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_border(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
|
/// Render `lines` inside a border sized to the widest span in the content.
|
||||||
let content_width = lines
|
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()
|
.iter()
|
||||||
.map(|line| {
|
.map(|line| {
|
||||||
line.iter()
|
line.iter()
|
||||||
@@ -657,6 +668,9 @@ fn with_border(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
|
|||||||
})
|
})
|
||||||
.max()
|
.max()
|
||||||
.unwrap_or(0);
|
.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 mut out = Vec::with_capacity(lines.len() + 2);
|
||||||
let border_inner_width = content_width + 2;
|
let border_inner_width = content_width + 2;
|
||||||
@@ -683,30 +697,10 @@ fn with_border(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
|
|||||||
out
|
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).
|
/// Return the emoji followed by a hair space (U+200A).
|
||||||
/// Using only the hair space avoids excessive padding after the emoji while
|
/// Using only the hair space avoids excessive padding after the emoji while
|
||||||
/// still providing a small visual gap across terminals.
|
/// 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}")
|
format!("{emoji}\u{200A}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -925,6 +919,12 @@ pub(crate) struct CompositeHistoryCell {
|
|||||||
parts: Vec<Box<dyn HistoryCell>>,
|
parts: Vec<Box<dyn HistoryCell>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl CompositeHistoryCell {
|
||||||
|
pub(crate) fn new(parts: Vec<Box<dyn HistoryCell>>) -> Self {
|
||||||
|
Self { parts }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl HistoryCell for CompositeHistoryCell {
|
impl HistoryCell for CompositeHistoryCell {
|
||||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||||
let mut out: Vec<Line<'static>> = Vec::new();
|
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`.
|
/// Render a summary of configured MCP servers from the current `Config`.
|
||||||
pub(crate) fn empty_mcp_output() -> PlainHistoryCell {
|
pub(crate) fn empty_mcp_output() -> PlainHistoryCell {
|
||||||
let lines: Vec<Line<'static>> = vec![
|
let lines: Vec<Line<'static>> = vec![
|
||||||
@@ -1760,82 +1538,6 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
|
|||||||
invocation_spans.into()
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ mod resume_picker;
|
|||||||
mod session_log;
|
mod session_log;
|
||||||
mod shimmer;
|
mod shimmer;
|
||||||
mod slash_command;
|
mod slash_command;
|
||||||
|
mod status;
|
||||||
mod status_indicator_widget;
|
mod status_indicator_widget;
|
||||||
mod streaming;
|
mod streaming;
|
||||||
mod text_formatting;
|
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