vtcode_tui/core_tui/session/
terminal_title.rs1use ratatui::crossterm::{execute, terminal::SetTitle};
24
25use super::Session;
26
27const MAX_TITLE_LENGTH: usize = 128;
29
30impl Session {
31 pub fn set_workspace_root(&mut self, workspace_root: Option<std::path::PathBuf>) {
33 self.workspace_root = workspace_root;
34 }
35
36 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 fn strip_spinner_prefix(text: &str) -> &str {
50 text.trim_start_matches(|c: char| {
51 c == '⠋' || c == '⠙' || c == '⠹' || c == '⠸' || c == '⠼'
53 || c == '⠴' || c == '⠦' || c == '⠧' || c == '⠇' || c == '⠏'
54 || c == '-' || c == '\\' || c == '|' || c == '/' || c == '.'
56 })
57 .trim_start()
58 }
59
60 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 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 fn generate_terminal_title(&self) -> String {
115 let project_name = self.extract_project_name();
116
117 if let Some(action) = self.extract_action_from_status() {
119 let context = self.extract_context_from_status();
121
122 if let Some(ctx) = context {
123 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 if self.is_running_activity() {
139 return truncate_title(format!("> {} ({}) | Running", self.app_name, project_name));
140 }
141
142 if self.has_status_spinner() {
144 return truncate_title(format!(
145 "> {} ({}) | Action Required",
146 self.app_name, project_name
147 ));
148 }
149
150 truncate_title(format!("> {} ({})", self.app_name, project_name))
152 }
153
154 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 if cleaned.contains("Running command:") {
161 let parts: Vec<&str> = cleaned.splitn(2, "Running command:").collect();
163 if parts.len() == 2 {
164 let command = parts[1].split_whitespace().next()?;
165 let cmd_name = command.split('/').next_back().unwrap_or(command);
167 return Some(cmd_name.to_string());
168 }
169 }
170
171 if cleaned.contains("Running tool:") {
173 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 if cleaned.contains("Editing") {
183 let parts: Vec<&str> = cleaned.splitn(2, "Editing").collect();
185 if parts.len() == 2 {
186 let after = parts[1].trim();
187 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 let name = filename.split('/').next_back().unwrap_or(filename);
195 return Some(name.to_string());
196 }
197 }
198 }
199
200 None
201 }
202
203 pub fn update_terminal_title(&mut self) {
205 let new_title = self.generate_terminal_title();
206
207 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 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
227fn sanitize_for_terminal_title(s: &str) -> String {
229 s.chars()
230 .map(|c| {
231 match c {
233 c if c.is_control() => ' ',
235 '\\' => '/',
237 c if c.is_ascii_alphanumeric() || "_.-".contains(c) => c,
239 _ => ' ',
241 }
242 })
243 .collect::<String>()
244 .split_whitespace()
245 .collect::<Vec<&str>>()
246 .join(" ")
247}
248
249fn truncate_title(title: String) -> String {
251 const ELLIPSIS: &str = "…";
252 if title.len() <= MAX_TITLE_LENGTH {
253 title
254 } else {
255 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}