Files
codex/codex-rs/tui/src/status/card.rs
T
Felipe CouryandClaude Opus 4.6 359e17a852 fix(tui): reduce startup and new-session latency (#17039)
## TL;DR

- Fetches account/rateLimits/read asynchronously so the TUI can continue
starting without waiting for the rate-limit response.
- Fixes the /status card so it no longer leaves a stale “refreshing
cached limits...” notice in terminal history.

## Problem

The TUI bootstrap path fetched account rate limits synchronously
(`account/rateLimits/read`) before the event loop started for
ChatGPT/OpenAI-authenticated startups. This added ~670 ms of blocking
latency in the measured hot-start case, even though rate-limit data is
not needed to render the initial UI or accept user input. The delay was
especially noticeable on hot starts where every other RPC
(`account/read`, `model/list`, `thread/start`) completed in under 70 ms
total.

Moving that fetch to the background also exposed a `/status` UI bug: the
status card is flattened into terminal scrollback when it is inserted. A
transient "refreshing limits in background..." line could not be cleared
later, because the async completion updated the retained `HistoryCell`,
not the already-written terminal history.

## Mental model

Before this change, `AppServerSession::bootstrap()` performed three
sequential RPCs: `account/read` → `model/list` →
`account/rateLimits/read`. The result of the third call was baked into
`AppServerBootstrap` and applied to the chat widget before the event
loop began.

After this change, `bootstrap()` only performs two RPCs (`account/read`
+ `model/list`), and rate-limit fetching is kicked off as an async
background task immediately after the first frame is scheduled. A new
enum, `RateLimitRefreshOrigin`, tags each fetch so the event handler
knows whether the result came from the startup prefetch or from a
user-initiated `/status` command; they have different completion
side-effects.

The `get_login_status()` helper (used outside the main app flow) was
also decoupled: it previously called the full `bootstrap()` just to
check auth mode, wasting model-list and rate-limit work. It now calls
the narrower `read_account()` directly.

For `/status`, this PR keeps the background refresh request but stops
printing transient refresh notices into status history when cached
limits are already available. If a refresh updates the cache, the next
`/status` command will render the new values.

## Non-goals

- This change does not alter the rate-limit data itself.
- This change does not introduce caching, retries, or staleness
management for rate limits.
- This change does not affect the `model/list` or `thread/start` RPCs;
they remain on the critical startup path.

## Tradeoffs

- **Stale-on-first-render**: The status bar will briefly show no
rate-limit info until the background fetch completes; observed
background fetches landed roughly in the 400-900 ms range after the UI
appeared. This is acceptable because the user cannot meaningfully act on
rate-limit data in the first fraction of a second.
- **Error silence on startup prefetch**: If the startup prefetch fails,
the error is logged but the UI is not notified (unlike `/status` refresh
failures, which go through the status-command completion path). This
avoids surfacing transient network errors as a startup blocker.
- **Static `/status` history**: `/status` output is terminal history,
not a live widget. The card now avoids progress-style language that
would appear stuck in scrollback; users can run `/status` again to see
newly cached values.
- **`account_auth_mode` field removed from `AppServerBootstrap`**: The
only consumer was `get_login_status()`, which no longer goes through
`bootstrap()`. The field was dead weight.

## Architecture

### New types

- `RateLimitRefreshOrigin` (in `app_event.rs`): A `Copy` enum
distinguishing `StartupPrefetch` from `StatusCommand { request_id }`.
Carried through `RefreshRateLimits` and `RateLimitsLoaded` events so the
handler applies the right completion behavior.

### Modified types

- `AppServerBootstrap`: Lost `account_auth_mode` and
`rate_limit_snapshots`; gained `requires_openai_auth: bool` (passed
through from the account response so the caller can decide whether to
fire the prefetch).

### Control flow

1. `bootstrap()` returns with `requires_openai_auth` and
`has_chatgpt_account`.
2. After scheduling the first frame, `App::run_inner` fires
`refresh_rate_limits(StartupPrefetch)` if both flags are true.
3. When `RateLimitsLoaded { StartupPrefetch, Ok(..) }` arrives,
snapshots are applied and a frame is scheduled to repaint the status
bar.
4. When `RateLimitsLoaded { StartupPrefetch, Err(..) }` arrives, the
error is logged and no UI update occurs.
5. `/status`-initiated refreshes continue to use `StatusCommand {
request_id }` and call `finish_status_rate_limit_refresh` on completion
(success or failure).
6. `/status` history cells with cached rate-limit rows no longer render
an additional "refreshing limits" notice; the async refresh updates the
cache for future status output.

### Extracted method

- `AppServerSession::read_account()`: Factored out of `bootstrap()` so
that `get_login_status()` can call it independently without triggering
model-list or rate-limit work.

## Observability

- The existing `tracing::warn!` for rate-limit fetch failures is
preserved for the startup path.
- No new metrics or spans are introduced. The startup-time improvement
is observable via the existing `ready` timestamp in TUI startup logs.

## Tests

- Existing tests in `status_command_tests.rs` are updated to match on
`RateLimitRefreshOrigin::StatusCommand { request_id }` instead of a bare
`request_id`.
- Focused `/status` tests now assert that status history avoids
transient refresh text, continues to request an async refresh, and uses
refreshed cached limits in future status output.
- No new tests are added for the startup prefetch path because it is a
fire-and-forget spawn with no observable side-effect other than the
widget state update, which is already covered by the
snapshot-application tests.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:16:09 -03:00

724 lines
26 KiB
Rust

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 chrono::DateTime;
use chrono::Local;
use codex_core::config::Config;
use codex_model_provider_info::WireApi;
use codex_protocol::ThreadId;
use codex_protocol::account::PlanType;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::NetworkAccess;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_utils_sandbox_summary::summarize_sandbox_policy;
use ratatui::prelude::*;
use ratatui::style::Stylize;
use std::collections::BTreeSet;
use std::path::PathBuf;
use url::Url;
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_model_display;
use super::helpers::format_directory_display;
use super::helpers::format_tokens_compact;
use super::rate_limits::RateLimitSnapshotDisplay;
use super::rate_limits::StatusRateLimitData;
use super::rate_limits::StatusRateLimitRow;
use super::rate_limits::StatusRateLimitValue;
use super::rate_limits::compose_rate_limit_data;
use super::rate_limits::compose_rate_limit_data_many;
use super::rate_limits::format_status_limit_summary;
use super::rate_limits::render_status_limit_progress_bar;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_lines;
use std::sync::Arc;
use std::sync::RwLock;
#[derive(Debug, Clone)]
struct StatusContextWindowData {
percent_remaining: i64,
tokens_in_context: i64,
window: i64,
}
#[derive(Debug, Clone)]
pub(crate) struct StatusTokenUsageData {
total: i64,
input: i64,
output: i64,
context_window: Option<StatusContextWindowData>,
}
#[derive(Debug)]
struct StatusRateLimitState {
rate_limits: StatusRateLimitData,
refreshing_rate_limits: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct StatusHistoryHandle {
agents_summary: Arc<RwLock<String>>,
rate_limit_state: Arc<RwLock<StatusRateLimitState>>,
}
impl StatusHistoryHandle {
pub(crate) fn finish_agents_summary_discovery(&self, agents_summary: String) {
#[expect(clippy::expect_used)]
let mut current = self
.agents_summary
.write()
.expect("status history agents summary state poisoned");
*current = agents_summary;
}
pub(crate) fn finish_rate_limit_refresh(
&self,
rate_limits: &[RateLimitSnapshotDisplay],
now: DateTime<Local>,
) {
let rate_limits = if rate_limits.len() <= 1 {
compose_rate_limit_data(rate_limits.first(), now)
} else {
compose_rate_limit_data_many(rate_limits, now)
};
#[expect(clippy::expect_used)]
let mut state = self
.rate_limit_state
.write()
.expect("status history rate-limit state poisoned");
state.rate_limits = rate_limits;
state.refreshing_rate_limits = false;
}
}
#[derive(Debug)]
struct StatusHistoryCell {
model_name: String,
model_details: Vec<String>,
directory: PathBuf,
permissions: String,
agents_summary: Arc<RwLock<String>>,
collaboration_mode: Option<String>,
model_provider: Option<String>,
account: Option<StatusAccountDisplay>,
thread_name: Option<String>,
session_id: Option<String>,
forked_from: Option<String>,
token_usage: StatusTokenUsageData,
rate_limit_state: Arc<RwLock<StatusRateLimitState>>,
}
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
pub(crate) fn new_status_output(
config: &Config,
account_display: Option<&StatusAccountDisplay>,
token_info: Option<&TokenUsageInfo>,
total_usage: &TokenUsage,
session_id: &Option<ThreadId>,
thread_name: Option<String>,
forked_from: Option<ThreadId>,
rate_limits: Option<&RateLimitSnapshotDisplay>,
_plan_type: Option<PlanType>,
now: DateTime<Local>,
model_name: &str,
collaboration_mode: Option<&str>,
reasoning_effort_override: Option<Option<ReasoningEffort>>,
) -> CompositeHistoryCell {
let snapshots = rate_limits.map(std::slice::from_ref).unwrap_or_default();
new_status_output_with_rate_limits(
config,
account_display,
token_info,
total_usage,
session_id,
thread_name,
forked_from,
snapshots,
_plan_type,
now,
model_name,
collaboration_mode,
reasoning_effort_override,
/*refreshing_rate_limits*/ false,
)
}
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
pub(crate) fn new_status_output_with_rate_limits(
config: &Config,
account_display: Option<&StatusAccountDisplay>,
token_info: Option<&TokenUsageInfo>,
total_usage: &TokenUsage,
session_id: &Option<ThreadId>,
thread_name: Option<String>,
forked_from: Option<ThreadId>,
rate_limits: &[RateLimitSnapshotDisplay],
_plan_type: Option<PlanType>,
now: DateTime<Local>,
model_name: &str,
collaboration_mode: Option<&str>,
reasoning_effort_override: Option<Option<ReasoningEffort>>,
refreshing_rate_limits: bool,
) -> CompositeHistoryCell {
new_status_output_with_rate_limits_handle(
config,
account_display,
token_info,
total_usage,
session_id,
thread_name,
forked_from,
rate_limits,
_plan_type,
now,
model_name,
collaboration_mode,
reasoning_effort_override,
"<none>".to_string(),
refreshing_rate_limits,
)
.0
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn new_status_output_with_rate_limits_handle(
config: &Config,
account_display: Option<&StatusAccountDisplay>,
token_info: Option<&TokenUsageInfo>,
total_usage: &TokenUsage,
session_id: &Option<ThreadId>,
thread_name: Option<String>,
forked_from: Option<ThreadId>,
rate_limits: &[RateLimitSnapshotDisplay],
_plan_type: Option<PlanType>,
now: DateTime<Local>,
model_name: &str,
collaboration_mode: Option<&str>,
reasoning_effort_override: Option<Option<ReasoningEffort>>,
agents_summary: String,
refreshing_rate_limits: bool,
) -> (CompositeHistoryCell, StatusHistoryHandle) {
let command = PlainHistoryCell::new(vec!["/status".magenta().into()]);
let (card, handle) = StatusHistoryCell::new(
config,
account_display,
token_info,
total_usage,
session_id,
thread_name,
forked_from,
rate_limits,
_plan_type,
now,
model_name,
collaboration_mode,
reasoning_effort_override,
agents_summary,
refreshing_rate_limits,
);
(
CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]),
handle,
)
}
impl StatusHistoryCell {
#[allow(clippy::too_many_arguments)]
fn new(
config: &Config,
account_display: Option<&StatusAccountDisplay>,
token_info: Option<&TokenUsageInfo>,
total_usage: &TokenUsage,
session_id: &Option<ThreadId>,
thread_name: Option<String>,
forked_from: Option<ThreadId>,
rate_limits: &[RateLimitSnapshotDisplay],
_plan_type: Option<PlanType>,
now: DateTime<Local>,
model_name: &str,
collaboration_mode: Option<&str>,
reasoning_effort_override: Option<Option<ReasoningEffort>>,
agents_summary: String,
refreshing_rate_limits: bool,
) -> (Self, StatusHistoryHandle) {
let mut config_entries = vec![
("workdir", config.cwd.display().to_string()),
("model", model_name.to_string()),
("provider", config.model_provider_id.clone()),
(
"approval",
config.permissions.approval_policy.value().to_string(),
),
(
"sandbox",
summarize_sandbox_policy(config.permissions.sandbox_policy.get()),
),
];
if config.model_provider.wire_api == WireApi::Responses {
let effort_value = reasoning_effort_override
.unwrap_or(None)
.map(|effort| effort.to_string())
.unwrap_or_else(|| "none".to_string());
config_entries.push(("reasoning effort", effort_value));
config_entries.push((
"reasoning summaries",
config
.model_reasoning_summary
.map(|summary| summary.to_string())
.unwrap_or_else(|| "auto".to_string()),
));
}
let (model_name, model_details) = compose_model_display(model_name, &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.permissions.sandbox_policy.get() {
SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(),
SandboxPolicy::ReadOnly { .. } => "read-only".to_string(),
SandboxPolicy::WorkspaceWrite {
network_access: true,
..
} => "workspace-write with network access".to_string(),
SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(),
SandboxPolicy::ExternalSandbox { network_access } => {
if matches!(network_access, NetworkAccess::Enabled) {
"external-sandbox (network access enabled)".to_string()
} else {
"external-sandbox".to_string()
}
}
};
let permissions = if config.permissions.approval_policy.value() == AskForApproval::OnRequest
&& *config.permissions.sandbox_policy.get()
== SandboxPolicy::new_workspace_write_policy()
{
"Default".to_string()
} else if config.permissions.approval_policy.value() == AskForApproval::Never
&& *config.permissions.sandbox_policy.get() == SandboxPolicy::DangerFullAccess
{
"Full Access".to_string()
} else {
format!("Custom ({sandbox}, {approval})")
};
let model_provider = format_model_provider(config);
let account = compose_account_display(account_display);
let session_id = session_id.as_ref().map(std::string::ToString::to_string);
let forked_from = forked_from.map(|id| id.to_string());
let default_usage = TokenUsage::default();
let (context_usage, context_window) = match token_info {
Some(info) => (&info.last_token_usage, info.model_context_window),
None => (&default_usage, config.model_context_window),
};
let context_window = context_window.map(|window| StatusContextWindowData {
percent_remaining: context_usage.percent_of_context_window_remaining(window),
tokens_in_context: context_usage.tokens_in_context_window(),
window,
});
let token_usage = StatusTokenUsageData {
total: total_usage.blended_total(),
input: total_usage.non_cached_input(),
output: total_usage.output_tokens,
context_window,
};
let rate_limits = if rate_limits.len() <= 1 {
compose_rate_limit_data(rate_limits.first(), now)
} else {
compose_rate_limit_data_many(rate_limits, now)
};
let rate_limit_state = Arc::new(RwLock::new(StatusRateLimitState {
rate_limits,
refreshing_rate_limits,
}));
let agents_summary = Arc::new(RwLock::new(agents_summary));
(
Self {
model_name,
model_details,
directory: config.cwd.to_path_buf(),
permissions,
collaboration_mode: collaboration_mode.map(ToString::to_string),
model_provider,
account,
thread_name,
session_id,
forked_from,
token_usage,
agents_summary: agents_summary.clone(),
rate_limit_state: rate_limit_state.clone(),
},
StatusHistoryHandle {
agents_summary,
rate_limit_state,
},
)
}
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 context_window_spans(&self) -> Option<Vec<Span<'static>>> {
let context = self.token_usage.context_window.as_ref()?;
let percent = context.percent_remaining;
let used_fmt = format_tokens_compact(context.tokens_in_context);
let window_fmt = format_tokens_compact(context.window);
Some(vec![
Span::from(format!("{percent}% left")),
Span::from(" (").dim(),
Span::from(used_fmt).dim(),
Span::from(" used / ").dim(),
Span::from(window_fmt).dim(),
Span::from(")").dim(),
])
}
fn rate_limit_lines(
&self,
state: &StatusRateLimitState,
available_inner_width: usize,
formatter: &FieldFormatter,
) -> Vec<Line<'static>> {
match &state.rate_limits {
StatusRateLimitData::Available(rows_data) => {
if rows_data.is_empty() {
return vec![formatter.line(
"Limits",
vec![Span::from("not available for this account").dim()],
)];
}
self.rate_limit_row_lines(rows_data, available_inner_width, formatter)
}
StatusRateLimitData::Stale(rows_data) => {
let mut lines =
self.rate_limit_row_lines(rows_data, available_inner_width, formatter);
lines.push(formatter.line(
"Warning",
vec![Span::from(if state.refreshing_rate_limits {
"limits may be stale - run /status again shortly."
} else {
"limits may be stale - start new turn to refresh."
})
.dim()],
));
lines
}
StatusRateLimitData::Unavailable => {
vec![formatter.line(
"Limits",
vec![Span::from("not available for this account").dim()],
)]
}
StatusRateLimitData::Missing => {
vec![formatter.line(
"Limits",
vec![Span::from(if state.refreshing_rate_limits {
"refresh requested; run /status again shortly."
} else {
"data not available yet"
})
.dim()],
)]
}
}
}
fn rate_limit_row_lines(
&self,
rows: &[StatusRateLimitRow],
available_inner_width: usize,
formatter: &FieldFormatter,
) -> Vec<Line<'static>> {
let mut lines = Vec::with_capacity(rows.len().saturating_mul(2));
for row in rows {
match &row.value {
StatusRateLimitValue::Window {
percent_used,
resets_at,
} => {
let percent_remaining = (100.0 - percent_used).clamp(0.0, 100.0);
let value_spans = vec![
Span::from(render_status_limit_progress_bar(percent_remaining)),
Span::from(" "),
Span::from(format_status_limit_summary(percent_remaining)),
];
let base_spans = formatter.full_spans(row.label.as_str(), value_spans);
let base_line = Line::from(base_spans.clone());
if let Some(resets_at) = resets_at.as_ref() {
let resets_span = Span::from(format!("(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);
}
}
StatusRateLimitValue::Text(text) => {
let label = row.label.clone();
let spans =
formatter.full_spans(label.as_str(), vec![Span::from(text.clone())]);
lines.push(Line::from(spans));
}
}
}
lines
}
fn collect_rate_limit_labels(
&self,
state: &StatusRateLimitState,
seen: &mut BTreeSet<String>,
labels: &mut Vec<String>,
) {
match &state.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.as_str());
}
}
}
StatusRateLimitData::Stale(rows) => {
for row in rows {
push_label(labels, seen, row.label.as_str());
}
push_label(labels, seen, "Warning");
}
StatusRateLimitData::Unavailable => push_label(labels, seen, "Limits"),
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<String> = vec!["Model", "Directory", "Permissions", "Agents.md"]
.into_iter()
.map(str::to_string)
.collect();
let mut seen: BTreeSet<String> = labels.iter().cloned().collect();
let thread_name = self.thread_name.as_deref().filter(|name| !name.is_empty());
#[expect(clippy::expect_used)]
let rate_limit_state = self
.rate_limit_state
.read()
.expect("status history rate-limit state poisoned");
#[expect(clippy::expect_used)]
let agents_summary = self
.agents_summary
.read()
.expect("status history agents summary state poisoned")
.clone();
if self.model_provider.is_some() {
push_label(&mut labels, &mut seen, "Model provider");
}
if account_value.is_some() {
push_label(&mut labels, &mut seen, "Account");
}
if thread_name.is_some() {
push_label(&mut labels, &mut seen, "Thread name");
}
if self.session_id.is_some() {
push_label(&mut labels, &mut seen, "Session");
}
if self.session_id.is_some() && self.forked_from.is_some() {
push_label(&mut labels, &mut seen, "Forked from");
}
if self.collaboration_mode.is_some() {
push_label(&mut labels, &mut seen, "Collaboration mode");
}
push_label(&mut labels, &mut seen, "Token usage");
if self.token_usage.context_window.is_some() {
push_label(&mut labels, &mut seen, "Context window");
}
self.collect_rate_limit_labels(&rate_limit_state, &mut seen, &mut labels);
let formatter = FieldFormatter::from_labels(labels.iter().map(String::as_str));
let value_width = formatter.value_width(available_inner_width);
let note_first_line = Line::from(vec![
Span::from("Visit ").cyan(),
"https://chatgpt.com/codex/settings/usage"
.cyan()
.underlined(),
Span::from(" for up-to-date").cyan(),
]);
let note_second_line = Line::from(vec![
Span::from("information on rate limits and credits").cyan(),
]);
let note_lines = adaptive_wrap_lines(
[note_first_line, note_second_line],
RtOptions::new(available_inner_width),
);
lines.extend(note_lines);
lines.push(Line::from(Vec::<Span<'static>>::new()));
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));
if let Some(model_provider) = self.model_provider.as_ref() {
lines.push(formatter.line("Model provider", vec![Span::from(model_provider.clone())]));
}
lines.push(formatter.line("Directory", vec![Span::from(directory_value)]));
lines.push(formatter.line("Permissions", vec![Span::from(self.permissions.clone())]));
lines.push(formatter.line("Agents.md", vec![Span::from(agents_summary)]));
if let Some(account_value) = account_value {
lines.push(formatter.line("Account", vec![Span::from(account_value)]));
}
if let Some(thread_name) = thread_name {
lines.push(formatter.line("Thread name", vec![Span::from(thread_name.to_string())]));
}
if let Some(collab_mode) = self.collaboration_mode.as_ref() {
lines.push(formatter.line("Collaboration mode", vec![Span::from(collab_mode.clone())]));
}
if let Some(session) = self.session_id.as_ref() {
lines.push(formatter.line("Session", vec![Span::from(session.clone())]));
}
if self.session_id.is_some()
&& let Some(forked_from) = self.forked_from.as_ref()
{
lines.push(formatter.line("Forked from", vec![Span::from(forked_from.clone())]));
}
lines.push(Line::from(Vec::<Span<'static>>::new()));
// Hide token usage only for ChatGPT subscribers
if !matches!(self.account, Some(StatusAccountDisplay::ChatGpt { .. })) {
lines.push(formatter.line("Token usage", self.token_usage_spans()));
}
if let Some(spans) = self.context_window_spans() {
lines.push(formatter.line("Context window", spans));
}
lines.extend(self.rate_limit_lines(&rate_limit_state, 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)
}
}
fn format_model_provider(config: &Config) -> Option<String> {
let provider = &config.model_provider;
let name = provider.name.trim();
let provider_name = if name.is_empty() {
config.model_provider_id.as_str()
} else {
name
};
let base_url = provider.base_url.as_deref().and_then(sanitize_base_url);
let is_default_openai = provider.is_openai() && base_url.is_none();
if is_default_openai {
return None;
}
Some(match base_url {
Some(base_url) => format!("{provider_name} - {base_url}"),
None => provider_name.to_string(),
})
}
fn sanitize_base_url(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let Ok(mut url) = Url::parse(trimmed) else {
return None;
};
let _ = url.set_username("");
let _ = url.set_password(None);
url.set_query(None);
url.set_fragment(None);
Some(url.to_string().trim_end_matches('/').to_string()).filter(|value| !value.is_empty())
}