|
| 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