use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; use codex_config::ConfigLayerSource; use codex_core::config::Config; use codex_git_utils::get_git_repo_root; use unicode_segmentation::UnicodeSegmentation; use super::CheckStatus; use super::DoctorCheck; const DEFAULT_TERMINAL_TITLE_ITEMS: &[&str] = &["activity", "project-name"]; const PROJECT_TITLE_MAX_CHARS: usize = 24; #[derive(Clone, Debug, Eq, PartialEq)] struct TerminalTitleInputs { configured_items: Option>, cwd: PathBuf, project_root: Option, } #[derive(Clone, Debug, Eq, PartialEq)] struct ProjectTitleRoot { source: &'static str, path: PathBuf, } pub(super) fn terminal_title_check(config: &Config) -> DoctorCheck { terminal_title_check_from_inputs(TerminalTitleInputs { configured_items: config.tui_terminal_title.clone(), cwd: config.cwd.to_path_buf(), project_root: terminal_title_project_root(config, &config.cwd), }) } fn terminal_title_check_from_inputs(inputs: TerminalTitleInputs) -> DoctorCheck { let (source, items, invalid_items) = match inputs.configured_items { Some(items) if items.is_empty() => ("disabled", Vec::new(), Vec::new()), Some(items) => { let (items, invalid_items) = parse_terminal_title_items(items); ("configured", items, invalid_items) } None => ( "default", DEFAULT_TERMINAL_TITLE_ITEMS .iter() .map(ToString::to_string) .collect(), Vec::new(), ), }; let mut details = vec![ format!("terminal title source: {source}"), format!( "terminal title items: {}", if items.is_empty() { "none".to_string() } else { items.join(", ") } ), format!("terminal title activity: {}", activity_enabled(&items)), ]; if !invalid_items.is_empty() { details.push(format!( "terminal title invalid items: {}", invalid_items.join(", ") )); } if project_title_selected(&items) { let (project_source, project_value) = project_title_candidate(inputs.project_root, &inputs.cwd); details.push(format!("terminal title project source: {project_source}")); if let Some(project_value) = project_value { details.push(format!("terminal title project value: {project_value}")); } } let status = if invalid_items.is_empty() { CheckStatus::Ok } else { CheckStatus::Warning }; let summary = if invalid_items.is_empty() { format!("terminal title {source}") } else { format!("terminal title {source} with invalid items") }; let mut check = DoctorCheck::new("terminal.title", "title", status, summary).details(details); if !invalid_items.is_empty() { check = check.issue( super::DoctorIssue::new( CheckStatus::Warning, "terminal title configuration contains unknown item identifiers", ) .measured(invalid_items.join(", ")) .expected("known terminal title item identifiers") .remedy("Remove or replace the unknown entries in [tui].terminal_title.") .field("terminal title invalid items"), ); } check } fn parse_terminal_title_items(items: Vec) -> (Vec, Vec) { let mut invalid = Vec::new(); let mut invalid_seen = HashSet::new(); let mut parsed = Vec::new(); for item in items { match terminal_title_item_id(&item) { Some(id) => parsed.push(id.to_string()), None => { if invalid_seen.insert(item.clone()) { invalid.push(format!(r#""{item}""#)); } } } } (parsed, invalid) } fn terminal_title_item_id(item: &str) -> Option<&'static str> { match item { "app-name" => Some("app-name"), "project-name" | "project" => Some("project-name"), "current-dir" => Some("current-dir"), "activity" | "spinner" => Some("activity"), "run-state" | "status" => Some("run-state"), "thread-title" | "thread" => Some("thread-title"), "git-branch" => Some("git-branch"), "context-remaining" => Some("context-remaining"), "context-used" | "context-usage" => Some("context-used"), "five-hour-limit" => Some("five-hour-limit"), "weekly-limit" => Some("weekly-limit"), "thread-credits" => Some("thread-credits"), "estimated-thread-cost" => Some("estimated-thread-cost"), "codex-version" => Some("codex-version"), "used-tokens" => Some("used-tokens"), "total-input-tokens" => Some("total-input-tokens"), "total-output-tokens" => Some("total-output-tokens"), "thread-id" | "session-id" => Some("thread-id"), "fast-mode" => Some("fast-mode"), "model" | "model-name" => Some("model"), "model-with-reasoning" => Some("model-with-reasoning"), "reasoning" => Some("reasoning"), "task-progress" => Some("task-progress"), _ => None, } } fn activity_enabled(items: &[String]) -> bool { items .iter() .any(|item| item == "activity" || item == "spinner") } fn project_title_selected(items: &[String]) -> bool { items .iter() .any(|item| item == "project-name" || item == "project") } fn terminal_title_project_root(config: &Config, cwd: &Path) -> Option { if let Some(repo_root) = get_git_repo_root(cwd) { return Some(ProjectTitleRoot { source: "git repo root", path: repo_root, }); } 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(|root| ProjectTitleRoot { source: "project config", path: root.to_path_buf(), }), _ => None, }) } fn project_title_candidate( project_root: Option, cwd: &Path, ) -> (&'static str, Option) { if let Some(project_root) = project_root { return ( project_root.source, Some(truncate_title_part(path_display_name(&project_root.path))), ); } ("cwd", Some(truncate_title_part(path_display_name(cwd)))) } fn path_display_name(path: &Path) -> String { path.file_name() .map(|name| name.to_string_lossy().to_string()) .unwrap_or_else(|| path.display().to_string()) } fn truncate_title_part(value: String) -> String { let mut graphemes = value.graphemes(true); let head = graphemes .by_ref() .take(PROJECT_TITLE_MAX_CHARS) .collect::(); if graphemes.next().is_none() || PROJECT_TITLE_MAX_CHARS <= 3 { return head; } let mut truncated = head .graphemes(true) .take(PROJECT_TITLE_MAX_CHARS - 3) .collect::(); truncated.push_str("..."); truncated } #[cfg(test)] mod tests { use pretty_assertions::assert_eq; use super::*; #[test] fn terminal_title_reports_default_items_and_git_project_name() { let check = terminal_title_check_from_inputs(TerminalTitleInputs { configured_items: None, cwd: PathBuf::from("/repo/subdir"), project_root: Some(ProjectTitleRoot { source: "git repo root", path: PathBuf::from("/repo"), }), }); assert_eq!(check.summary, "terminal title default"); assert!( check .details .contains(&"terminal title items: activity, project-name".to_string()) ); assert!( check .details .contains(&"terminal title project source: git repo root".to_string()) ); assert!( check .details .contains(&"terminal title project value: repo".to_string()) ); } #[test] fn terminal_title_reports_disabled_configuration() { let check = terminal_title_check_from_inputs(TerminalTitleInputs { configured_items: Some(Vec::new()), cwd: PathBuf::from("/workspace"), project_root: None, }); assert_eq!(check.summary, "terminal title disabled"); assert!( check .details .contains(&"terminal title items: none".to_string()) ); assert!( check .details .contains(&"terminal title activity: false".to_string()) ); assert!( !check .details .iter() .any(|detail| detail.starts_with("terminal title project ")) ); } #[test] fn terminal_title_reports_project_config_fallback() { let check = terminal_title_check_from_inputs(TerminalTitleInputs { configured_items: Some(vec!["project".to_string()]), cwd: PathBuf::from("/workspace/project/subdir"), project_root: Some(ProjectTitleRoot { source: "project config", path: PathBuf::from("/workspace/project"), }), }); assert_eq!(check.summary, "terminal title configured"); assert!( check .details .contains(&"terminal title project source: project config".to_string()) ); assert!( check .details .contains(&"terminal title project value: project".to_string()) ); } #[test] fn terminal_title_accepts_thread_usage_items() { let check = terminal_title_check_from_inputs(TerminalTitleInputs { configured_items: Some(vec![ "thread-credits".to_string(), "estimated-thread-cost".to_string(), ]), cwd: PathBuf::from("/workspace/project"), project_root: None, }); assert_eq!(check.status, CheckStatus::Ok); assert_eq!(check.summary, "terminal title configured"); assert!( check.details.contains( &"terminal title items: thread-credits, estimated-thread-cost".to_string() ) ); assert!(check.issues.is_empty()); } #[test] fn terminal_title_omits_project_when_project_item_is_not_selected() { let check = terminal_title_check_from_inputs(TerminalTitleInputs { configured_items: Some(vec!["model".to_string()]), cwd: PathBuf::from("/workspace/project"), project_root: Some(ProjectTitleRoot { source: "project config", path: PathBuf::from("/workspace/project"), }), }); assert_eq!(check.summary, "terminal title configured"); assert!( !check .details .iter() .any(|detail| detail.starts_with("terminal title project ")) ); } #[test] fn terminal_title_warns_for_invalid_configured_items() { let check = terminal_title_check_from_inputs(TerminalTitleInputs { configured_items: Some(vec![ "project".to_string(), "bogus".to_string(), "activity".to_string(), "bogus".to_string(), ]), cwd: PathBuf::from("/workspace/project"), project_root: Some(ProjectTitleRoot { source: "project config", path: PathBuf::from("/workspace/project"), }), }); assert_eq!(check.status, CheckStatus::Warning); assert_eq!( check.summary, "terminal title configured with invalid items" ); assert!( check .details .contains(&"terminal title items: project-name, activity".to_string()) ); assert!( check .details .contains(&r#"terminal title invalid items: "bogus""#.to_string()) ); assert_eq!(check.issues.len(), 1); } #[test] fn terminal_title_warns_when_all_configured_items_are_invalid() { let check = terminal_title_check_from_inputs(TerminalTitleInputs { configured_items: Some(vec!["bogus".to_string()]), cwd: PathBuf::from("/workspace/project"), project_root: None, }); assert_eq!(check.status, CheckStatus::Warning); assert!( check .details .contains(&"terminal title items: none".to_string()) ); assert!( check .details .contains(&r#"terminal title invalid items: "bogus""#.to_string()) ); } #[test] fn terminal_title_project_value_uses_tui_truncation_shape() { let check = terminal_title_check_from_inputs(TerminalTitleInputs { configured_items: Some(vec!["project".to_string()]), cwd: PathBuf::from("/workspace/abcdefghijklmnopqrstuvwxyz"), project_root: Some(ProjectTitleRoot { source: "project config", path: PathBuf::from("/workspace/abcdefghijklmnopqrstuvwxyz"), }), }); assert!( check .details .contains(&"terminal title project value: abcdefghijklmnopqrstu...".to_string()) ); } }