Skip to main content

vtcode_tui/core_tui/session/
terminal_title.rs

1use 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}