Skip to content

Commit e363dac

Browse files
authored
revamp /status (#4196)
<img width="543" height="520" alt="image" src="https://github.com/user-attachments/assets/bbc0eec0-e40b-45e7-bcd0-a997f8eeffa2" />
1 parent 250b244 commit e363dac

13 files changed

+1044
-341
lines changed

codex-rs/tui/src/history_cell.rs

Lines changed: 43 additions & 341 deletions
Large diffs are not rendered by default.

codex-rs/tui/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ mod resume_picker;
6060
mod session_log;
6161
mod shimmer;
6262
mod slash_command;
63+
mod status;
6364
mod status_indicator_widget;
6465
mod streaming;
6566
mod text_formatting;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
source: tui/src/status.rs
3+
expression: sanitized
4+
---
5+
/status
6+
7+
╭──────────────────────────────────────────────────────────────────╮
8+
>_ OpenAI Codex (v0.0.0) │
9+
│ │
10+
Model : gpt-5-codex (reasoning high, summaries detailed) │
11+
Directory : /workspace/tests
12+
Approval : on-request
13+
Sandbox : workspace-write
14+
Agents.md : <none> │
15+
│ │
16+
│ Token Usage : 1.9K total (1K input + 900 output) │
17+
│ 5h limit : [███████████████░░░░░] 72% used · resets 03:14 │
18+
│ Weekly limit : [█████████░░░░░░░░░░░] 45% used · resets 03:24 │
19+
╰──────────────────────────────────────────────────────────────────╯
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
source: tui/src/status.rs
3+
expression: sanitized
4+
---
5+
/status
6+
7+
╭────────────────────────────────────────────╮
8+
>_ OpenAI Codex (v0.0.0) │
9+
│ │
10+
Model : gpt-5-codex (reasoning high
11+
Directory : /workspace/tests
12+
Approval : on-request
13+
Sandbox : read-only
14+
Agents.md : <none> │
15+
│ │
16+
│ Token Usage : 1.9K total (1K input + 900 │
17+
│ 5h limit : [███████████████░░░░░] 72% │
18+
│ · resets 03:14 │
19+
╰────────────────────────────────────────────╯

codex-rs/tui/src/status/account.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#[derive(Debug, Clone)]
2+
pub(crate) enum StatusAccountDisplay {
3+
ChatGpt {
4+
email: Option<String>,
5+
plan: Option<String>,
6+
},
7+
ApiKey,
8+
}

codex-rs/tui/src/status/card.rs

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
use crate::history_cell::CompositeHistoryCell;
2+
use crate::history_cell::HistoryCell;
3+
use crate::history_cell::PlainHistoryCell;
4+
use crate::history_cell::with_border_with_inner_width;
5+
use crate::version::CODEX_CLI_VERSION;
6+
use codex_common::create_config_summary_entries;
7+
use codex_core::config::Config;
8+
use codex_core::protocol::SandboxPolicy;
9+
use codex_core::protocol::TokenUsage;
10+
use codex_protocol::mcp_protocol::ConversationId;
11+
use ratatui::prelude::*;
12+
use ratatui::style::Stylize;
13+
use std::collections::BTreeSet;
14+
use std::path::PathBuf;
15+
16+
use super::account::StatusAccountDisplay;
17+
use super::format::FieldFormatter;
18+
use super::format::line_display_width;
19+
use super::format::push_label;
20+
use super::format::truncate_line_to_width;
21+
use super::helpers::compose_account_display;
22+
use super::helpers::compose_agents_summary;
23+
use super::helpers::compose_model_display;
24+
use super::helpers::format_directory_display;
25+
use super::helpers::format_tokens_compact;
26+
use super::rate_limits::RESET_BULLET;
27+
use super::rate_limits::RateLimitSnapshotDisplay;
28+
use super::rate_limits::StatusRateLimitData;
29+
use super::rate_limits::compose_rate_limit_data;
30+
use super::rate_limits::format_status_limit_summary;
31+
use super::rate_limits::render_status_limit_progress_bar;
32+
33+
#[derive(Debug, Clone)]
34+
pub(crate) struct StatusTokenUsageData {
35+
total: u64,
36+
input: u64,
37+
output: u64,
38+
}
39+
40+
#[derive(Debug)]
41+
struct StatusHistoryCell {
42+
model_name: String,
43+
model_details: Vec<String>,
44+
directory: PathBuf,
45+
approval: String,
46+
sandbox: String,
47+
agents_summary: String,
48+
account: Option<StatusAccountDisplay>,
49+
session_id: Option<String>,
50+
token_usage: StatusTokenUsageData,
51+
rate_limits: StatusRateLimitData,
52+
}
53+
54+
pub(crate) fn new_status_output(
55+
config: &Config,
56+
usage: &TokenUsage,
57+
session_id: &Option<ConversationId>,
58+
rate_limits: Option<&RateLimitSnapshotDisplay>,
59+
) -> CompositeHistoryCell {
60+
let command = PlainHistoryCell::new(vec!["/status".magenta().into()]);
61+
let card = StatusHistoryCell::new(config, usage, session_id, rate_limits);
62+
63+
CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)])
64+
}
65+
66+
impl StatusHistoryCell {
67+
fn new(
68+
config: &Config,
69+
usage: &TokenUsage,
70+
session_id: &Option<ConversationId>,
71+
rate_limits: Option<&RateLimitSnapshotDisplay>,
72+
) -> Self {
73+
let config_entries = create_config_summary_entries(config);
74+
let (model_name, model_details) = compose_model_display(config, &config_entries);
75+
let approval = config_entries
76+
.iter()
77+
.find(|(k, _)| *k == "approval")
78+
.map(|(_, v)| v.clone())
79+
.unwrap_or_else(|| "<unknown>".to_string());
80+
let sandbox = match &config.sandbox_policy {
81+
SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(),
82+
SandboxPolicy::ReadOnly => "read-only".to_string(),
83+
SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(),
84+
};
85+
let agents_summary = compose_agents_summary(config);
86+
let account = compose_account_display(config);
87+
let session_id = session_id.as_ref().map(std::string::ToString::to_string);
88+
let token_usage = StatusTokenUsageData {
89+
total: usage.blended_total(),
90+
input: usage.non_cached_input(),
91+
output: usage.output_tokens,
92+
};
93+
let rate_limits = compose_rate_limit_data(rate_limits);
94+
95+
Self {
96+
model_name,
97+
model_details,
98+
directory: config.cwd.clone(),
99+
approval,
100+
sandbox,
101+
agents_summary,
102+
account,
103+
session_id,
104+
token_usage,
105+
rate_limits,
106+
}
107+
}
108+
109+
fn token_usage_spans(&self) -> Vec<Span<'static>> {
110+
let total_fmt = format_tokens_compact(self.token_usage.total);
111+
let input_fmt = format_tokens_compact(self.token_usage.input);
112+
let output_fmt = format_tokens_compact(self.token_usage.output);
113+
114+
vec![
115+
Span::from(total_fmt),
116+
Span::from(" total "),
117+
Span::from(" (").dim(),
118+
Span::from(input_fmt).dim(),
119+
Span::from(" input").dim(),
120+
Span::from(" + ").dim(),
121+
Span::from(output_fmt).dim(),
122+
Span::from(" output").dim(),
123+
Span::from(")").dim(),
124+
]
125+
}
126+
127+
fn rate_limit_lines(
128+
&self,
129+
available_inner_width: usize,
130+
formatter: &FieldFormatter,
131+
) -> Vec<Line<'static>> {
132+
match &self.rate_limits {
133+
StatusRateLimitData::Available(rows_data) => {
134+
if rows_data.is_empty() {
135+
return vec![
136+
formatter.line("Limits", vec![Span::from("data not available yet").dim()]),
137+
];
138+
}
139+
140+
let mut lines = Vec::with_capacity(rows_data.len() * 2);
141+
142+
for row in rows_data {
143+
let value_spans = vec![
144+
Span::from(render_status_limit_progress_bar(row.percent_used)),
145+
Span::from(" "),
146+
Span::from(format_status_limit_summary(row.percent_used)),
147+
];
148+
let base_spans = formatter.full_spans(row.label, value_spans);
149+
let base_line = Line::from(base_spans.clone());
150+
151+
if let Some(resets_at) = row.resets_at.as_ref() {
152+
let resets_span =
153+
Span::from(format!("{RESET_BULLET} resets {resets_at}")).dim();
154+
let mut inline_spans = base_spans.clone();
155+
inline_spans.push(Span::from(" ").dim());
156+
inline_spans.push(resets_span.clone());
157+
158+
if line_display_width(&Line::from(inline_spans.clone()))
159+
<= available_inner_width
160+
{
161+
lines.push(Line::from(inline_spans));
162+
} else {
163+
lines.push(base_line);
164+
lines.push(formatter.continuation(vec![resets_span]));
165+
}
166+
} else {
167+
lines.push(base_line);
168+
}
169+
}
170+
171+
lines
172+
}
173+
StatusRateLimitData::Missing => {
174+
vec![formatter.line("Limits", vec![Span::from("data not available yet").dim()])]
175+
}
176+
}
177+
}
178+
179+
fn collect_rate_limit_labels(
180+
&self,
181+
seen: &mut BTreeSet<&'static str>,
182+
labels: &mut Vec<&'static str>,
183+
) {
184+
match &self.rate_limits {
185+
StatusRateLimitData::Available(rows) => {
186+
if rows.is_empty() {
187+
push_label(labels, seen, "Limits");
188+
} else {
189+
for row in rows {
190+
push_label(labels, seen, row.label);
191+
}
192+
}
193+
}
194+
StatusRateLimitData::Missing => push_label(labels, seen, "Limits"),
195+
}
196+
}
197+
}
198+
199+
impl HistoryCell for StatusHistoryCell {
200+
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
201+
let mut lines: Vec<Line<'static>> = Vec::new();
202+
lines.push(Line::from(vec![
203+
Span::from(format!("{}>_ ", FieldFormatter::INDENT)).dim(),
204+
Span::from("OpenAI Codex").bold(),
205+
Span::from(" ").dim(),
206+
Span::from(format!("(v{CODEX_CLI_VERSION})")).dim(),
207+
]));
208+
lines.push(Line::from(Vec::<Span<'static>>::new()));
209+
210+
let available_inner_width = usize::from(width.saturating_sub(4));
211+
if available_inner_width == 0 {
212+
return Vec::new();
213+
}
214+
215+
let account_value = self.account.as_ref().map(|account| match account {
216+
StatusAccountDisplay::ChatGpt { email, plan } => match (email, plan) {
217+
(Some(email), Some(plan)) => format!("{email} ({plan})"),
218+
(Some(email), None) => email.clone(),
219+
(None, Some(plan)) => plan.clone(),
220+
(None, None) => "ChatGPT".to_string(),
221+
},
222+
StatusAccountDisplay::ApiKey => {
223+
"API key configured (run codex login to use ChatGPT)".to_string()
224+
}
225+
});
226+
227+
let mut labels: Vec<&'static str> =
228+
vec!["Model", "Directory", "Approval", "Sandbox", "Agents.md"];
229+
let mut seen: BTreeSet<&'static str> = labels.iter().copied().collect();
230+
231+
if account_value.is_some() {
232+
push_label(&mut labels, &mut seen, "Account");
233+
}
234+
if self.session_id.is_some() {
235+
push_label(&mut labels, &mut seen, "Session");
236+
}
237+
push_label(&mut labels, &mut seen, "Token Usage");
238+
self.collect_rate_limit_labels(&mut seen, &mut labels);
239+
240+
let formatter = FieldFormatter::from_labels(labels.iter().copied());
241+
let value_width = formatter.value_width(available_inner_width);
242+
243+
let mut model_spans = vec![Span::from(self.model_name.clone())];
244+
if !self.model_details.is_empty() {
245+
model_spans.push(Span::from(" (").dim());
246+
model_spans.push(Span::from(self.model_details.join(", ")).dim());
247+
model_spans.push(Span::from(")").dim());
248+
}
249+
250+
let directory_value = format_directory_display(&self.directory, Some(value_width));
251+
252+
lines.push(formatter.line("Model", model_spans));
253+
lines.push(formatter.line("Directory", vec![Span::from(directory_value)]));
254+
lines.push(formatter.line("Approval", vec![Span::from(self.approval.clone())]));
255+
lines.push(formatter.line("Sandbox", vec![Span::from(self.sandbox.clone())]));
256+
lines.push(formatter.line("Agents.md", vec![Span::from(self.agents_summary.clone())]));
257+
258+
if let Some(account_value) = account_value {
259+
lines.push(formatter.line("Account", vec![Span::from(account_value)]));
260+
}
261+
262+
if let Some(session) = self.session_id.as_ref() {
263+
lines.push(formatter.line("Session", vec![Span::from(session.clone())]));
264+
}
265+
266+
lines.push(Line::from(Vec::<Span<'static>>::new()));
267+
lines.push(formatter.line("Token Usage", self.token_usage_spans()));
268+
269+
lines.extend(self.rate_limit_lines(available_inner_width, &formatter));
270+
271+
let content_width = lines.iter().map(line_display_width).max().unwrap_or(0);
272+
let inner_width = content_width.min(available_inner_width);
273+
let truncated_lines: Vec<Line<'static>> = lines
274+
.into_iter()
275+
.map(|line| truncate_line_to_width(line, inner_width))
276+
.collect();
277+
278+
with_border_with_inner_width(truncated_lines, inner_width)
279+
}
280+
}

0 commit comments

Comments
 (0)