use std::collections::HashSet;use std::path::Path;use std::path::PathBuf;use codex_config::ConfigLayerSource;use codex_config::ConfigLayerStackOrdering;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<Vec<String>>, cwd: PathBuf, project_root: Option<ProjectTitleRoot>,}#[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<String>) -> (Vec<String>, Vec<String>) { 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"), "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<ProjectTitleRoot> { if let Some(repo_root) = get_git_repo_root(cwd) { return Some(ProjectTitleRoot { source: "git repo root", path: repo_root, }); } config .config_layer_stack .get_layers( ConfigLayerStackOrdering::LowestPrecedenceFirst, /*include_disabled*/ true, ) .iter() .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<ProjectTitleRoot>, cwd: &Path,) -> (&'static str, Option<String>) { 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::<String>(); 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::<String>(); 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_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()) ); }}