vtcode_tui/core_tui/session/
terminal_title.rs1use std::collections::HashSet;
2use std::io::Write;
3
4use crate::config::constants::ui;
5
6use super::Session;
7
8const MAX_TITLE_LENGTH: usize = 128;
9const OSC_SET_WINDOW_TITLE: &str = "\u{1b}]0;";
10const OSC_TERMINATOR: &str = "\u{7}";
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13enum TerminalTitleItem {
14 AppName,
15 Project,
16 Spinner,
17 Status,
18 Thread,
19 GitBranch,
20 Model,
21 TaskProgress,
22}
23
24impl TerminalTitleItem {
25 fn from_id(id: &str) -> Option<Self> {
26 match id.trim() {
27 "app-name" => Some(Self::AppName),
28 "project" => Some(Self::Project),
29 "spinner" => Some(Self::Spinner),
30 "status" => Some(Self::Status),
31 "thread" => Some(Self::Thread),
32 "git-branch" => Some(Self::GitBranch),
33 "model" => Some(Self::Model),
34 "task-progress" => Some(Self::TaskProgress),
35 _ => None,
36 }
37 }
38
39 fn default_items() -> [Self; 2] {
40 [Self::Spinner, Self::Project]
41 }
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45enum TerminalTitleStatus {
46 Ready,
47 Thinking,
48 Working,
49 Waiting,
50 Undoing,
51 ActionRequired,
52}
53
54impl TerminalTitleStatus {
55 fn label(self) -> &'static str {
56 match self {
57 Self::Ready => "Ready",
58 Self::Thinking => "Thinking",
59 Self::Working => "Working",
60 Self::Waiting => "Waiting",
61 Self::Undoing => "Undoing",
62 Self::ActionRequired => "Action Required",
63 }
64 }
65}
66
67#[derive(Clone, Debug, PartialEq, Eq)]
68struct RenderedTitlePart {
69 text: String,
70 spinner: bool,
71}
72
73impl Session {
74 pub fn set_workspace_root(&mut self, workspace_root: Option<std::path::PathBuf>) {
75 self.workspace_root = workspace_root;
76 }
77
78 fn extract_project_name(&self) -> String {
79 self.workspace_root
80 .as_ref()
81 .and_then(|path| {
82 path.file_name()
83 .or_else(|| path.parent()?.file_name())
84 .map(|name| name.to_string_lossy().to_string())
85 })
86 .unwrap_or_else(|| self.app_name.clone())
87 }
88
89 fn strip_spinner_prefix(text: &str) -> &str {
90 text.trim_start_matches(|c: char| {
91 c == '⠋'
92 || c == '⠙'
93 || c == '⠹'
94 || c == '⠸'
95 || c == '⠼'
96 || c == '⠴'
97 || c == '⠦'
98 || c == '⠧'
99 || c == '⠇'
100 || c == '⠏'
101 || c == '-'
102 || c == '\\'
103 || c == '|'
104 || c == '/'
105 || c == '.'
106 })
107 .trim_start()
108 }
109
110 fn terminal_title_status(&self) -> TerminalTitleStatus {
111 let left = self
112 .input_status_left
113 .as_deref()
114 .map(Self::strip_spinner_prefix)
115 .unwrap_or("")
116 .trim()
117 .to_ascii_lowercase();
118
119 if self.has_status_spinner()
120 || left.contains("action required")
121 || left.contains("approval")
122 || left.contains("input required")
123 {
124 TerminalTitleStatus::ActionRequired
125 } else if left.contains("undo") || left.contains("rewind") || left.contains("revert") {
126 TerminalTitleStatus::Undoing
127 } else if left.contains("waiting") || left.contains("queued") || left.contains("paused") {
128 TerminalTitleStatus::Waiting
129 } else if self.thinking_spinner.is_active
130 || left.contains("thinking")
131 || left.contains("processing")
132 {
133 TerminalTitleStatus::Thinking
134 } else if self.is_running_activity() {
135 TerminalTitleStatus::Working
136 } else {
137 TerminalTitleStatus::Ready
138 }
139 }
140
141 fn resolve_terminal_title_items(&self) -> Option<Vec<TerminalTitleItem>> {
142 let items = match &self.terminal_title_items {
143 Some(items) if items.is_empty() => return None,
144 Some(items) => items
145 .iter()
146 .filter_map(|item| TerminalTitleItem::from_id(item))
147 .collect::<Vec<_>>(),
148 None => TerminalTitleItem::default_items().to_vec(),
149 };
150
151 (!items.is_empty()).then_some(items)
152 }
153
154 fn title_item_value(&self, item: TerminalTitleItem) -> Option<RenderedTitlePart> {
155 let status = self.terminal_title_status();
156 let text = match item {
157 TerminalTitleItem::AppName => Some(self.app_name.clone()),
158 TerminalTitleItem::Project => Some(self.extract_project_name()),
159 TerminalTitleItem::Spinner => self.spinner_title_value(status),
160 TerminalTitleItem::Status => Some(status.label().to_string()),
161 TerminalTitleItem::Thread => self.terminal_title_thread_label.clone(),
162 TerminalTitleItem::GitBranch => self.terminal_title_git_branch.clone(),
163 TerminalTitleItem::Model => {
164 strip_header_value(&self.header_context.model, ui::HEADER_MODEL_PREFIX)
165 }
166 TerminalTitleItem::TaskProgress => self.terminal_title_task_progress.clone(),
167 }?;
168
169 Some(RenderedTitlePart {
170 text,
171 spinner: item == TerminalTitleItem::Spinner,
172 })
173 }
174
175 fn spinner_title_value(&self, status: TerminalTitleStatus) -> Option<String> {
176 match status {
177 TerminalTitleStatus::Ready => None,
178 TerminalTitleStatus::ActionRequired => Some("!".to_string()),
179 TerminalTitleStatus::Thinking => {
180 Some(self.thinking_spinner.current_frame().to_string())
181 }
182 TerminalTitleStatus::Working
183 | TerminalTitleStatus::Waiting
184 | TerminalTitleStatus::Undoing => Some("...".to_string()),
185 }
186 }
187
188 fn render_terminal_title(&self) -> Option<String> {
189 let items = self.resolve_terminal_title_items()?;
190 let mut parts = Vec::new();
191 let mut seen = HashSet::new();
192 for item in items {
193 let Some(part) = self.title_item_value(item) else {
194 continue;
195 };
196 let key = normalize_title_part(&part.text);
197 if key.is_empty() || seen.contains(&key) {
198 continue;
199 }
200 seen.insert(key);
201 parts.push(part);
202 }
203 if parts.is_empty() {
204 return None;
205 }
206
207 let mut title = String::new();
208 for (index, part) in parts.iter().enumerate() {
209 if index > 0 {
210 let previous_spinner = parts[index - 1].spinner;
211 title.push_str(if previous_spinner || part.spinner {
212 " "
213 } else {
214 " | "
215 });
216 }
217 title.push_str(&part.text);
218 }
219
220 sanitize_terminal_title(&title)
221 }
222
223 pub fn update_terminal_title(&mut self) {
224 let Some(new_title) = self.render_terminal_title() else {
225 self.clear_terminal_title();
226 return;
227 };
228
229 if self.last_terminal_title.as_ref() != Some(&new_title) {
230 if let Err(error) = write_terminal_title(&new_title) {
231 tracing::debug!(%error, "failed to update terminal title");
232 } else {
233 self.last_terminal_title = Some(new_title);
234 }
235 }
236 }
237
238 pub fn clear_terminal_title(&mut self) {
239 if self.last_terminal_title.is_none() {
240 return;
241 }
242
243 if let Err(error) = write_terminal_title("") {
244 tracing::debug!(%error, "failed to clear terminal title");
245 return;
246 }
247 self.last_terminal_title = None;
248 }
249}
250
251fn strip_header_value(value: &str, prefix: &str) -> Option<String> {
252 let trimmed = value.trim();
253 let stripped = trimmed.strip_prefix(prefix).unwrap_or(trimmed).trim();
254 if stripped.is_empty() || stripped == ui::HEADER_UNKNOWN_PLACEHOLDER {
255 None
256 } else {
257 Some(stripped.to_string())
258 }
259}
260
261fn write_terminal_title(title: &str) -> std::io::Result<()> {
262 let mut stdout = std::io::stdout();
263 stdout.write_all(OSC_SET_WINDOW_TITLE.as_bytes())?;
264 stdout.write_all(title.as_bytes())?;
265 stdout.write_all(OSC_TERMINATOR.as_bytes())?;
266 stdout.flush()
267}
268
269fn sanitize_terminal_title(title: &str) -> Option<String> {
270 let collapsed = title
271 .chars()
272 .filter_map(|ch| {
273 if is_stripped_terminal_title_char(ch) {
274 None
275 } else if ch.is_control() {
276 Some(' ')
277 } else {
278 Some(ch)
279 }
280 })
281 .collect::<String>()
282 .split_whitespace()
283 .collect::<Vec<_>>()
284 .join(" ");
285
286 if collapsed.is_empty() {
287 return None;
288 }
289
290 Some(truncate_title(&collapsed))
291}
292
293fn is_stripped_terminal_title_char(ch: char) -> bool {
294 matches!(
295 ch,
296 '\u{00ad}'
297 | '\u{200b}'
298 | '\u{200c}'
299 | '\u{200d}'
300 | '\u{200e}'
301 | '\u{200f}'
302 | '\u{202a}'..='\u{202e}'
303 | '\u{2060}'..='\u{2064}'
304 | '\u{2066}'..='\u{2069}'
305 | '\u{feff}'
306 )
307}
308
309fn truncate_title(title: &str) -> String {
310 const ELLIPSIS: &str = vtcode_design::constants::ELLIPSIS_ASCII;
311 let char_count = title.chars().count();
312 if char_count <= MAX_TITLE_LENGTH {
313 return title.to_string();
314 }
315
316 let keep = MAX_TITLE_LENGTH.saturating_sub(ELLIPSIS.chars().count());
317 let truncated = title.chars().take(keep).collect::<String>();
318 format!("{truncated}{ELLIPSIS}")
319}
320
321fn normalize_title_part(value: &str) -> String {
322 value
323 .split_whitespace()
324 .collect::<Vec<_>>()
325 .join(" ")
326 .to_ascii_lowercase()
327}
328
329#[cfg(test)]
330mod tests {
331 use super::{
332 Session, TerminalTitleStatus, is_stripped_terminal_title_char, normalize_title_part,
333 sanitize_terminal_title, truncate_title,
334 };
335
336 fn session_for_title_tests() -> Session {
337 let mut session = Session::new(Default::default(), None, 24);
338 session.app_name = "VT Code".to_string();
339 session
340 }
341
342 #[test]
343 fn default_title_uses_spinner_and_project_items() {
344 let mut session = session_for_title_tests();
345 session.set_workspace_root(Some(std::path::PathBuf::from("/tmp/demo-project")));
346 session.input_status_left = Some("Thinking".to_string());
347 session.thinking_spinner.start();
348
349 assert_eq!(
350 session.render_terminal_title().as_deref(),
351 Some("⠋ demo-project")
352 );
353 }
354
355 #[test]
356 fn unavailable_items_are_omitted() {
357 let mut session = session_for_title_tests();
358 session.terminal_title_items = Some(vec![
359 "thread".to_string(),
360 "project".to_string(),
361 "git-branch".to_string(),
362 ]);
363 session.set_workspace_root(Some(std::path::PathBuf::from("/tmp/demo-project")));
364
365 assert_eq!(
366 session.render_terminal_title().as_deref(),
367 Some("demo-project")
368 );
369 }
370
371 #[test]
372 fn spinner_uses_plain_space_separator() {
373 let mut session = session_for_title_tests();
374 session.terminal_title_items = Some(vec![
375 "project".to_string(),
376 "spinner".to_string(),
377 "status".to_string(),
378 ]);
379 session.set_workspace_root(Some(std::path::PathBuf::from("/tmp/demo-project")));
380 session.input_status_left = Some("Thinking".to_string());
381 session.thinking_spinner.start();
382
383 assert_eq!(
384 session.render_terminal_title().as_deref(),
385 Some("demo-project ⠋ Thinking")
386 );
387 }
388
389 #[test]
390 fn status_label_mapping_prefers_short_labels() {
391 let mut session = session_for_title_tests();
392 session.input_status_left = Some("Action Required: approve command".to_string());
393 assert_eq!(
394 session.terminal_title_status(),
395 TerminalTitleStatus::ActionRequired
396 );
397
398 session.input_status_left = Some("Rewinding last turn".to_string());
399 assert_eq!(
400 session.terminal_title_status(),
401 TerminalTitleStatus::Undoing
402 );
403
404 session.input_status_left = Some("Waiting for tool".to_string());
405 assert_eq!(
406 session.terminal_title_status(),
407 TerminalTitleStatus::Waiting
408 );
409 }
410
411 #[test]
412 fn explicit_empty_items_disable_title_updates() {
413 let mut session = session_for_title_tests();
414 session.terminal_title_items = Some(Vec::new());
415 session.set_workspace_root(Some(std::path::PathBuf::from("/tmp/demo-project")));
416
417 assert_eq!(session.render_terminal_title(), None);
418 }
419
420 #[test]
421 fn sanitization_strips_control_and_bidi_chars() {
422 let sanitized = sanitize_terminal_title("demo\u{1b}]0;bad\u{7}\u{202e} title\tok")
423 .expect("title should survive sanitization");
424
425 assert_eq!(sanitized, "demo ]0;bad title ok");
426 }
427
428 #[test]
429 fn invisible_formatting_chars_are_removed() {
430 assert!(is_stripped_terminal_title_char('\u{2066}'));
431 assert!(is_stripped_terminal_title_char('\u{200b}'));
432 assert!(!is_stripped_terminal_title_char('a'));
433 }
434
435 #[test]
436 fn sanitization_returns_none_when_title_is_empty_after_cleanup() {
437 assert_eq!(sanitize_terminal_title("\u{200b}\u{202e}\t"), None);
438 }
439
440 #[test]
441 fn title_truncation_caps_length() {
442 let title = "x".repeat(200);
443 let truncated = truncate_title(&title);
444
445 assert_eq!(truncated.chars().count(), 128);
446 assert!(truncated.ends_with("..."));
447 }
448
449 #[test]
450 fn task_progress_item_uses_parsed_summary() {
451 let mut session = session_for_title_tests();
452 session.terminal_title_items =
453 Some(vec!["task-progress".to_string(), "project".to_string()]);
454 session.terminal_title_task_progress = Some("2/5".to_string());
455 session.set_workspace_root(Some(std::path::PathBuf::from("/tmp/demo-project")));
456
457 assert_eq!(
458 session.render_terminal_title().as_deref(),
459 Some("2/5 | demo-project")
460 );
461 }
462
463 #[test]
464 fn invalid_terminal_title_items_are_ignored() {
465 let mut session = session_for_title_tests();
466 session.terminal_title_items = Some(vec!["not-real".to_string(), "project".to_string()]);
467 session.set_workspace_root(Some(std::path::PathBuf::from("/tmp/demo-project")));
468
469 assert_eq!(
470 session.render_terminal_title().as_deref(),
471 Some("demo-project")
472 );
473 }
474
475 #[test]
476 fn duplicate_title_items_are_deduplicated() {
477 let mut session = session_for_title_tests();
478 session.terminal_title_items = Some(vec![
479 "thread".to_string(),
480 "git-branch".to_string(),
481 "status".to_string(),
482 ]);
483 session.terminal_title_thread_label = Some("main".to_string());
484 session.terminal_title_git_branch = Some("main".to_string());
485 session.input_status_left = Some("Ready".to_string());
486
487 assert_eq!(
488 session.render_terminal_title().as_deref(),
489 Some("main | Ready")
490 );
491 }
492
493 #[test]
494 fn normalize_title_part_collapses_spacing_and_case() {
495 assert_eq!(normalize_title_part(" Main Branch "), "main branch");
496 }
497}