Skip to main content

vtcode_tui/core_tui/session/
terminal_title.rs

1/// Terminal title management for dynamic title updates
2///
3/// This module provides functionality to update the terminal title
4/// based on the current agent activity state.
5///
6/// ## Terminal Compatibility
7///
8/// Uses Crossterm `terminal::SetTitle`, which maps to standard terminal title
9/// sequences across:
10/// - iTerm2 (macOS)
11/// - Kitty (cross-platform)
12/// - Alacritty (cross-platform)
13/// - Ghostty (cross-platform)
14/// - Warp (macOS)
15/// - Terminal.app (macOS)
16/// - WezTerm (cross-platform)
17/// - Windows Terminal (Windows 10+)
18/// - Most XTerm-compatible terminals
19///
20/// References:
21/// - XTerm control sequences: OSC 2 sets window title
22/// - Crossterm SetTitle uses OSC 2 internally
23use ratatui::crossterm::{execute, terminal::SetTitle};
24
25use super::Session;
26
27/// Maximum length for terminal title to avoid truncation issues
28const MAX_TITLE_LENGTH: usize = 128;
29
30impl Session {
31    /// Set the workspace root path for dynamic title generation
32    pub fn set_workspace_root(&mut self, workspace_root: Option<std::path::PathBuf>) {
33        self.workspace_root = workspace_root;
34    }
35
36    /// Extract a short project name from the workspace path
37    fn extract_project_name(&self) -> String {
38        self.workspace_root
39            .as_ref()
40            .and_then(|path| {
41                path.file_name()
42                    .or_else(|| path.parent()?.file_name())
43                    .map(|name| name.to_string_lossy().to_string())
44            })
45            .unwrap_or_else(|| self.app_name.clone())
46    }
47
48    /// Strip spinner characters and leading whitespace from status text
49    fn strip_spinner_prefix(text: &str) -> &str {
50        text.trim_start_matches(|c: char| {
51            // Braille spinner frames
52            c == '⠋' || c == '⠙' || c == '⠹' || c == '⠸' || c == '⠼'
53                || c == '⠴' || c == '⠦' || c == '⠧' || c == '⠇' || c == '⠏'
54                // Simple spinners
55                || c == '-' || c == '\\' || c == '|' || c == '/' || c == '.'
56        })
57        .trim_start()
58    }
59
60    /// Extract action verb from status text
61    /// Status format: "⠋ Running command: cargo build" or "Running tool: read_file"
62    fn extract_action_from_status(&self) -> Option<String> {
63        let left = self.input_status_left.as_deref()?;
64        let cleaned = Self::strip_spinner_prefix(left);
65
66        // Check for common action patterns
67        if cleaned.contains("Running command:") || cleaned.contains("Running tool:") {
68            return Some("Running".to_string());
69        }
70        if cleaned.starts_with("Running:") || cleaned.starts_with("Running ") {
71            return Some("Running".to_string());
72        }
73        if cleaned.starts_with("Executing") {
74            return Some("Executing".to_string());
75        }
76        if cleaned.contains("Editing") {
77            return Some("Editing".to_string());
78        }
79        if cleaned.contains("Debugging") {
80            return Some("Debugging".to_string());
81        }
82        if cleaned.contains("Building") {
83            return Some("Building".to_string());
84        }
85        if cleaned.contains("Testing") {
86            return Some("Testing".to_string());
87        }
88        if cleaned.contains("Searching") || cleaned.contains("Finding") {
89            return Some("Searching".to_string());
90        }
91        if cleaned.contains("Creating") {
92            return Some("Creating".to_string());
93        }
94        if cleaned.contains("Reading") || cleaned.contains("Writing") {
95            return Some("Editing".to_string());
96        }
97        if cleaned.contains("Waiting") || cleaned.contains("Action Required") {
98            return Some("Action Required".to_string());
99        }
100        if cleaned.contains("Thinking") || cleaned.contains("Processing") {
101            return Some("Thinking".to_string());
102        }
103        if cleaned.contains("Checking") {
104            return Some("Checking".to_string());
105        }
106        if cleaned.contains("Loading") {
107            return Some("Loading".to_string());
108        }
109
110        None
111    }
112
113    /// Generate the dynamic terminal title based on current state
114    fn generate_terminal_title(&self) -> String {
115        let project_name = self.extract_project_name();
116
117        // Check if we're in an active state
118        if let Some(action) = self.extract_action_from_status() {
119            // Try to extract additional context (e.g., filename or command)
120            let context = self.extract_context_from_status();
121
122            if let Some(ctx) = context {
123                // Sanitize context for terminal title (remove special chars)
124                let sanitized_ctx = sanitize_for_terminal_title(&ctx);
125                return truncate_title(format!(
126                    "> {} ({}) | {} {}",
127                    self.app_name, project_name, action, sanitized_ctx
128                ));
129            } else {
130                return truncate_title(format!(
131                    "> {} ({}) | {}",
132                    self.app_name, project_name, action
133                ));
134            }
135        }
136
137        // Check for PTY sessions (long-running processes)
138        if self.is_running_activity() {
139            return truncate_title(format!("> {} ({}) | Running", self.app_name, project_name));
140        }
141
142        // Check for HITL (Human in the Loop) states
143        if self.has_status_spinner() {
144            return truncate_title(format!(
145                "> {} ({}) | Action Required",
146                self.app_name, project_name
147            ));
148        }
149
150        // Default idle state
151        truncate_title(format!("> {} ({})", self.app_name, project_name))
152    }
153
154    /// Extract additional context from status (filename, command, etc.)
155    fn extract_context_from_status(&self) -> Option<String> {
156        let left = self.input_status_left.as_deref()?;
157        let cleaned = Self::strip_spinner_prefix(left);
158
159        // Try to extract command from running status
160        if cleaned.contains("Running command:") {
161            // Split on "Running command:" and take the part after it
162            let parts: Vec<&str> = cleaned.splitn(2, "Running command:").collect();
163            if parts.len() == 2 {
164                let command = parts[1].split_whitespace().next()?;
165                // Get basename only (remove path)
166                let cmd_name = command.split('/').next_back().unwrap_or(command);
167                return Some(cmd_name.to_string());
168            }
169        }
170
171        // Try to extract tool name
172        if cleaned.contains("Running tool:") {
173            // Split on "Running tool:" and take the part after it
174            let parts: Vec<&str> = cleaned.splitn(2, "Running tool:").collect();
175            if parts.len() == 2 {
176                let tool = parts[1].split_whitespace().next()?;
177                return Some(tool.to_string());
178            }
179        }
180
181        // Try to extract filename from editing status
182        if cleaned.contains("Editing") {
183            // Split on "Editing" and take the part after it
184            let parts: Vec<&str> = cleaned.splitn(2, "Editing").collect();
185            if parts.len() == 2 {
186                let after = parts[1].trim();
187                // Find the end of the filename (space, colon, or end of string)
188                let end_pos = after
189                    .find(|c: char| c == ':' || c.is_whitespace())
190                    .unwrap_or(after.len());
191                let filename = after[..end_pos].trim();
192                if !filename.is_empty() {
193                    // Just show the filename without path
194                    let name = filename.split('/').next_back().unwrap_or(filename);
195                    return Some(name.to_string());
196                }
197            }
198        }
199
200        None
201    }
202
203    /// Update the terminal title if it has changed.
204    pub fn update_terminal_title(&mut self) {
205        let new_title = self.generate_terminal_title();
206
207        // Only update if the title has changed to avoid redundant operations
208        if self.last_terminal_title.as_ref() != Some(&new_title) {
209            if let Err(error) = execute!(std::io::stderr(), SetTitle(new_title.as_str())) {
210                tracing::debug!(%error, "failed to update terminal title");
211            }
212
213            self.last_terminal_title = Some(new_title);
214        }
215    }
216
217    /// Clear terminal title (reset to default)
218    pub fn clear_terminal_title(&mut self) {
219        if let Err(error) = execute!(std::io::stderr(), SetTitle("")) {
220            tracing::debug!(%error, "failed to clear terminal title");
221        }
222
223        self.last_terminal_title = None;
224    }
225}
226
227/// Sanitize string for use in terminal title (remove problematic characters)
228fn sanitize_for_terminal_title(s: &str) -> String {
229    s.chars()
230        .map(|c| {
231            // Replace problematic characters with safe alternatives
232            match c {
233                // Remove control characters
234                c if c.is_control() => ' ',
235                // Replace backslash with forward slash for cleaner display
236                '\\' => '/',
237                // Keep alphanumeric and common punctuation
238                c if c.is_ascii_alphanumeric() || "_.-".contains(c) => c,
239                // Replace other special chars with space
240                _ => ' ',
241            }
242        })
243        .collect::<String>()
244        .split_whitespace()
245        .collect::<Vec<&str>>()
246        .join(" ")
247}
248
249/// Truncate title to maximum length while preserving readability
250fn truncate_title(title: String) -> String {
251    const ELLIPSIS: &str = "…";
252    if title.len() <= MAX_TITLE_LENGTH {
253        title
254    } else {
255        // Truncate and add ellipsis
256        let truncated = &title[..MAX_TITLE_LENGTH.saturating_sub(ELLIPSIS.len())];
257        format!("{}{}", truncated, ELLIPSIS)
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn test_strip_spinner_prefix() {
267        assert_eq!(
268            Session::strip_spinner_prefix("⠋ Running command: cargo"),
269            "Running command: cargo"
270        );
271        assert_eq!(
272            Session::strip_spinner_prefix("⠙ Running tool: test"),
273            "Running tool: test"
274        );
275        assert_eq!(Session::strip_spinner_prefix("- Building"), "Building");
276        assert_eq!(Session::strip_spinner_prefix("| Checking"), "Checking");
277        assert_eq!(Session::strip_spinner_prefix("  No spinner"), "No spinner");
278    }
279
280    #[test]
281    fn test_sanitize_for_terminal_title() {
282        assert_eq!(sanitize_for_terminal_title("cargo build"), "cargo build");
283        assert_eq!(sanitize_for_terminal_title("cargo\\build"), "cargo/build");
284        assert_eq!(sanitize_for_terminal_title("test$cmd"), "test cmd");
285        assert_eq!(sanitize_for_terminal_title("file\tname"), "file name");
286    }
287
288    #[test]
289    fn test_truncate_title() {
290        assert_eq!(truncate_title("Short".to_string()), "Short");
291        let long = "a".repeat(150);
292        let truncated = truncate_title(long.clone());
293        assert!(truncated.len() <= MAX_TITLE_LENGTH);
294        assert!(truncated.ends_with("…"));
295    }
296}