mirror of
https://github.com/openai/codex.git
synced 2026-10-26 18:38:47 +00:00
## Why TUI preferences and persistence paths belong to the local client, while thread configuration and account requirements may come from the active app server. Keeping both in the same resolved `Config` can replace live local preferences when a thread is resumed, forked, reconnected, or switched. ## What changed - Add `LocalSettings` as the TUI-owned source for UI, history, notice, and local persistence settings, and preserve it across session lifecycle transitions. - Write preference changes to the selected user config file and reload local settings from disk when changing roots. - Use the app server's account response for authentication-dependent onboarding and status UI instead of the local model-provider configuration. - Retain platform family and OS metadata reported by remote app servers. ## Testing - Cover local setting defaults, overrides, persistence destinations, reloads, and preservation across widget replacement and root switching. - Cover remote platform metadata and server-controlled authentication UI. GitOrigin-RevId: dbc2965fb1d6cbecb3f973f6133f3dcd4467b753
974 lines
36 KiB
Rust
974 lines
36 KiB
Rust
use crate::history_cell::CompositeHistoryCell;
|
|
use crate::history_cell::HistoryCell;
|
|
use crate::history_cell::PlainHistoryCell;
|
|
use crate::history_cell::plain_lines;
|
|
use crate::history_cell::with_border_with_inner_width;
|
|
use crate::legacy_core::config::Config;
|
|
use crate::line_truncation::line_width;
|
|
use crate::token_usage::TokenUsage;
|
|
use crate::token_usage::TokenUsageInfo;
|
|
use crate::version::CODEX_CLI_VERSION;
|
|
use crate::width::display_width;
|
|
use chrono::DateTime;
|
|
use chrono::Local;
|
|
use codex_app_server_protocol::AskForApproval;
|
|
use codex_model_provider_info::WireApi;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::account::PlanType;
|
|
use codex_protocol::config_types::ApprovalsReviewer;
|
|
use codex_protocol::models::ActivePermissionProfile;
|
|
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
|
|
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
|
|
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::openai_models::ReasoningEffort;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_sandbox_summary::summarize_permission_profile;
|
|
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::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 super::remote_connection::RemoteConnectionStatus;
|
|
use super::thread_usage::StatusThreadUsage;
|
|
use crate::wrapping::RtOptions;
|
|
use crate::wrapping::adaptive_wrap_lines;
|
|
use crate::wrapping::word_wrap_lines;
|
|
use std::sync::Arc;
|
|
use std::sync::RwLock;
|
|
|
|
const CHATGPT_USAGE_URL: &str = "https://chatgpt.com/codex/settings/usage";
|
|
|
|
#[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 {
|
|
rate_limit_state: Arc<RwLock<StatusRateLimitState>>,
|
|
thread_usage: StatusThreadUsage,
|
|
}
|
|
|
|
impl StatusHistoryHandle {
|
|
pub(crate) fn reserve_thread_usage_label_width(&self) {
|
|
self.thread_usage.reserve_label_width();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
pub(crate) fn set_thread_usage(
|
|
&self,
|
|
estimate: Option<codex_app_server_protocol::ThreadUsage>,
|
|
) {
|
|
self.thread_usage.set_estimate(estimate);
|
|
}
|
|
}
|
|
|
|
#[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>,
|
|
remote_connection: Option<RemoteConnectionStatus>,
|
|
show_chatgpt_usage_link: bool,
|
|
account: Option<StatusAccountDisplay>,
|
|
thread_name: Option<String>,
|
|
session_id: Option<String>,
|
|
forked_from: Option<String>,
|
|
token_usage: StatusTokenUsageData,
|
|
rate_limit_state: Arc<RwLock<StatusRateLimitState>>,
|
|
thread_usage: StatusThreadUsage,
|
|
}
|
|
|
|
#[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,
|
|
config.model_provider.requires_openai_auth,
|
|
/*runtime_model_provider_base_url*/ None,
|
|
/*remote_connection*/ None,
|
|
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,
|
|
requires_openai_auth: bool,
|
|
runtime_model_provider_base_url: Option<&str>,
|
|
remote_connection: Option<&RemoteConnectionStatus>,
|
|
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,
|
|
requires_openai_auth,
|
|
runtime_model_provider_base_url,
|
|
remote_connection,
|
|
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,
|
|
requires_openai_auth: bool,
|
|
runtime_model_provider_base_url: Option<&str>,
|
|
remote_connection: Option<&RemoteConnectionStatus>,
|
|
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 approval_policy = AskForApproval::from(config.permissions.approval_policy.value());
|
|
let permission_profile = config.permissions.effective_permission_profile();
|
|
let workspace_roots = config.effective_workspace_roots();
|
|
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_permission_profile(
|
|
&permission_profile,
|
|
&config.cwd,
|
|
workspace_roots.as_slice(),
|
|
),
|
|
),
|
|
];
|
|
if config.model_provider.wire_api == WireApi::Responses {
|
|
let effort_value = reasoning_effort_override
|
|
.unwrap_or_else(|| config.model_reasoning_effort.clone())
|
|
.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 active_permission_profile = config.permissions.active_permission_profile();
|
|
let sandbox =
|
|
status_permission_summary(&permission_profile, &config.cwd, workspace_roots.as_slice());
|
|
let workspace_root_suffix = workspace_root_suffix(workspace_roots.as_slice(), &config.cwd);
|
|
let approval = status_approval_label(approval_policy, config.approvals_reviewer, &approval);
|
|
let permissions = status_permissions_label(
|
|
active_permission_profile.as_ref(),
|
|
&permission_profile,
|
|
approval_policy,
|
|
&sandbox,
|
|
&approval,
|
|
workspace_root_suffix.as_deref(),
|
|
);
|
|
let model_provider = format_model_provider(config, runtime_model_provider_base_url);
|
|
let show_chatgpt_usage_link = requires_openai_auth;
|
|
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));
|
|
let thread_usage = StatusThreadUsage::default();
|
|
|
|
(
|
|
Self {
|
|
model_name,
|
|
model_details,
|
|
directory: config.cwd.to_path_buf(),
|
|
permissions,
|
|
collaboration_mode: collaboration_mode.map(ToString::to_string),
|
|
model_provider,
|
|
remote_connection: remote_connection.cloned(),
|
|
show_chatgpt_usage_link,
|
|
account,
|
|
thread_name,
|
|
session_id,
|
|
forked_from,
|
|
token_usage,
|
|
agents_summary,
|
|
rate_limit_state: rate_limit_state.clone(),
|
|
thread_usage: thread_usage.clone(),
|
|
},
|
|
StatusHistoryHandle {
|
|
rate_limit_state,
|
|
thread_usage,
|
|
},
|
|
)
|
|
}
|
|
|
|
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,
|
|
details,
|
|
} => {
|
|
let percent_remaining = (100.0 - percent_used).clamp(0.0, 100.0);
|
|
let summary = format_status_limit_summary(percent_remaining);
|
|
let full_value_spans = vec![
|
|
Span::from(render_status_limit_progress_bar(percent_remaining)),
|
|
Span::from(" "),
|
|
Span::from(summary.clone()),
|
|
];
|
|
// On narrow terminals, keep the percentage visible rather than
|
|
// letting the fixed-width progress bar crowd out the reset time.
|
|
let value_spans = if line_width(&Line::from(full_value_spans.clone()))
|
|
<= formatter.value_width(available_inner_width)
|
|
{
|
|
full_value_spans
|
|
} else {
|
|
vec![Span::from(summary)]
|
|
};
|
|
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_width(&Line::from(inline_spans.clone())) <= available_inner_width {
|
|
lines.push(Line::from(inline_spans));
|
|
} else {
|
|
lines.push(base_line);
|
|
let reset_text = format!("(resets {resets_at})");
|
|
let reset_width = formatter.value_width(available_inner_width).max(1);
|
|
let wrap_options =
|
|
textwrap::Options::new(reset_width).break_words(false);
|
|
// Reset timestamps are the actionable part of this row, so wrap them
|
|
// onto continuation lines instead of truncating partial times/dates.
|
|
lines.extend(
|
|
textwrap::wrap(reset_text.as_str(), wrap_options)
|
|
.into_iter()
|
|
.map(|wrapped| {
|
|
formatter.continuation(vec![
|
|
Span::from(wrapped.into_owned()).dim(),
|
|
])
|
|
}),
|
|
);
|
|
}
|
|
} else {
|
|
lines.push(base_line);
|
|
}
|
|
if let Some(details) = details {
|
|
let detail_width = formatter.value_width(available_inner_width).max(1);
|
|
let wrap_options = textwrap::Options::new(detail_width).break_words(false);
|
|
lines.extend(
|
|
textwrap::wrap(details.as_str(), wrap_options)
|
|
.into_iter()
|
|
.map(|wrapped| {
|
|
formatter
|
|
.continuation(vec![Span::from(wrapped.into_owned()).dim()])
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
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"),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn status_permission_summary(
|
|
permission_profile: &PermissionProfile,
|
|
cwd: &AbsolutePathBuf,
|
|
workspace_roots: &[AbsolutePathBuf],
|
|
) -> String {
|
|
let summary = summarize_permission_profile(permission_profile, cwd, workspace_roots);
|
|
if let Some(details) = summary.strip_prefix("read-only") {
|
|
if details.contains("(network access enabled)") {
|
|
return "read-only with network access".to_string();
|
|
}
|
|
return "read-only".to_string();
|
|
}
|
|
if let Some(details) = summary.strip_prefix("workspace-write") {
|
|
if details.contains("(network access enabled)") {
|
|
return "workspace with network access".to_string();
|
|
}
|
|
return "workspace".to_string();
|
|
}
|
|
if summary == "custom permissions (network access enabled)" {
|
|
return "custom permissions with network access".to_string();
|
|
}
|
|
summary
|
|
}
|
|
|
|
fn workspace_root_suffix(
|
|
workspace_roots: &[AbsolutePathBuf],
|
|
cwd: &AbsolutePathBuf,
|
|
) -> Option<String> {
|
|
let extra_roots = workspace_roots
|
|
.iter()
|
|
.filter(|root| *root != cwd)
|
|
.map(|root| root.to_string_lossy().to_string())
|
|
.collect::<Vec<_>>();
|
|
if extra_roots.is_empty() {
|
|
None
|
|
} else {
|
|
Some(format!(" [{}]", extra_roots.join(", ")))
|
|
}
|
|
}
|
|
|
|
fn status_permissions_label(
|
|
active_permission_profile: Option<&ActivePermissionProfile>,
|
|
permission_profile: &PermissionProfile,
|
|
approval_policy: AskForApproval,
|
|
sandbox: &str,
|
|
approval: &str,
|
|
workspace_root_suffix: Option<&str>,
|
|
) -> String {
|
|
let active_id = active_permission_profile.map(|active| active.id.as_str());
|
|
match active_id {
|
|
Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY) => {
|
|
let label = if sandbox == "read-only with network access" {
|
|
"Read Only with network access"
|
|
} else {
|
|
"Read Only"
|
|
};
|
|
return format!("{label} ({approval})");
|
|
}
|
|
Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE) => match sandbox {
|
|
"workspace" => {
|
|
return format!(
|
|
"Workspace{} ({approval})",
|
|
workspace_root_suffix.unwrap_or("")
|
|
);
|
|
}
|
|
"workspace with network access" => {
|
|
return format!(
|
|
"Workspace with network access{} ({approval})",
|
|
workspace_root_suffix.unwrap_or("")
|
|
);
|
|
}
|
|
_ => {}
|
|
},
|
|
Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS)
|
|
if permission_profile == &PermissionProfile::Disabled =>
|
|
{
|
|
return if approval_policy == AskForApproval::Never {
|
|
"Full Access".to_string()
|
|
} else {
|
|
format!("No Sandbox ({approval})")
|
|
};
|
|
}
|
|
Some(id) => {
|
|
let sandbox = decorate_workspace_sandbox_label(sandbox, workspace_root_suffix);
|
|
return format!("Profile {id} ({sandbox}, {approval})");
|
|
}
|
|
None => {}
|
|
}
|
|
|
|
if sandbox == "read-only" {
|
|
return format!("Read Only ({approval})");
|
|
}
|
|
if approval_policy == AskForApproval::OnRequest && sandbox == "workspace" {
|
|
return format!(
|
|
"Workspace{} ({approval})",
|
|
workspace_root_suffix.unwrap_or("")
|
|
);
|
|
}
|
|
if approval_policy == AskForApproval::Never
|
|
&& permission_profile == &PermissionProfile::Disabled
|
|
{
|
|
return "Full Access".to_string();
|
|
}
|
|
let sandbox = decorate_workspace_sandbox_label(sandbox, workspace_root_suffix);
|
|
format!("Custom ({sandbox}, {approval})")
|
|
}
|
|
|
|
fn decorate_workspace_sandbox_label(sandbox: &str, workspace_root_suffix: Option<&str>) -> String {
|
|
match workspace_root_suffix {
|
|
Some(suffix) if sandbox.starts_with("workspace") => format!("{sandbox}{suffix}"),
|
|
_ => sandbox.to_string(),
|
|
}
|
|
}
|
|
|
|
fn status_approval_label(
|
|
approval_policy: AskForApproval,
|
|
approvals_reviewer: ApprovalsReviewer,
|
|
approval: &str,
|
|
) -> String {
|
|
if approval_policy == AskForApproval::OnRequest {
|
|
return match approvals_reviewer {
|
|
ApprovalsReviewer::AutoReview => "Approve for me".to_string(),
|
|
ApprovalsReviewer::User => "Ask for approval".to_string(),
|
|
};
|
|
}
|
|
|
|
approval.to_string()
|
|
}
|
|
|
|
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(),
|
|
]));
|
|
|
|
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);
|
|
self.thread_usage.push_labels(&mut labels, &mut seen);
|
|
|
|
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(),
|
|
CHATGPT_USAGE_URL.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.push(Line::from(Vec::<Span<'static>>::new()));
|
|
// The ChatGPT usage page only applies to providers backed by OpenAI auth;
|
|
// providers like Bedrock manage limits and billing elsewhere.
|
|
if self.show_chatgpt_usage_link {
|
|
lines.extend(note_lines);
|
|
lines.push(Line::from(Vec::<Span<'static>>::new()));
|
|
}
|
|
if let Some(remote_connection) = self.remote_connection.as_ref() {
|
|
let wrapped_remote = word_wrap_lines(
|
|
[Line::from(vec![
|
|
Span::from(remote_connection.address.clone()),
|
|
Span::from(" (").dim(),
|
|
Span::from(remote_connection.version.clone()).dim(),
|
|
Span::from(")").dim(),
|
|
])],
|
|
RtOptions::new(value_width.max(1)),
|
|
);
|
|
let mut wrapped_remote = wrapped_remote.into_iter();
|
|
if let Some(first) = wrapped_remote.next() {
|
|
lines.push(formatter.line("Remote", first.spans));
|
|
lines.extend(wrapped_remote.map(|line| formatter.continuation(line.spans)));
|
|
}
|
|
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 thread_usage_lines = self.thread_usage.lines(&formatter, value_width);
|
|
if !thread_usage_lines.is_empty() {
|
|
lines.push(Line::from(Vec::<Span<'static>>::new()));
|
|
lines.extend(thread_usage_lines);
|
|
}
|
|
|
|
let content_width = lines.iter().map(line_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 raw_lines(&self) -> Vec<Line<'static>> {
|
|
plain_lines(self.display_lines(u16::MAX))
|
|
}
|
|
|
|
fn display_hyperlink_lines(
|
|
&self,
|
|
width: u16,
|
|
) -> Vec<crate::terminal_hyperlinks::HyperlinkLine> {
|
|
let mut lines =
|
|
crate::terminal_hyperlinks::plain_hyperlink_lines(self.display_lines(width));
|
|
for line in &mut lines {
|
|
let visible = line
|
|
.line
|
|
.spans
|
|
.iter()
|
|
.map(|span| span.content.as_ref())
|
|
.collect::<String>();
|
|
if let Some(start_byte) = visible.find(CHATGPT_USAGE_URL) {
|
|
let start = display_width(&visible[..start_byte]);
|
|
line.hyperlinks
|
|
.push(crate::terminal_hyperlinks::TerminalHyperlink::web(
|
|
start..start + display_width(CHATGPT_USAGE_URL),
|
|
CHATGPT_USAGE_URL.to_string(),
|
|
));
|
|
}
|
|
}
|
|
lines
|
|
}
|
|
|
|
fn transcript_hyperlink_lines(
|
|
&self,
|
|
width: u16,
|
|
) -> Vec<crate::terminal_hyperlinks::HyperlinkLine> {
|
|
self.display_hyperlink_lines(width)
|
|
}
|
|
}
|
|
|
|
fn format_model_provider(config: &Config, runtime_base_url: Option<&str>) -> 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 = runtime_base_url.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())
|
|
}
|