diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 074a3b19..39933bb7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -921,6 +921,7 @@ dependencies = [ "color-eyre", "crossterm", "diffy", + "dirs", "image", "insta", "itertools 0.14.0", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 02da641d..06e892b7 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -43,6 +43,7 @@ crossterm = { version = "0.28.1", features = [ "bracketed-paste", "event-stream", ] } +dirs = "6" diffy = "0.4.2" image = { version = "^0.25.8", default-features = false, features = [ "jpeg", diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ebd97c81..73845bfe 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -781,8 +781,7 @@ async fn binary_size_transcript_snapshot() { // Consider content only after the last session banner marker. Skip the transient // 'thinking' header if present, and start from the first non-empty content line // that follows. This keeps the snapshot stable across sessions. - const MARKER_PREFIX: &str = - "Describe a task to get started or try one of the following commands:"; + const MARKER_PREFIX: &str = "To get started, describe a task or try one of these commands:"; let last_marker_line_idx = lines .iter() .rposition(|l| l.trim_start().starts_with(MARKER_PREFIX)) diff --git a/codex-rs/tui/src/exec_command.rs b/codex-rs/tui/src/exec_command.rs index 1923fa3b..93c937e0 100644 --- a/codex-rs/tui/src/exec_command.rs +++ b/codex-rs/tui/src/exec_command.rs @@ -1,6 +1,7 @@ use std::path::Path; use std::path::PathBuf; +use dirs::home_dir; use shlex::try_join; pub(crate) fn escape_command(command: &[String]) -> String { @@ -27,13 +28,9 @@ where return None; } - if let Some(home_dir) = std::env::var_os("HOME").map(PathBuf::from) - && let Ok(rel) = path.strip_prefix(&home_dir) - { - return Some(rel.to_path_buf()); - } - - None + let home_dir = home_dir()?; + let rel = path.strip_prefix(&home_dir).ok()?; + Some(rel.to_path_buf()) } #[cfg(test)] diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index bd1056e0..d6add6d3 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -574,7 +574,7 @@ impl HistoryCell for CompletedMcpToolCallWithImageOutput { } const TOOL_CALL_MAX_LINES: usize = 5; -const SESSION_HEADER_MAX_INNER_WIDTH: usize = 70; +const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value fn title_case(s: &str) -> String { if s.is_empty() { @@ -628,24 +628,29 @@ pub(crate) fn new_session_info( // Help lines below the header (new copy and list) let help_lines: Vec> = vec![ - "Describe a task to get started or try one of the following commands:" + " To get started, describe a task or try one of these commands:" .dim() .into(), - Line::from("".dim()), + Line::from(""), Line::from(vec![ - "1. ".into(), - "/status".bold(), - " - show current session configuration and token usage".dim(), + " ".into(), + "/init".into(), + " - create an AGENTS.md file with instructions for Codex".dim(), ]), Line::from(vec![ - "2. ".into(), - "/compact".bold(), - " - compact the chat history to avoid context limits".dim(), + " ".into(), + "/status".into(), + " - show current session configuration".dim(), ]), Line::from(vec![ - "3. ".into(), - "/prompts".bold(), - " - explore starter prompts to get to know Codex".dim(), + " ".into(), + "/approvals".into(), + " - choose what Codex can do without approval".dim(), + ]), + Line::from(vec![ + " ".into(), + "/model".into(), + " - choose what model and reasoning effort to use".dim(), ]), ]; @@ -715,16 +720,31 @@ impl SessionHeaderHistoryCell { } } - fn format_directory(&self) -> String { - if let Some(rel) = relativize_to_home(&self.directory) { + fn format_directory(&self, max_width: Option) -> String { + Self::format_directory_inner(&self.directory, max_width) + } + + fn format_directory_inner(directory: &Path, max_width: Option) -> String { + let formatted = if let Some(rel) = relativize_to_home(directory) { if rel.as_os_str().is_empty() { "~".to_string() } else { format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display()) } } else { - self.directory.display().to_string() + directory.display().to_string() + }; + + if let Some(max_width) = max_width { + if max_width == 0 { + return String::new(); + } + if UnicodeWidthStr::width(formatted.as_str()) > max_width { + return crate::text_formatting::center_truncate_path(&formatted, max_width); + } } + + formatted } fn reasoning_label(&self) -> Option<&'static str> { @@ -753,79 +773,93 @@ impl HistoryCell for SessionHeaderHistoryCell { top.push('╭'); top.push_str(&"─".repeat(inner_width)); top.push('╮'); - out.push(Line::from(top)); + out.push(Line::from(top.dim())); // Title line rendered inside the box: " >_ OpenAI Codex (vX)" let title_text = format!(" >_ OpenAI Codex (v{})", self.version); let title_w = UnicodeWidthStr::width(title_text.as_str()); let pad_w = inner_width.saturating_sub(title_w); let mut title_spans: Vec> = vec![ - "│".into(), - " ".into(), - ">_ ".into(), - "OpenAI Codex".bold(), - " ".into(), - format!("(v{})", self.version).dim(), + Span::from("│").dim(), + Span::from(" ").dim(), + Span::from(">_ ").dim(), + Span::from("OpenAI Codex").bold(), + Span::from(" ").dim(), + Span::from(format!("(v{})", self.version)).dim(), ]; if pad_w > 0 { - title_spans.push(" ".repeat(pad_w).into()); + title_spans.push(Span::from(" ".repeat(pad_w)).dim()); } - title_spans.push("│".into()); + title_spans.push(Span::from("│").dim()); out.push(Line::from(title_spans)); // Spacer row between title and details out.push(Line::from(vec![ - "│".into(), - " ".repeat(inner_width).into(), - "│".into(), + Span::from(format!("│{}│", " ".repeat(inner_width))).dim(), ])); - // Model line: " Model: (change with /model)" - const CHANGE_MODEL_HINT: &str = "(change with /model)"; + // Model line: " model: (change with /model)" + const CHANGE_MODEL_HINT_COMMAND: &str = "/model"; + const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change"; + const DIR_LABEL: &str = "directory:"; + let label_width = DIR_LABEL.len(); + let model_label = format!( + "{model_label:> = vec![ - "│".into(), - " ".into(), - "Model: ".bold(), - self.model.clone().into(), + Span::from(format!("│ {model_label} ")).dim(), + Span::from(self.model.clone()), ]; if let Some(reasoning) = reasoning_label { - spans.push(" ".into()); - spans.push(reasoning.into()); + spans.push(Span::from(" ")); + spans.push(Span::from(reasoning)); } - spans.push(" ".into()); - spans.push(CHANGE_MODEL_HINT.dim()); + spans.push(Span::from(" ").dim()); + spans.push(Span::from(CHANGE_MODEL_HINT_COMMAND).cyan()); + spans.push(Span::from(CHANGE_MODEL_HINT_EXPLANATION).dim()); if pad_w > 0 { - spans.push(" ".repeat(pad_w).into()); + spans.push(Span::from(" ".repeat(pad_w)).dim()); } - spans.push("│".into()); + spans.push(Span::from("│").dim()); out.push(Line::from(spans)); // Directory line: " Directory: " - let dir = self.format_directory(); - let dir_text = format!(" Directory: {dir}"); + let dir_label = format!("{DIR_LABEL:> = - vec!["│".into(), " ".into(), "Directory: ".bold(), dir.into()]; + let mut spans: Vec> = vec![ + Span::from("│").dim(), + Span::from(" ").dim(), + Span::from(dir_label).dim(), + Span::from(" ").dim(), + Span::from(dir), + ]; if pad_w > 0 { - spans.push(" ".repeat(pad_w).into()); + spans.push(Span::from(" ".repeat(pad_w)).dim()); } - spans.push("│".into()); + spans.push(Span::from("│").dim()); out.push(Line::from(spans)); // Bottom border let bottom = format!("╰{}╯", "─".repeat(inner_width)); - out.push(Line::from(bottom)); + out.push(Line::from(bottom.dim())); out } @@ -1523,6 +1557,7 @@ mod tests { use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; + use dirs::home_dir; fn test_config() -> Config { Config::load_from_base_config_with_overrides( @@ -1561,11 +1596,35 @@ mod tests { let lines = render_lines(&cell.display_lines(80)); let model_line = lines .into_iter() - .find(|line| line.contains("Model:")) + .find(|line| line.contains("model:")) .expect("model line"); - assert!(model_line.contains("Model: gpt-4o high")); - assert!(model_line.contains("(change with /model)")); + assert!(model_line.contains("gpt-4o high")); + assert!(model_line.contains("/model to change")); + } + + #[test] + fn session_header_directory_center_truncates() { + let mut dir = home_dir().expect("home directory"); + for part in ["hello", "the", "fox", "is", "very", "fast"] { + dir.push(part); + } + + let formatted = SessionHeaderHistoryCell::format_directory_inner(&dir, Some(24)); + let sep = std::path::MAIN_SEPARATOR; + let expected = format!("~{sep}hello{sep}the{sep}…{sep}very{sep}fast"); + assert_eq!(formatted, expected); + } + + #[test] + fn session_header_directory_front_truncates_long_segment() { + let mut dir = home_dir().expect("home directory"); + dir.push("supercalifragilisticexpialidocious"); + + let formatted = SessionHeaderHistoryCell::format_directory_inner(&dir, Some(18)); + let sep = std::path::MAIN_SEPARATOR; + let expected = format!("~{sep}…cexpialidocious"); + assert_eq!(formatted, expected); } #[test] diff --git a/codex-rs/tui/src/text_formatting.rs b/codex-rs/tui/src/text_formatting.rs index 3c4cfc31..91d1c84f 100644 --- a/codex-rs/tui/src/text_formatting.rs +++ b/codex-rs/tui/src/text_formatting.rs @@ -1,4 +1,6 @@ use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthChar; +use unicode_width::UnicodeWidthStr; /// Truncate a tool result to fit within the given height and width. If the text is valid JSON, we format it in a compact way before truncating. /// This is a best-effort approach that may not work perfectly for text where 1 grapheme is rendered as multiple terminal cells. @@ -100,6 +102,219 @@ pub(crate) fn truncate_text(text: &str, max_graphemes: usize) -> String { } } +/// Truncate a path-like string to the given display width, keeping leading and trailing segments +/// where possible and inserting a single Unicode ellipsis between them. If an individual segment +/// cannot fit, it is front-truncated with an ellipsis. +pub(crate) fn center_truncate_path(path: &str, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } + if UnicodeWidthStr::width(path) <= max_width { + return path.to_string(); + } + + let sep = std::path::MAIN_SEPARATOR; + let has_leading_sep = path.starts_with(sep); + let has_trailing_sep = path.ends_with(sep); + let mut raw_segments: Vec<&str> = path.split(sep).collect(); + if has_leading_sep && !raw_segments.is_empty() && raw_segments[0].is_empty() { + raw_segments.remove(0); + } + if has_trailing_sep + && !raw_segments.is_empty() + && raw_segments.last().is_some_and(|last| last.is_empty()) + { + raw_segments.pop(); + } + + if raw_segments.is_empty() { + if has_leading_sep { + let root = sep.to_string(); + if UnicodeWidthStr::width(root.as_str()) <= max_width { + return root; + } + } + return "…".to_string(); + } + + struct Segment<'a> { + original: &'a str, + text: String, + truncatable: bool, + is_suffix: bool, + } + + let assemble = |leading: bool, segments: &[Segment<'_>]| -> String { + let mut result = String::new(); + if leading { + result.push(sep); + } + for segment in segments { + if !result.is_empty() && !result.ends_with(sep) { + result.push(sep); + } + result.push_str(segment.text.as_str()); + } + result + }; + + let front_truncate = |original: &str, allowed_width: usize| -> String { + if allowed_width == 0 { + return String::new(); + } + if UnicodeWidthStr::width(original) <= allowed_width { + return original.to_string(); + } + if allowed_width == 1 { + return "…".to_string(); + } + + let mut kept: Vec = Vec::new(); + let mut used_width = 1; // reserve space for leading ellipsis + for ch in original.chars().rev() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if used_width + ch_width > allowed_width { + break; + } + used_width += ch_width; + kept.push(ch); + } + kept.reverse(); + let mut truncated = String::from("…"); + for ch in kept { + truncated.push(ch); + } + truncated + }; + + let mut combos: Vec<(usize, usize)> = Vec::new(); + let segment_count = raw_segments.len(); + for left in 1..=segment_count { + let min_right = if left == segment_count { 0 } else { 1 }; + for right in min_right..=(segment_count - left) { + combos.push((left, right)); + } + } + let desired_suffix = if segment_count > 1 { + std::cmp::min(2, segment_count - 1) + } else { + 0 + }; + let mut prioritized: Vec<(usize, usize)> = Vec::new(); + let mut fallback: Vec<(usize, usize)> = Vec::new(); + for combo in combos { + if combo.1 >= desired_suffix { + prioritized.push(combo); + } else { + fallback.push(combo); + } + } + let sort_combos = |items: &mut Vec<(usize, usize)>| { + items.sort_by(|(left_a, right_a), (left_b, right_b)| { + left_b + .cmp(left_a) + .then_with(|| right_b.cmp(right_a)) + .then_with(|| (left_b + right_b).cmp(&(left_a + right_a))) + }); + }; + sort_combos(&mut prioritized); + sort_combos(&mut fallback); + + let fit_segments = + |segments: &mut Vec>, allow_front_truncate: bool| -> Option { + loop { + let candidate = assemble(has_leading_sep, segments); + let width = UnicodeWidthStr::width(candidate.as_str()); + if width <= max_width { + return Some(candidate); + } + + if !allow_front_truncate { + return None; + } + + let mut indices: Vec = Vec::new(); + for (idx, seg) in segments.iter().enumerate().rev() { + if seg.truncatable && seg.is_suffix { + indices.push(idx); + } + } + for (idx, seg) in segments.iter().enumerate().rev() { + if seg.truncatable && !seg.is_suffix { + indices.push(idx); + } + } + + if indices.is_empty() { + return None; + } + + let mut changed = false; + for idx in indices { + let original_width = UnicodeWidthStr::width(segments[idx].original); + if original_width <= max_width && segment_count > 2 { + continue; + } + let seg_width = UnicodeWidthStr::width(segments[idx].text.as_str()); + let other_width = width.saturating_sub(seg_width); + let allowed_width = max_width.saturating_sub(other_width).max(1); + let new_text = front_truncate(segments[idx].original, allowed_width); + if new_text != segments[idx].text { + segments[idx].text = new_text; + changed = true; + break; + } + } + + if !changed { + return None; + } + } + }; + + for (left_count, right_count) in prioritized.into_iter().chain(fallback.into_iter()) { + let mut segments: Vec> = raw_segments[..left_count] + .iter() + .map(|seg| Segment { + original: seg, + text: (*seg).to_string(), + truncatable: true, + is_suffix: false, + }) + .collect(); + + let need_ellipsis = left_count + right_count < segment_count; + if need_ellipsis { + segments.push(Segment { + original: "…", + text: "…".to_string(), + truncatable: false, + is_suffix: false, + }); + } + + if right_count > 0 { + segments.extend( + raw_segments[segment_count - right_count..] + .iter() + .map(|seg| Segment { + original: seg, + text: (*seg).to_string(), + truncatable: true, + is_suffix: true, + }), + ); + } + + let allow_front_truncate = need_ellipsis || segment_count <= 2; + if let Some(candidate) = fit_segments(&mut segments, allow_front_truncate) { + return candidate; + } + } + + front_truncate(path, max_width) +} + #[cfg(test)] mod tests { use super::*; @@ -203,6 +418,49 @@ mod tests { ); } + #[test] + fn test_center_truncate_doesnt_truncate_short_path() { + let sep = std::path::MAIN_SEPARATOR; + let path = format!("{sep}Users{sep}codex{sep}Public"); + let truncated = center_truncate_path(&path, 40); + + assert_eq!(truncated, path); + } + + #[test] + fn test_center_truncate_truncates_long_path() { + let sep = std::path::MAIN_SEPARATOR; + let path = format!("~{sep}hello{sep}the{sep}fox{sep}is{sep}very{sep}fast"); + let truncated = center_truncate_path(&path, 24); + + assert_eq!( + truncated, + format!("~{sep}hello{sep}the{sep}…{sep}very{sep}fast") + ); + } + + #[test] + fn test_center_truncate_truncates_long_windows_path() { + let sep = std::path::MAIN_SEPARATOR; + let path = format!( + "C:{sep}Users{sep}codex{sep}Projects{sep}super{sep}long{sep}windows{sep}path{sep}file.txt" + ); + let truncated = center_truncate_path(&path, 36); + + let expected = format!("C:{sep}Users{sep}codex{sep}…{sep}path{sep}file.txt"); + + assert_eq!(truncated, expected); + } + + #[test] + fn test_center_truncate_handles_long_segment() { + let sep = std::path::MAIN_SEPARATOR; + let path = format!("~{sep}supercalifragilisticexpialidocious"); + let truncated = center_truncate_path(&path, 18); + + assert_eq!(truncated, format!("~{sep}…cexpialidocious")); + } + #[test] fn test_format_json_compact_array() { let json = r#"[ 1, 2, { "key": "value" }, "string" ]"#;