mirror of
https://github.com/openai/codex.git
synced 2026-10-28 00:03:58 +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
1213 lines
49 KiB
Rust
1213 lines
49 KiB
Rust
//! 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::status::format_credit_micros;
|
|
use crate::status::format_estimated_usd_micros;
|
|
use crate::status::format_tokens_compact;
|
|
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. Intentionally minimal: activity indicator + project name.
|
|
pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["activity", "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<StatusLineItem>,
|
|
invalid_status_line_items: Vec<String>,
|
|
terminal_title_items: Vec<TerminalTitleItem>,
|
|
invalid_terminal_title_items: Vec<String>,
|
|
}
|
|
|
|
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<String>,
|
|
}
|
|
|
|
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);
|
|
self.set_status_line_hyperlink(/*url*/ 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));
|
|
}
|
|
}
|
|
|
|
self.set_status_line(status_line_from_segments(
|
|
segments,
|
|
self.local_settings.tui.status_line_use_colors,
|
|
));
|
|
let hyperlink_url = selections
|
|
.status_line_items
|
|
.contains(&StatusLineItem::PullRequestNumber)
|
|
.then(|| self.status_line_pull_request_url())
|
|
.flatten();
|
|
self.set_status_line_hyperlink(hyperlink_url);
|
|
}
|
|
|
|
/// 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) {
|
|
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) {
|
|
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()
|
|
}
|
|
|
|
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<String> {
|
|
if self.terminal_title_shows_action_required_with_selections(selections) {
|
|
return Some(self.action_required_terminal_title_text(selections, now));
|
|
}
|
|
|
|
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 {
|
|
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<Duration> {
|
|
if self.local_settings.tui.animations
|
|
&& 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<StatusLineItem>, Vec<String>) {
|
|
parse_items_with_invalids(self.configured_status_line_items())
|
|
}
|
|
|
|
pub(super) fn configured_status_line_items(&self) -> Vec<String> {
|
|
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<TerminalTitleItem>, Vec<String>) {
|
|
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<String> {
|
|
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<PathBuf> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<crate::workspace_messages::WorkspaceHeadlineFetchResult, String>,
|
|
) -> 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<String> {
|
|
match item {
|
|
StatusLineItem::ModelName => Some(self.model_display_name().to_string()),
|
|
StatusLineItem::ModelWithReasoning => Some(self.model_with_reasoning_display_name()),
|
|
StatusLineItem::Reasoning => Some(self.reasoning_display_name()),
|
|
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()
|
|
.map(|usage| usage.estimated_usage_credits_micros)
|
|
.map(|credits| format!("{} credits", format_credit_micros(credits))),
|
|
StatusLineItem::EstimatedThreadCost => self
|
|
.estimated_thread_usage()
|
|
.and_then(|usage| usage.estimated_usage_usd_micros)
|
|
.and_then(format_estimated_usd_micros),
|
|
StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()),
|
|
StatusLineItem::FastMode => self
|
|
.model_catalog
|
|
.try_list_models()
|
|
.ok()
|
|
.and_then(|models| {
|
|
models
|
|
.into_iter()
|
|
.find(|preset| preset.model == self.current_model())
|
|
})
|
|
.is_none_or(|preset| preset.supports_fast_mode())
|
|
.then(|| {
|
|
if self.current_service_tier() == Some(ServiceTier::Fast.request_value()) {
|
|
"Fast on".to_string()
|
|
} else {
|
|
"Fast off".to_string()
|
|
}
|
|
}),
|
|
StatusLineItem::RawOutput => self.raw_output_mode().then(|| "raw output".to_string()),
|
|
StatusLineItem::ThreadTitle => self.thread_name.as_ref().map_or_else(
|
|
|| self.thread_id.map(|id| id.to_string()),
|
|
|name| {
|
|
let trimmed = name.trim();
|
|
if trimmed.is_empty() {
|
|
self.thread_id.map(|id| id.to_string())
|
|
} else {
|
|
Some(trimmed.to_string())
|
|
}
|
|
},
|
|
),
|
|
StatusLineItem::WorkspaceHeadline => self.status_line_workspace_headline.clone(),
|
|
StatusLineItem::TaskProgress => self.terminal_title_task_progress(),
|
|
}
|
|
}
|
|
|
|
fn status_line_pull_request_url(&self) -> Option<String> {
|
|
self.status_line_git_summary
|
|
.as_ref()
|
|
.and_then(|summary| summary.pull_request.as_ref())
|
|
.map(|pull_request| pull_request.url.clone())
|
|
}
|
|
|
|
pub(super) fn status_surface_preview_value_for_item(
|
|
&mut self,
|
|
item: StatusSurfacePreviewItem,
|
|
) -> Option<String> {
|
|
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::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::RawOutput => StatusLineItem::RawOutput,
|
|
StatusSurfacePreviewItem::WorkspaceHeadline => StatusLineItem::WorkspaceHeadline,
|
|
StatusSurfacePreviewItem::Model => StatusLineItem::ModelName,
|
|
StatusSurfacePreviewItem::ModelWithReasoning => StatusLineItem::ModelWithReasoning,
|
|
StatusSurfacePreviewItem::Reasoning => StatusLineItem::Reasoning,
|
|
};
|
|
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<String> {
|
|
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 => self
|
|
.status_line_value_for_item(StatusLineItem::ThreadTitle)
|
|
.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::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(),
|
|
/*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) -> String {
|
|
let label = self.reasoning_display_name();
|
|
let service_tier_label = self
|
|
.current_service_tier()
|
|
.and_then(|service_tier| {
|
|
self.current_model_service_tier_commands()
|
|
.into_iter()
|
|
.find(|tier| tier.id == service_tier)
|
|
.map(|tier| tier.name)
|
|
})
|
|
.filter(|_| self.has_chatgpt_account)
|
|
.map(|tier| format!(" {tier}"))
|
|
.unwrap_or_default();
|
|
format!("{} {label}{service_tier_label}", self.model_display_name())
|
|
}
|
|
|
|
/// 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<String> {
|
|
if !self.local_settings.tui.animations {
|
|
return None;
|
|
}
|
|
|
|
if !self.terminal_title_has_active_progress() {
|
|
return None;
|
|
}
|
|
|
|
Some(self.terminal_title_spinner_frame_at(now).to_string())
|
|
}
|
|
|
|
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.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.terminal_title_shows_action_required()
|
|
}
|
|
|
|
fn should_animate_terminal_title_spinner_with_selections(
|
|
&self,
|
|
selections: &StatusSurfaceSelections,
|
|
) -> bool {
|
|
self.local_settings.tui.animations
|
|
&& 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<String> {
|
|
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::<String>();
|
|
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 workspace_roots = config.effective_workspace_roots();
|
|
let summary =
|
|
summarize_permission_profile(&permission_profile, &config.cwd, workspace_roots.as_slice());
|
|
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<T>(ids: impl IntoIterator<Item = String>) -> (Vec<T>, Vec<String>)
|
|
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::<T>() {
|
|
Ok(item) => items.push(item),
|
|
Err(_) => {
|
|
if invalid_seen.insert(id.clone()) {
|
|
invalid.push(format!(r#""{id}""#));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
(items, invalid)
|
|
}
|