//! Status-line and terminal-title rendering helpers for `ChatWidget`. //! //! Keeping this logic in a focused submodule makes the additive title/status //! behavior easier to review without paging through the rest of `chatwidget.rs`. use super::*; use crate::bottom_pane::status_line_from_segments; use crate::branch_summary; use crate::chatwidget::limit_label_for_window; use crate::chatwidget::rate_limits::get_limits_duration; use crate::legacy_core::config::Config; use crate::model_catalog::LUNA_RESERVE_MODEL; use crate::status::format_credit_micros; use crate::status::format_estimated_usd_micros; use crate::status::format_tokens_compact; use crate::status::thread_cost_micros; use crate::terminal_program_status::ProgramStatus; use codex_app_server_protocol::AskForApproval; use codex_config::ConfigLayerSource; use codex_config::os_host_name; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ServiceTier; use codex_protocol::models::PermissionProfile; use codex_utils_sandbox_summary::summarize_permission_profile; use super::status_state::TerminalTitleStatusKind; /// Items shown in the terminal title when the user has not configured a /// custom selection. Unnamed threads omit the name until one is available. pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 3] = ["activity", "thread-name", "project-name"]; /// Braille-pattern dot-spinner frames for the terminal title animation. pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; /// Time between spinner frame advances in the terminal title. pub(super) const TERMINAL_TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100); /// Time between action-required blink phases in the terminal title. const TERMINAL_TITLE_ACTION_REQUIRED_INTERVAL: Duration = Duration::from_secs(1); /// Prefix shown in the terminal title when the agent is blocked on user input. const TERMINAL_TITLE_ACTION_REQUIRED_PREFIX: &str = "[ ! ] Action Required"; const TERMINAL_TITLE_ACTION_REQUIRED_PREFIX_HIDDEN: &str = "[ . ] Action Required"; #[derive(Debug)] /// Parsed status-surface configuration for one refresh pass. /// /// The status line and terminal title share some expensive or stateful inputs /// (notably git branch lookup and invalid-item warnings). This snapshot lets one /// refresh pass compute those shared concerns once, then render both surfaces /// from the same selection set. struct StatusSurfaceSelections { status_line_items: Vec, invalid_status_line_items: Vec, terminal_title_items: Vec, invalid_terminal_title_items: Vec, } impl StatusSurfaceSelections { fn uses_git_branch(&self) -> bool { self.status_line_items.contains(&StatusLineItem::GitBranch) || self .terminal_title_items .contains(&TerminalTitleItem::GitBranch) } fn uses_git_summary(&self) -> bool { self.status_line_items .contains(&StatusLineItem::PullRequestNumber) || self .status_line_items .contains(&StatusLineItem::BranchChanges) } fn uses_workspace_headline(&self) -> bool { self.status_line_items .contains(&StatusLineItem::WorkspaceHeadline) } fn uses_thread_usage(&self) -> bool { self.status_line_items.iter().any(|item| { matches!( item, StatusLineItem::ThreadCredits | StatusLineItem::EstimatedThreadCost ) }) || self.terminal_title_items.iter().any(|item| { matches!( item, TerminalTitleItem::ThreadCredits | TerminalTitleItem::EstimatedThreadCost ) }) } } /// Cached project-root display name keyed by the cwd used for the last lookup. /// /// Terminal-title refreshes can happen very frequently, so the title path avoids /// repeatedly walking up the filesystem to rediscover the same project root name /// while the working directory is unchanged. #[derive(Clone, Debug)] pub(super) struct CachedProjectRootName { pub(super) cwd: PathBuf, pub(super) root_name: Option, } impl ChatWidget { fn status_surface_selections(&self) -> StatusSurfaceSelections { let (status_line_items, invalid_status_line_items) = self.status_line_items_with_invalids(); let (terminal_title_items, invalid_terminal_title_items) = self.terminal_title_items_with_invalids(); StatusSurfaceSelections { status_line_items, invalid_status_line_items, terminal_title_items, invalid_terminal_title_items, } } fn warn_invalid_status_line_items_once(&mut self, invalid_items: &[String]) { if self.thread_id.is_some() && !invalid_items.is_empty() && self .status_line_invalid_items_warned .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) .is_ok() { let label = if invalid_items.len() == 1 { "item" } else { "items" }; let message = format!( "Ignored invalid status line {label}: {}.", proper_join(invalid_items) ); self.on_warning(message); } } fn warn_invalid_terminal_title_items_once(&mut self, invalid_items: &[String]) { if self.thread_id.is_some() && !invalid_items.is_empty() && self .terminal_title_invalid_items_warned .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) .is_ok() { let label = if invalid_items.len() == 1 { "item" } else { "items" }; let message = format!( "Ignored invalid terminal title {label}: {}.", proper_join(invalid_items) ); self.on_warning(message); } } fn sync_status_surface_shared_state(&mut self, selections: &StatusSurfaceSelections) { if !selections.uses_git_branch() { self.status_line_branch = None; self.status_line_branch_pending = false; self.status_line_branch_lookup_complete = false; } else { let cwd = self.status_line_cwd().to_path_buf(); self.sync_status_line_branch_state(&cwd); if !self.status_line_branch_lookup_complete { self.request_status_line_branch(cwd); } } if !selections.uses_git_summary() { self.status_line_git_summary = None; self.status_line_git_summary_pending = false; self.status_line_git_summary_lookup_complete = false; } else { let cwd = self.status_line_cwd().to_path_buf(); self.sync_status_line_git_summary_state(&cwd); if !self.status_line_git_summary_lookup_complete { self.request_status_line_git_summary(cwd); } } if !selections.uses_workspace_headline() { self.status_line_workspace_headline = None; self.status_line_workspace_headline_pending_request_id = None; self.status_line_workspace_headline_last_requested_at = None; self.status_line_workspace_messages_disabled = false; } else { self.request_status_line_workspace_headline_if_due(Instant::now()); } if selections.uses_thread_usage() { self.ensure_thread_usage_requested(); } else { self.cancel_thread_usage_polling(); } } fn refresh_status_line_from_selections(&mut self, selections: &StatusSurfaceSelections) { let enabled = !selections.status_line_items.is_empty(); self.bottom_pane.set_status_line_enabled(enabled); if !enabled { self.set_status_line(/*status_line*/ None); return; } let mut segments = Vec::new(); for item in &selections.status_line_items { if let Some(value) = self.status_line_value_for_item(*item) { segments.push((*item, value)); } } let line = status_line_from_segments( segments, self.local_settings.tui.status_line_use_colors, self.thread_id, self.status_line_pull_request_url(), ); self.set_status_line(line.map(Into::into)); } /// Clears the terminal title Codex most recently wrote, if any. /// /// This does not attempt to restore the shell or terminal's previous title; /// it only clears the managed title and updates the cache after a successful /// OSC write. pub(crate) fn clear_managed_terminal_title(&mut self) -> std::io::Result<()> { if self.last_terminal_title.is_some() { clear_terminal_title()?; self.last_terminal_title = None; } Ok(()) } /// Renders and applies the terminal title for one parsed selection snapshot. /// /// Empty selections clear the managed title. Non-empty selections render the /// current values in configured order, skip unavailable segments, and cache /// the last successfully written title so redundant OSC writes are avoided. /// Animated titles record their next refresh for the foreground loop, independently /// of full TUI redraws. fn refresh_terminal_title_from_selections(&mut self, selections: &StatusSurfaceSelections) { if self.app_event_tx.voice_only.load(Ordering::Relaxed) { return; } self.last_terminal_title_requires_action = self.terminal_title_shows_action_required_with_selections(selections); let now = Instant::now(); self.terminal_title_next_refresh = self .terminal_title_animation_interval_with_selections(selections) .map(|interval| now + interval); if selections.terminal_title_items.is_empty() { if let Err(err) = self.clear_managed_terminal_title() { tracing::debug!(error = %err, "failed to clear terminal title"); } return; } let title = self.terminal_title_text_for_selections(selections, now); if self.last_terminal_title == title { return; } match title { Some(title) => match set_terminal_title(&title) { Ok(SetTerminalTitleResult::Applied) => { self.last_terminal_title = Some(title); } Ok(SetTerminalTitleResult::NoVisibleContent) => { if let Err(err) = self.clear_managed_terminal_title() { tracing::debug!(error = %err, "failed to clear terminal title"); } } Err(err) => { tracing::debug!(error = %err, "failed to set terminal title"); } }, None => { if let Err(err) = self.clear_managed_terminal_title() { tracing::debug!(error = %err, "failed to clear terminal title"); } } } } /// Recomputes both status surfaces from one shared config snapshot. /// /// This is the common refresh entrypoint for the footer status line and the /// terminal title. It parses both configurations once, emits invalid-item /// warnings once, synchronizes shared cached state (such as git-branch /// lookup), then renders each surface from that shared snapshot. pub(crate) fn refresh_status_surfaces(&mut self) { self.bottom_pane .set_luna_reserve_active(self.current_model() == LUNA_RESERVE_MODEL); let selections = self.status_surface_selections(); self.warn_invalid_status_line_items_once(&selections.invalid_status_line_items); self.warn_invalid_terminal_title_items_once(&selections.invalid_terminal_title_items); self.sync_status_surface_shared_state(&selections); self.refresh_status_line_from_selections(&selections); self.refresh_terminal_title_from_selections(&selections); } /// Recomputes and emits the terminal title from config and runtime state. pub(crate) fn refresh_terminal_title(&mut self) { let selections = self.status_surface_selections(); self.warn_invalid_terminal_title_items_once(&selections.invalid_terminal_title_items); self.sync_status_surface_shared_state(&selections); self.refresh_terminal_title_from_selections(&selections); } fn terminal_title_requires_action(&self) -> bool { self.bottom_pane.terminal_title_requires_action() } /// Reports the displayed thread's actual state independently of terminal /// title/notification settings. Replays never produce an unseen result. pub(crate) fn desired_program_status(&self) -> ProgramStatus { if let Some(kind) = self.bottom_pane.program_status_blocked_kind() { ProgramStatus::Blocked(kind) } else if self.bottom_pane.is_task_running() || self.is_user_turn_pending_or_running() || (self.status_state.program_status_after_turn == ProgramStatus::Done && self .current_goal_status .as_ref() .is_some_and(GoalStatusState::is_active)) { ProgramStatus::Working } else { self.status_state.program_status_after_turn } } pub(crate) fn acknowledge_program_status(&mut self) { if matches!( self.desired_program_status(), ProgramStatus::Done | ProgramStatus::Error ) { self.status_state.program_status_after_turn = ProgramStatus::Idle; self.request_redraw(); } } pub(super) fn iterm_session_detail(&self, status: ProgramStatus) -> Option<&str> { (status == ProgramStatus::Working && self.bottom_pane.is_task_running()) .then_some(self.status_state.current_status.header.as_str()) .filter(|detail| *detail != "Working") } pub(super) fn terminal_title_shows_action_required(&self) -> bool { self.terminal_title_requires_action() && self.terminal_title_uses_activity() } fn terminal_title_text_for_selections( &mut self, selections: &StatusSurfaceSelections, now: Instant, ) -> Option { if self.terminal_title_shows_action_required_with_selections(selections) { let title = self.action_required_terminal_title_text(selections, now); return Some(if self.realtime_microphone_is_listening() { format!("● {title}") } else { title }); } let mut previous = None; let title = selections .terminal_title_items .iter() .copied() .filter_map(|item| { self.terminal_title_value_for_item(item, now) .map(|value| (item, value)) }) .fold(String::new(), |mut title, (item, value)| { title.push_str(item.separator_from_previous(previous)); title.push_str(&value); previous = Some(item); title }); (!title.is_empty()).then_some(title) } fn action_required_terminal_title_text( &mut self, selections: &StatusSurfaceSelections, now: Instant, ) -> String { crate::bottom_pane::build_action_required_title_text( self.action_required_terminal_title_prefix_at(now), selections.terminal_title_items.iter().copied(), &[TerminalTitleItem::Status], |item| self.terminal_title_value_for_item(item, now), ) } fn action_required_terminal_title_prefix_at(&self, now: Instant) -> &'static str { if !(self.local_settings.tui.animations && self.local_settings.tui.effects.title) { return TERMINAL_TITLE_ACTION_REQUIRED_PREFIX; } let elapsed = now.saturating_duration_since(self.terminal_title_animation_origin); let phase = (elapsed.as_millis() / TERMINAL_TITLE_ACTION_REQUIRED_INTERVAL.as_millis()) % 2; if phase == 0 { TERMINAL_TITLE_ACTION_REQUIRED_PREFIX } else { TERMINAL_TITLE_ACTION_REQUIRED_PREFIX_HIDDEN } } fn terminal_title_shows_action_required_with_selections( &self, selections: &StatusSurfaceSelections, ) -> bool { self.terminal_title_requires_action() && selections .terminal_title_items .contains(&TerminalTitleItem::Spinner) } fn terminal_title_animation_interval_with_selections( &self, selections: &StatusSurfaceSelections, ) -> Option { if self.local_settings.tui.animations && self.local_settings.tui.effects.title && self.terminal_title_shows_action_required_with_selections(selections) { return Some(TERMINAL_TITLE_ACTION_REQUIRED_INTERVAL); } self.should_animate_terminal_title_spinner_with_selections(selections) .then_some(TERMINAL_TITLE_SPINNER_INTERVAL) } pub(super) fn request_status_line_branch_refresh(&mut self) { let selections = self.status_surface_selections(); if !selections.uses_git_branch() { return; } let cwd = self.status_line_cwd().to_path_buf(); self.sync_status_line_branch_state(&cwd); self.request_status_line_branch(cwd); } pub(super) fn request_status_line_git_summary_refresh(&mut self) { let selections = self.status_surface_selections(); if !selections.uses_git_summary() { return; } let cwd = self.status_line_cwd().to_path_buf(); self.sync_status_line_git_summary_state(&cwd); self.request_status_line_git_summary(cwd); } /// Parses configured status-line ids into known items and collects unknown ids. /// /// Unknown ids are deduplicated in insertion order for warning messages. fn status_line_items_with_invalids(&self) -> (Vec, Vec) { parse_items_with_invalids(self.configured_status_line_items()) } pub(super) fn configured_status_line_items(&self) -> Vec { self.local_settings .tui .status_line .clone() .unwrap_or_else(|| { DEFAULT_STATUS_LINE_ITEMS .iter() .map(ToString::to_string) .collect() }) } /// Parses configured terminal-title ids into known items and collects unknown ids. /// /// Unknown ids are deduplicated in insertion order for warning messages. fn terminal_title_items_with_invalids(&self) -> (Vec, Vec) { parse_items_with_invalids(self.configured_terminal_title_items()) } /// Returns the configured terminal-title ids, or the default ordering when unset. pub(super) fn configured_terminal_title_items(&self) -> Vec { self.local_settings .tui .terminal_title .clone() .unwrap_or_else(|| { DEFAULT_TERMINAL_TITLE_ITEMS .iter() .map(ToString::to_string) .collect() }) } fn status_line_cwd(&self) -> &Path { self.current_cwd .as_deref() .unwrap_or(self.config.cwd.as_path()) } /// Resolves the project root associated with `cwd`. /// /// Git repository root wins when available. Otherwise we fall back to the /// nearest project config layer so non-git projects can still surface a /// stable project label. fn status_line_project_root_for_cwd(&self, cwd: &Path) -> Option { if let Some(repo_root) = get_git_repo_root(cwd) { return Some(repo_root); } self.config .config_layer_stack .all_layers_low_to_high() .find_map(|layer| match &layer.name { ConfigLayerSource::Project { dot_codex_folder } => { dot_codex_folder.as_path().parent().map(Path::to_path_buf) } _ => None, }) } fn status_line_project_root_name_for_cwd(&self, cwd: &Path) -> Option { self.status_line_project_root_for_cwd(cwd).map(|root| { root.file_name() .map(|name| name.to_string_lossy().to_string()) .unwrap_or_else(|| format_directory_display(&root, /*max_width*/ None)) }) } /// Returns a cached project-root display name for the active cwd. fn status_line_project_root_name(&mut self) -> Option { let cwd = self.status_line_cwd().to_path_buf(); if let Some(cache) = &self.status_line_project_root_name_cache && cache.cwd == cwd { return cache.root_name.clone(); } let root_name = self.status_line_project_root_name_for_cwd(&cwd); self.status_line_project_root_name_cache = Some(CachedProjectRootName { cwd, root_name: root_name.clone(), }); root_name } /// Produces the terminal-title `project` value. /// /// This prefers the cached project-root name and falls back to the current /// directory name when no project root can be inferred. fn terminal_title_project_name(&mut self) -> Option { let project = self.status_line_project_root_name().or_else(|| { let cwd = self.status_line_cwd(); Some( cwd.file_name() .map(|name| name.to_string_lossy().to_string()) .unwrap_or_else(|| format_directory_display(cwd, /*max_width*/ None)), ) })?; Some(Self::truncate_terminal_title_part( project, /*max_chars*/ 24, )) } /// Resets git-branch cache state when the status-line cwd changes. /// /// The branch cache is keyed by cwd because branch lookup is performed relative to that path. /// Keeping stale branch values across cwd changes would surface incorrect repository context. fn sync_status_line_branch_state(&mut self, cwd: &Path) { if self .status_line_branch_cwd .as_ref() .is_some_and(|path| path == cwd) { return; } self.status_line_branch_cwd = Some(cwd.to_path_buf()); self.status_line_branch = None; self.status_line_branch_pending = false; self.status_line_branch_lookup_complete = false; } fn sync_status_line_git_summary_state(&mut self, cwd: &Path) { if self.status_line_git_summary_cwd.as_deref() == Some(cwd) { return; } self.status_line_git_summary_cwd = Some(cwd.to_path_buf()); self.status_line_git_summary = None; self.status_line_git_summary_pending = false; self.status_line_git_summary_lookup_complete = false; } /// Starts an async git-branch lookup unless one is already running. /// /// The resulting `StatusLineBranchUpdated` event carries the lookup cwd so callers can reject /// stale completions after directory changes. fn request_status_line_branch(&mut self, cwd: PathBuf) { if self.status_line_branch_pending { return; } let Some(runner) = self.workspace_command_runner.clone() else { self.status_line_branch_lookup_complete = true; return; }; self.status_line_branch_pending = true; let tx = self.app_event_tx.clone(); tokio::spawn(async move { let branch = branch_summary::current_branch_name(runner.as_ref(), &cwd).await; tx.send(AppEvent::StatusLineBranchUpdated { cwd, branch }); }); } fn request_status_line_git_summary(&mut self, cwd: PathBuf) { if self.status_line_git_summary_pending { return; } let Some(runner) = self.workspace_command_runner.clone() else { self.status_line_git_summary_lookup_complete = true; return; }; self.status_line_git_summary_pending = true; let tx = self.app_event_tx.clone(); tokio::spawn(async move { let summary = branch_summary::status_line_git_summary(runner.as_ref(), &cwd).await; tx.send(AppEvent::StatusLineGitSummaryUpdated { cwd, summary }); }); } fn request_status_line_workspace_headline_if_due(&mut self, now: Instant) { if !self.status_line_workspace_headline_should_fetch(now) { return; } let request_id = self.next_status_line_workspace_headline_request_id; self.next_status_line_workspace_headline_request_id = self .next_status_line_workspace_headline_request_id .wrapping_add(/*rhs*/ 1); self.status_line_workspace_headline_pending_request_id = Some(request_id); self.status_line_workspace_headline_last_requested_at = Some(now); self.app_event_tx .send(AppEvent::RefreshStatusLineWorkspaceHeadline { request_id }); } fn status_line_workspace_headline_should_fetch(&self, now: Instant) -> bool { if self .status_line_workspace_headline_pending_request_id .is_some() || self.status_line_workspace_messages_disabled || !self.has_codex_backend_auth { return false; } self.status_line_workspace_headline_last_requested_at .is_none_or(|last_requested_at| { now.saturating_duration_since(last_requested_at) >= crate::workspace_messages::WORKSPACE_HEADLINE_REFRESH_INTERVAL }) } pub(super) fn refresh_status_line_if_workspace_headline_due(&mut self) { let now = Instant::now(); if self.status_line_workspace_headline_should_fetch(now) && self .status_line_items_with_invalids() .0 .contains(&StatusLineItem::WorkspaceHeadline) { self.refresh_status_line(); } } pub(crate) fn set_status_line_workspace_headline( &mut self, request_id: u64, result: Result, ) -> bool { if self.status_line_workspace_headline_pending_request_id != Some(request_id) { return false; } self.status_line_workspace_headline_pending_request_id = None; match result { Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(headline)) => { self.status_line_workspace_messages_disabled = false; self.status_line_workspace_headline = headline; } Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled) => { self.status_line_workspace_messages_disabled = true; self.status_line_workspace_headline = None; } Err(err) => { tracing::debug!(error = %err, "failed to fetch workspace headline"); } } if !self.status_line_workspace_messages_disabled && self .status_line_items_with_invalids() .0 .contains(&StatusLineItem::WorkspaceHeadline) { self.frame_requester .schedule_frame_in(crate::workspace_messages::WORKSPACE_HEADLINE_REFRESH_INTERVAL); } self.refresh_status_line(); true } /// Resolves a display string for one configured status-line item. /// /// Returning `None` means "omit this item for now", not "configuration error". Callers rely on /// this to keep partially available status lines readable while waiting for session, token, or /// git metadata. pub(super) fn status_line_value_for_item(&mut self, item: StatusLineItem) -> Option { match item { StatusLineItem::ModelName => Some(self.model_display_name().to_string()), StatusLineItem::ModelWithReasoning => { let service_tier = self.processing_tier_display_name().map(str::to_lowercase); Some(self.model_with_reasoning_display_name(service_tier.as_deref())) } StatusLineItem::Reasoning => Some(self.reasoning_display_name()), StatusLineItem::Profile => { let layer = self.config.config_layer_stack.get_active_user_layer()?; let ConfigLayerSource::User { profile, .. } = &layer.name else { return None; }; profile.as_ref().map(|name| format!("profile: {name}")) } StatusLineItem::Provider => self .thread_id .map(|_| format!("provider: {}", self.config.model_provider_id)), StatusLineItem::CurrentDir => { Some(format_directory_display( self.status_line_cwd(), /*max_width*/ None, )) } StatusLineItem::ProjectRoot => self.status_line_project_root_name(), StatusLineItem::Hostname => os_host_name(), StatusLineItem::GitBranch => self.status_line_branch.clone(), StatusLineItem::PullRequestNumber => self .status_line_git_summary .as_ref() .and_then(|summary| summary.pull_request.as_ref()) .map(|pull_request| format!("PR #{}", pull_request.number)), StatusLineItem::BranchChanges => self .status_line_git_summary .as_ref() .and_then(|summary| summary.branch_change_stats.as_ref()) .map(|stats| { if stats.additions == 0 && stats.deletions == 0 { "No changes".to_string() } else { format!("+{} -{}", stats.additions, stats.deletions) } }), StatusLineItem::Status => Some(self.run_state_status_text()), StatusLineItem::Permissions => Some(permissions_display(&self.config)), StatusLineItem::ApprovalMode => Some(approval_mode_display(&self.config)), StatusLineItem::UsedTokens => { let usage = self.status_line_total_usage(); let total = usage.blended_total(); if total <= 0 { None } else { Some(format!("{} used", format_tokens_compact(total))) } } StatusLineItem::ContextRemaining => self .status_line_context_remaining_percent() .map(|remaining| format!("Context {remaining}% left")), StatusLineItem::ContextUsed => self .status_line_context_used_percent() .map(|used| format!("Context {used}% used")), StatusLineItem::FiveHourLimit => { let (window, is_secondary) = self .rate_limit_snapshots_by_limit_id .get("codex") .and_then(five_hour_status_window)?; let label = limit_label_for_window(window.window_minutes, is_secondary); self.status_line_limit_display(Some(window), &label) } StatusLineItem::WeeklyLimit => { let (window, is_secondary) = self .rate_limit_snapshots_by_limit_id .get("codex") .and_then(weekly_status_window)?; let label = limit_label_for_window(window.window_minutes, is_secondary); self.status_line_limit_display(Some(window), &label) } StatusLineItem::CodexVersion => Some(CODEX_CLI_VERSION.to_string()), StatusLineItem::ContextWindowSize => self .status_line_context_window_size() .map(|cws| format!("{} window", format_tokens_compact(cws))), StatusLineItem::TotalInputTokens => (!self.token_usage_pending).then(|| { format!( "{} in", format_tokens_compact(self.status_line_total_usage().input_tokens) ) }), StatusLineItem::TotalOutputTokens => (!self.token_usage_pending).then(|| { format!( "{} out", format_tokens_compact(self.status_line_total_usage().output_tokens) ) }), StatusLineItem::ThreadCredits => self .estimated_thread_usage() .filter(|_| self.plan_type != Some(PlanType::Ent26)) .map(|usage| usage.estimated_usage_credits_micros) .map(|credits| format!("{} credits", format_credit_micros(credits))), StatusLineItem::EstimatedThreadCost => self .estimated_thread_usage() .and_then(|usage| thread_cost_micros(usage, self.plan_type)) .and_then(format_estimated_usd_micros), StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()), StatusLineItem::FastMode => Some( self.processing_tier_display_name() .unwrap_or("Standard") .to_string(), ), StatusLineItem::Daybreak => Some( if self.daybreak_enabled && !self.side_conversation_active() { "Daybreak on".to_string() } else { "Daybreak off".to_string() }, ), StatusLineItem::RawOutput => self.raw_output_mode().then(|| "raw output".to_string()), StatusLineItem::ThreadName => { self.thread_name.as_deref().and_then(normalize_thread_name) } StatusLineItem::ThreadTitle => self .thread_name .as_deref() .and_then(normalize_thread_name) .or_else(|| self.thread_id.map(|id| id.to_string())), StatusLineItem::WorkspaceHeadline => self.status_line_workspace_headline.clone(), StatusLineItem::TaskProgress => self.terminal_title_task_progress(), } } pub(super) fn status_line_pull_request_url(&self) -> Option<&str> { self.status_line_git_summary .as_ref() .and_then(|summary| summary.pull_request.as_ref()) .map(|pull_request| pull_request.url.as_str()) } pub(super) fn status_surface_preview_value_for_item( &mut self, item: StatusSurfacePreviewItem, ) -> Option { let status_line_item = match item { StatusSurfacePreviewItem::AppName => return Some("codex".to_string()), StatusSurfacePreviewItem::ProjectName => return self.terminal_title_project_name(), StatusSurfacePreviewItem::ProjectRoot => StatusLineItem::ProjectRoot, StatusSurfacePreviewItem::Status => return Some(self.run_state_status_text()), StatusSurfacePreviewItem::TaskProgress => return self.terminal_title_task_progress(), StatusSurfacePreviewItem::CurrentDir => StatusLineItem::CurrentDir, StatusSurfacePreviewItem::Hostname => StatusLineItem::Hostname, StatusSurfacePreviewItem::ThreadName => StatusLineItem::ThreadName, StatusSurfacePreviewItem::ThreadTitle => StatusLineItem::ThreadTitle, StatusSurfacePreviewItem::GitBranch => StatusLineItem::GitBranch, StatusSurfacePreviewItem::PullRequestNumber => StatusLineItem::PullRequestNumber, StatusSurfacePreviewItem::BranchChanges => StatusLineItem::BranchChanges, StatusSurfacePreviewItem::Permissions => StatusLineItem::Permissions, StatusSurfacePreviewItem::ApprovalMode => StatusLineItem::ApprovalMode, StatusSurfacePreviewItem::ContextRemaining => StatusLineItem::ContextRemaining, StatusSurfacePreviewItem::ContextUsed => StatusLineItem::ContextUsed, StatusSurfacePreviewItem::FiveHourLimit => StatusLineItem::FiveHourLimit, StatusSurfacePreviewItem::WeeklyLimit => StatusLineItem::WeeklyLimit, StatusSurfacePreviewItem::CodexVersion => StatusLineItem::CodexVersion, StatusSurfacePreviewItem::ContextWindowSize => StatusLineItem::ContextWindowSize, StatusSurfacePreviewItem::UsedTokens => StatusLineItem::UsedTokens, StatusSurfacePreviewItem::TotalInputTokens => StatusLineItem::TotalInputTokens, StatusSurfacePreviewItem::TotalOutputTokens => StatusLineItem::TotalOutputTokens, StatusSurfacePreviewItem::ThreadCredits => StatusLineItem::ThreadCredits, StatusSurfacePreviewItem::EstimatedThreadCost => StatusLineItem::EstimatedThreadCost, StatusSurfacePreviewItem::SessionId => StatusLineItem::SessionId, StatusSurfacePreviewItem::FastMode => StatusLineItem::FastMode, StatusSurfacePreviewItem::Daybreak => StatusLineItem::Daybreak, StatusSurfacePreviewItem::RawOutput => StatusLineItem::RawOutput, StatusSurfacePreviewItem::WorkspaceHeadline => StatusLineItem::WorkspaceHeadline, StatusSurfacePreviewItem::Model => StatusLineItem::ModelName, StatusSurfacePreviewItem::ModelWithReasoning => StatusLineItem::ModelWithReasoning, StatusSurfacePreviewItem::Reasoning => StatusLineItem::Reasoning, StatusSurfacePreviewItem::Profile => StatusLineItem::Profile, StatusSurfacePreviewItem::Provider => StatusLineItem::Provider, }; self.status_line_value_for_item(status_line_item) } /// Resolves one configured terminal-title item into a displayable segment. /// /// Returning `None` means "omit this segment for now" so callers can keep /// the configured order while hiding values that are not yet available. pub(super) fn terminal_title_value_for_item( &mut self, item: TerminalTitleItem, now: Instant, ) -> Option { match item { TerminalTitleItem::AppName => Some("codex".to_string()), TerminalTitleItem::Project => self.terminal_title_project_name(), TerminalTitleItem::CurrentDir => Some(Self::truncate_terminal_title_part( format_directory_display(self.status_line_cwd(), /*max_width*/ None), /*max_chars*/ 32, )), TerminalTitleItem::Spinner => self.terminal_title_spinner_text_at(now), TerminalTitleItem::Status => Some(self.run_state_status_text()), TerminalTitleItem::Thread | TerminalTitleItem::ThreadName => { let item = if item == TerminalTitleItem::ThreadName { StatusLineItem::ThreadName } else { StatusLineItem::ThreadTitle }; self.status_line_value_for_item(item) .map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 48)) } TerminalTitleItem::GitBranch => self.status_line_branch.as_ref().map(|branch| { Self::truncate_terminal_title_part(branch.clone(), /*max_chars*/ 32) }), TerminalTitleItem::ContextRemaining => self .status_line_value_for_item(StatusLineItem::ContextRemaining) .map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)), TerminalTitleItem::ContextUsed => self .status_line_value_for_item(StatusLineItem::ContextUsed) .map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)), TerminalTitleItem::FiveHourLimit => self .status_line_value_for_item(StatusLineItem::FiveHourLimit) .map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)), TerminalTitleItem::WeeklyLimit => self .status_line_value_for_item(StatusLineItem::WeeklyLimit) .map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)), TerminalTitleItem::CodexVersion => self .status_line_value_for_item(StatusLineItem::CodexVersion) .map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)), TerminalTitleItem::UsedTokens => self .status_line_value_for_item(StatusLineItem::UsedTokens) .map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)), TerminalTitleItem::TotalInputTokens => self .status_line_value_for_item(StatusLineItem::TotalInputTokens) .map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)), TerminalTitleItem::TotalOutputTokens => self .status_line_value_for_item(StatusLineItem::TotalOutputTokens) .map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)), TerminalTitleItem::ThreadCredits => self .status_line_value_for_item(StatusLineItem::ThreadCredits) .map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)), TerminalTitleItem::EstimatedThreadCost => self .status_line_value_for_item(StatusLineItem::EstimatedThreadCost) .map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)), TerminalTitleItem::SessionId => self .status_line_value_for_item(StatusLineItem::SessionId) .map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)), TerminalTitleItem::FastMode => self .status_line_value_for_item(StatusLineItem::FastMode) .map(|value| Self::truncate_terminal_title_part(value, /*max_chars*/ 32)), TerminalTitleItem::Daybreak => { self.status_line_value_for_item(StatusLineItem::Daybreak) } TerminalTitleItem::Model => Some(Self::truncate_terminal_title_part( self.model_display_name().to_string(), /*max_chars*/ 32, )), TerminalTitleItem::ModelWithReasoning => Some(Self::truncate_terminal_title_part( self.model_with_reasoning_display_name(self.processing_tier_display_name()), /*max_chars*/ 32, )), TerminalTitleItem::Reasoning => Some(Self::truncate_terminal_title_part( self.reasoning_display_name(), /*max_chars*/ 32, )), TerminalTitleItem::TaskProgress => self.terminal_title_task_progress(), } } fn reasoning_display_name(&self) -> String { let effort = self.effective_reasoning_effort(); Self::status_line_reasoning_effort_label(effort.as_ref()) } fn model_with_reasoning_display_name(&self, service_tier: Option<&str>) -> String { let label = self.reasoning_display_name(); let service_tier_label = service_tier .map(|tier| format!(" {tier}")) .unwrap_or_default(); format!("{} {label}{service_tier_label}", self.model_display_name()) } fn processing_tier_display_name(&self) -> Option<&str> { let id = self.current_service_tier()?; self.model_catalog .models .iter() .find(|preset| preset.model == self.service_tier_model()) .and_then(|preset| preset.service_tiers.iter().find(|tier| tier.id == id)) .map(|tier| tier.name.as_str()) .or_else(|| { (id == ServiceTier::Flex.request_value() && self .config .model_providers .get(&self.config.model_provider_id) .is_some_and(|provider| !provider.is_amazon_bedrock())) .then_some("Flex") }) } /// Computes the compact runtime status label used by word-based status items. /// /// Startup takes precedence over normal task states, and idle state renders /// as `Ready` regardless of the last active status bucket. pub(super) fn run_state_status_text(&self) -> String { if self.mcp_startup_status.is_some() { return "Starting".to_string(); } match self.status_state.terminal_title_status_kind { TerminalTitleStatusKind::Working if !self.bottom_pane.is_task_running() => { "Ready".to_string() } TerminalTitleStatusKind::WaitingForBackgroundTerminal if !self.bottom_pane.is_task_running() => { "Ready".to_string() } TerminalTitleStatusKind::Thinking if !self.bottom_pane.is_task_running() => { "Ready".to_string() } TerminalTitleStatusKind::Working => "Working".to_string(), TerminalTitleStatusKind::WaitingForBackgroundTerminal => "Waiting".to_string(), TerminalTitleStatusKind::Thinking => "Thinking".to_string(), } } pub(super) fn terminal_title_spinner_text_at(&self, now: Instant) -> Option { let spinner = (self.local_settings.tui.animations && self.local_settings.tui.effects.progress && self.terminal_title_has_active_progress()) .then(|| self.terminal_title_spinner_frame_at(now)); if self.realtime_microphone_is_listening() { return Some(match spinner { Some(frame) => format!("● {frame}"), None => "●".to_string(), }); } spinner.map(str::to_string) } pub(super) fn terminal_title_spinner_frame_at(&self, now: Instant) -> &'static str { let elapsed = now.saturating_duration_since(self.terminal_title_animation_origin); let frame_index = (elapsed.as_millis() / TERMINAL_TITLE_SPINNER_INTERVAL.as_millis()) as usize; TERMINAL_TITLE_SPINNER_FRAMES[frame_index % TERMINAL_TITLE_SPINNER_FRAMES.len()] } fn terminal_title_uses_activity(&self) -> bool { self.local_settings .tui .terminal_title .as_ref() .is_none_or(|items| { items .iter() .any(|item| item == "activity" || item == "spinner") }) } fn terminal_title_has_active_progress(&self) -> bool { if self.terminal_title_shows_action_required() { return false; } self.mcp_startup_status.is_some() || self.bottom_pane.is_task_running() } pub(super) fn should_animate_terminal_title_spinner(&self) -> bool { self.local_settings.tui.animations && self.local_settings.tui.effects.progress && self.terminal_title_uses_activity() && self.terminal_title_has_active_progress() } pub(super) fn should_animate_terminal_title_action_required(&self) -> bool { self.local_settings.tui.animations && self.local_settings.tui.effects.title && self.terminal_title_shows_action_required() } fn should_animate_terminal_title_spinner_with_selections( &self, selections: &StatusSurfaceSelections, ) -> bool { self.local_settings.tui.animations && self.local_settings.tui.effects.progress && selections .terminal_title_items .contains(&TerminalTitleItem::Spinner) && self.terminal_title_has_active_progress() } /// Formats the last `update_plan` progress snapshot for terminal-title display. pub(super) fn terminal_title_task_progress(&self) -> Option { let (completed, total) = self.transcript.last_plan_progress?; if total == 0 { return None; } Some(format!("Tasks {completed}/{total}")) } /// Truncates a title segment by grapheme cluster and appends `...` when needed. pub(super) fn truncate_terminal_title_part(value: String, max_chars: usize) -> String { if max_chars == 0 { return String::new(); } let mut graphemes = value.graphemes(true); let head: String = graphemes.by_ref().take(max_chars).collect(); if graphemes.next().is_none() || max_chars <= 3 { return head; } let mut truncated = head.graphemes(true).take(max_chars - 3).collect::(); truncated.push_str("..."); truncated } } fn five_hour_status_window( snapshot: &RateLimitSnapshotDisplay, ) -> Option<(&RateLimitWindowDisplay, bool)> { find_primary_codex_window(snapshot, "5h") .or_else(|| secondary_window_with_label_when_weekly_is_available(snapshot, "5h")) .or_else(|| non_weekly_primary_window(snapshot)) .or_else(|| non_weekly_secondary_window_when_primary_is_weekly(snapshot)) } fn weekly_status_window( snapshot: &RateLimitSnapshotDisplay, ) -> Option<(&RateLimitWindowDisplay, bool)> { find_codex_window(snapshot, "weekly") .or_else(|| snapshot.secondary.as_ref().map(|window| (window, true))) } fn find_codex_window<'a>( snapshot: &'a RateLimitSnapshotDisplay, label: &str, ) -> Option<(&'a RateLimitWindowDisplay, bool)> { if let Some(primary) = snapshot.primary.as_ref() && matches_window_label(primary, label) { return Some((primary, false)); } if let Some(secondary) = snapshot.secondary.as_ref() && matches_window_label(secondary, label) { return Some((secondary, true)); } None } fn find_primary_codex_window<'a>( snapshot: &'a RateLimitSnapshotDisplay, label: &str, ) -> Option<(&'a RateLimitWindowDisplay, bool)> { let primary = snapshot.primary.as_ref()?; if matches_window_label(primary, label) { Some((primary, false)) } else { None } } fn secondary_window_with_label_when_weekly_is_available<'a>( snapshot: &'a RateLimitSnapshotDisplay, label: &str, ) -> Option<(&'a RateLimitWindowDisplay, bool)> { find_codex_window(snapshot, "weekly")?; let secondary = snapshot.secondary.as_ref()?; if matches_window_label(secondary, label) { Some((secondary, true)) } else { None } } fn non_weekly_primary_window( snapshot: &RateLimitSnapshotDisplay, ) -> Option<(&RateLimitWindowDisplay, bool)> { let primary = snapshot.primary.as_ref()?; if matches_window_label(primary, "weekly") { None } else { Some((primary, false)) } } fn non_weekly_secondary_window_when_primary_is_weekly( snapshot: &RateLimitSnapshotDisplay, ) -> Option<(&RateLimitWindowDisplay, bool)> { let primary = snapshot.primary.as_ref()?; if !matches_window_label(primary, "weekly") { return None; } let secondary = snapshot.secondary.as_ref()?; if matches_window_label(secondary, "weekly") { None } else { Some((secondary, true)) } } fn matches_window_label(window: &RateLimitWindowDisplay, label: &str) -> bool { window .window_minutes .and_then(get_limits_duration) .as_deref() == Some(label) } fn permissions_display(config: &Config) -> String { let active_permission_profile = config.permissions.active_permission_profile(); if let Some(active_permission_profile) = active_permission_profile.as_ref() && !active_permission_profile.id.starts_with(':') { return active_permission_profile.id.clone(); } let permission_profile = config.permissions.effective_permission_profile(); let summary = summarize_permission_profile( &permission_profile, &PathUri::from_abs_path(&config.cwd), &config.effective_workspace_roots(), ); if let Some(details) = summary.strip_prefix("read-only") && !details.contains("(network access enabled)") { return "Read Only".to_string(); } if let Some(details) = summary.strip_prefix("workspace-write") && !details.contains("(network access enabled)") { return "Workspace".to_string(); } if permission_profile == PermissionProfile::Disabled { return "Full Access".to_string(); } "Custom permissions".to_string() } fn approval_mode_display(config: &Config) -> String { let approval_policy = AskForApproval::from(config.permissions.approval_policy.value()); if approval_policy == AskForApproval::OnRequest { return match config.approvals_reviewer { ApprovalsReviewer::AutoReview => "Approve for me".to_string(), ApprovalsReviewer::User => "Ask for approval".to_string(), }; } config.permissions.approval_policy.value().to_string() } fn parse_items_with_invalids(ids: impl IntoIterator) -> (Vec, Vec) where T: std::str::FromStr, { let mut invalid = Vec::new(); let mut invalid_seen = HashSet::new(); let mut items = Vec::new(); for id in ids { match id.parse::() { Ok(item) => items.push(item), Err(_) => { if invalid_seen.insert(id.clone()) { invalid.push(format!(r#""{id}""#)); } } } } (items, invalid) }