From e5f5767d2c44f6342357c5b73ef7cfe710ec9566 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Thu, 28 May 2026 23:47:05 -0400 Subject: [PATCH] Preserve terminal spinners in thread titles (#57983) Summary: - Preserve spinner/logo prefixes from live terminal titles when terminal threads have custom titles. - Store raw terminal titles and custom user titles separately, recomposing display titles on demand. - Keep spinner prefixes out of the title editor while preserving sidebar/search display behavior. Tests: - cargo test -p agent_ui test_terminal_custom_title_recomposes_with_live_spinner -- --nocapture - cargo test -p agent_ui test_terminal_title_editor_excludes_spinner_prefix -- --nocapture - cargo test -p sidebar test_agent_panel_terminals_appear_in_sidebar_and_search -- --nocapture Closes AI-304 Release Notes: - Fixed terminal thread titles to preserve animated spinner and logo prefixes after renaming. --- crates/agent_ui/src/agent_panel.rs | 277 +++++++++++++++--- .../src/terminal_thread_metadata_store.rs | 96 ++++++ crates/sidebar/src/sidebar.rs | 5 +- crates/sidebar/src/sidebar_tests.rs | 11 +- crates/sidebar/src/thread_switcher.rs | 2 +- 5 files changed, 348 insertions(+), 43 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 72ba53045e5..91b17a91a79 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -38,7 +38,10 @@ use crate::ExpandMessageEditor; use crate::ManageProfiles; use crate::agent_connection_store::AgentConnectionStore; use crate::completion_provider::AgentContextSource; -use crate::terminal_thread_metadata_store::{TerminalThreadMetadata, TerminalThreadMetadataStore}; +use crate::terminal_thread_metadata_store::{ + TerminalThreadMetadata, TerminalThreadMetadataStore, compose_terminal_thread_title, + terminal_title_without_prefix, +}; use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore, ThreadMetadataStoreEvent}; use crate::{ AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, Follow, @@ -870,6 +873,7 @@ struct AgentTerminal { title_editor_initial_title: Option, title_editor_subscription: Option, last_known_title: String, + last_known_terminal_title: String, last_observed_program: Option, working_directory: Option, created_at: DateTime, @@ -880,32 +884,58 @@ struct AgentTerminal { } impl AgentTerminal { - fn title(&self, cx: &App) -> SharedString { - let view = self.view.read(cx); - let title = if let Some(custom_title) = view.custom_title() { - SharedString::from(custom_title) - } else { - let terminal = view.terminal().read(cx); - if terminal.breadcrumb_text.is_empty() { - let title = terminal.title(true); - if title == "Terminal" { - SharedString::from("") - } else { - title.into() - } + fn terminal_title_for_view(view: &TerminalView, cx: &App) -> SharedString { + let terminal = view.terminal().read(cx); + if terminal.breadcrumb_text.is_empty() { + let title = terminal.title(true); + if title == "Terminal" { + SharedString::from("") } else { - terminal.breadcrumb_text.clone().into() + title.into() } - }; + } else { + terminal.breadcrumb_text.clone().into() + } + } - if title.is_empty() && !self.last_known_title.is_empty() { - SharedString::from(self.last_known_title.clone()) + fn current_terminal_title(&self, cx: &App) -> SharedString { + let view = self.view.read(cx); + Self::terminal_title_for_view(view, cx) + } + + fn terminal_title(&self, cx: &App) -> SharedString { + let title = self.current_terminal_title(cx); + if title.is_empty() && !self.last_known_terminal_title.is_empty() { + SharedString::from(self.last_known_terminal_title.clone()) } else { title } } + fn title(&self, cx: &App) -> SharedString { + let terminal_title = self.terminal_title(cx); + let custom_title = self.custom_title(cx); + compose_terminal_thread_title( + terminal_title.as_ref(), + custom_title.as_ref().map(|title| title.as_ref()), + ) + } + + fn editable_title(&self, cx: &App) -> SharedString { + if let Some(custom_title) = self.custom_title(cx) { + custom_title + } else { + let terminal_title = self.terminal_title(cx); + SharedString::from(terminal_title_without_prefix(terminal_title.as_ref()).to_string()) + } + } + fn refresh_title(&mut self, cx: &mut App) -> bool { + let terminal_title = self.current_terminal_title(cx); + if !terminal_title.is_empty() { + self.last_known_terminal_title = terminal_title.to_string(); + } + let title = self.title(cx); let changed = self.last_known_title != title.as_ref(); if changed { @@ -1981,14 +2011,16 @@ impl AgentPanel { }, ); + let last_known_terminal_title = initial_title + .map(|title| title.to_string()) + .unwrap_or_default(); let mut terminal = AgentTerminal { view: terminal_view, title_editor: None, title_editor_initial_title: None, title_editor_subscription: None, - last_known_title: initial_title - .map(|title| title.to_string()) - .unwrap_or_default(), + last_known_title: last_known_terminal_title.clone(), + last_known_terminal_title, last_observed_program: None, working_directory, created_at: created_at.unwrap_or_else(Utc::now), @@ -2164,7 +2196,7 @@ impl AgentPanel { let project = self.project.read(cx); Some(TerminalThreadMetadata { terminal_id, - title: terminal.title(cx), + title: terminal.terminal_title(cx), custom_title: terminal.custom_title(cx), created_at: terminal.created_at, worktree_paths: project.worktree_paths(cx), @@ -2242,10 +2274,7 @@ impl AgentPanel { } fn terminal_restore_initial_title(metadata: &TerminalThreadMetadata) -> Option { - metadata - .custom_title - .clone() - .or_else(|| (!metadata.title.is_empty()).then(|| metadata.title.clone())) + (!metadata.title.is_empty()).then(|| metadata.title.clone()) } fn edit_terminal_title( @@ -2263,7 +2292,7 @@ impl AgentPanel { return; } - let title = terminal.title(cx).to_string(); + let title = terminal.editable_title(cx).to_string(); let title_editor_initial_title = title.clone(); let title_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); @@ -2331,7 +2360,7 @@ impl AgentPanel { if !title_editor.read(cx).is_focused(window) { return; } - let Some((terminal_view, initial_title)) = + let Some((terminal_view, initial_title, terminal_title)) = self.terminals.get(&terminal_id).and_then(|terminal| { terminal .title_editor @@ -2341,25 +2370,23 @@ impl AgentPanel { ( terminal.view.clone(), terminal.title_editor_initial_title.clone(), + terminal.terminal_title(cx), ) }) }) else { return; }; - let new_title = title_editor.read(cx).text(cx).trim().to_string(); - if initial_title.as_deref().map(str::trim) == Some(new_title.as_str()) { + let new_title = title_editor.read(cx).text(cx); + if initial_title.as_deref() == Some(new_title.as_str()) { return; } - let label = if new_title.is_empty() { + let label = if new_title.trim().is_empty() + || new_title == terminal_title_without_prefix(terminal_title.as_ref()) + { None } else { - let terminal_title = terminal_view.read(cx).terminal().read(cx).title(true); - if new_title == terminal_title { - None - } else { - Some(new_title) - } + Some(new_title) }; cx.defer(move |cx| { @@ -9008,6 +9035,182 @@ mod tests { }); } + #[gpui::test] + async fn test_terminal_custom_title_recomposes_with_live_spinner(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Fix bug", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + let terminal_entity = panel.read_with(&cx, |panel, _cx| { + panel + .terminals + .get(&terminal_id) + .expect("terminal should remain in the panel") + .view + .clone() + }); + let terminal_entity = + terminal_entity.read_with(&cx, |terminal_view, _cx| terminal_view.terminal().clone()); + + terminal_entity.update(&mut cx, |terminal, cx| { + terminal.breadcrumb_text = "⠋ Thinking".to_string(); + cx.emit(TerminalEvent::BreadcrumbsChanged); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + let terminals = panel.terminals(cx); + assert_eq!(terminals.len(), 1); + assert_eq!(terminals[0].title.as_ref(), "⠋ Fix bug"); + let metadata = panel + .terminal_metadata(terminal_id, cx) + .expect("terminal metadata should be available"); + assert_eq!(metadata.title.as_ref(), "⠋ Thinking"); + assert_eq!( + metadata.custom_title.as_ref().map(|title| title.as_ref()), + Some("Fix bug") + ); + assert_eq!(metadata.display_title().as_ref(), "⠋ Fix bug"); + }); + + terminal_entity.update(&mut cx, |terminal, cx| { + terminal.breadcrumb_text = "⠙ Thinking".to_string(); + cx.emit(TerminalEvent::BreadcrumbsChanged); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + let terminals = panel.terminals(cx); + assert_eq!(terminals.len(), 1); + assert_eq!(terminals[0].title.as_ref(), "⠙ Fix bug"); + let metadata = panel + .terminal_metadata(terminal_id, cx) + .expect("terminal metadata should be available"); + assert_eq!(metadata.title.as_ref(), "⠙ Thinking"); + assert_eq!(metadata.display_title().as_ref(), "⠙ Fix bug"); + }); + + terminal_entity.update(&mut cx, |terminal, cx| { + terminal.breadcrumb_text = "Thinking".to_string(); + cx.emit(TerminalEvent::BreadcrumbsChanged); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + let terminals = panel.terminals(cx); + assert_eq!(terminals.len(), 1); + assert_eq!(terminals[0].title.as_ref(), "Fix bug"); + let metadata = panel + .terminal_metadata(terminal_id, cx) + .expect("terminal metadata should be available"); + assert_eq!(metadata.title.as_ref(), "Thinking"); + assert_eq!(metadata.display_title().as_ref(), "Fix bug"); + }); + } + + #[gpui::test] + async fn test_terminal_title_editor_excludes_spinner_prefix(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Initial Custom Title", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + let terminal_view = panel.read_with(&cx, |panel, _cx| { + panel + .terminals + .get(&terminal_id) + .expect("terminal should remain in the panel") + .view + .clone() + }); + terminal_view.update(&mut cx, |terminal_view, cx| { + terminal_view.set_custom_title(None, cx); + }); + let terminal_entity = + terminal_view.read_with(&cx, |terminal_view, _cx| terminal_view.terminal().clone()); + terminal_entity.update(&mut cx, |terminal, cx| { + terminal.breadcrumb_text = "⠋ Thinking".to_string(); + cx.emit(TerminalEvent::BreadcrumbsChanged); + }); + cx.run_until_parked(); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.edit_terminal_title(terminal_id, window, cx); + }); + cx.run_until_parked(); + + let title_editor = panel.read_with(&cx, |panel, cx| { + let terminal = panel + .terminals + .get(&terminal_id) + .expect("terminal should remain in the panel"); + let title_editor = terminal + .title_editor + .as_ref() + .expect("terminal title editor should be active while editing") + .clone(); + assert_eq!(title_editor.read(cx).text(cx), "Thinking"); + title_editor + }); + + title_editor.update_in(&mut cx, |editor, window, cx| { + editor.set_text("Fix bug", window, cx); + editor.focus_handle(cx).focus(window, cx); + }); + panel.update_in(&mut cx, |panel, window, cx| { + panel.handle_terminal_title_editor_event( + terminal_id, + &title_editor, + &editor::EditorEvent::BufferEdited, + window, + cx, + ); + }); + cx.run_until_parked(); + + terminal_view.read_with(&cx, |terminal_view, _cx| { + assert_eq!(terminal_view.custom_title(), Some("Fix bug")); + }); + panel.read_with(&cx, |panel, cx| { + let terminals = panel.terminals(cx); + assert_eq!(terminals.len(), 1); + assert_eq!(terminals[0].title.as_ref(), "⠋ Fix bug"); + let metadata = panel + .terminal_metadata(terminal_id, cx) + .expect("terminal metadata should be available"); + assert_eq!(metadata.title.as_ref(), "⠋ Thinking"); + assert_eq!( + metadata.custom_title.as_ref().map(|title| title.as_ref()), + Some("Fix bug") + ); + }); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.stop_editing_terminal_title(terminal_id, false, window, cx); + panel.edit_terminal_title(terminal_id, window, cx); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + let terminal = panel + .terminals + .get(&terminal_id) + .expect("terminal should remain in the panel"); + let title_editor = terminal + .title_editor + .as_ref() + .expect("terminal title editor should be active while editing"); + assert_eq!(title_editor.read(cx).text(cx), "Fix bug"); + }); + } + #[gpui::test] async fn test_terminal_bell_marks_and_activation_clears_notification(cx: &mut TestAppContext) { let (panel, mut cx) = setup_panel(cx).await; diff --git a/crates/agent_ui/src/terminal_thread_metadata_store.rs b/crates/agent_ui/src/terminal_thread_metadata_store.rs index c5e2dbbdfba..6baedca4289 100644 --- a/crates/agent_ui/src/terminal_thread_metadata_store.rs +++ b/crates/agent_ui/src/terminal_thread_metadata_store.rs @@ -63,6 +63,76 @@ impl TerminalThreadMetadata { pub fn main_worktree_paths(&self) -> &PathList { self.worktree_paths.main_worktree_path_list() } + + pub fn display_title(&self) -> SharedString { + compose_terminal_thread_title( + self.title.as_ref(), + self.custom_title.as_ref().map(|title| title.as_ref()), + ) + } +} + +pub(crate) fn compose_terminal_thread_title( + terminal_title: &str, + custom_title: Option<&str>, +) -> SharedString { + let Some(custom_title) = custom_title.filter(|title| !title.trim().is_empty()) else { + return SharedString::from(terminal_title.to_string()); + }; + + if let Some(prefix) = terminal_title_prefix(terminal_title) { + SharedString::from(format!("{prefix}{custom_title}")) + } else { + SharedString::from(custom_title.to_string()) + } +} + +pub(crate) fn terminal_title_without_prefix(title: &str) -> &str { + terminal_title_prefix(title) + .map(|prefix| &title[prefix.len()..]) + .unwrap_or(title) +} + +fn terminal_title_prefix(title: &str) -> Option<&str> { + let mut prefix_byte_len = 0; + let mut saw_prefix_character = false; + let mut saw_whitespace_after_prefix = false; + + let mut chars = title.chars().peekable(); + while let Some(character) = chars.next() { + if character.is_alphanumeric() { + return None; + } + + if character.is_whitespace() { + if !saw_prefix_character { + return None; + } + + prefix_byte_len += character.len_utf8(); + saw_whitespace_after_prefix = true; + + while let Some(character) = chars.peek() { + if !character.is_whitespace() { + break; + } + + prefix_byte_len += character.len_utf8(); + chars.next(); + } + + break; + } + + saw_prefix_character = true; + prefix_byte_len += character.len_utf8(); + } + + if saw_whitespace_after_prefix { + Some(&title[..prefix_byte_len]) + } else { + None + } } pub struct TerminalThreadMetadataStore { @@ -563,6 +633,32 @@ mod tests { } } + #[test] + fn test_terminal_title_prefix_preserves_non_alphanumeric_prefixes() { + assert_eq!(terminal_title_prefix("✳ Thinking"), Some("✳ ")); + assert_eq!(terminal_title_prefix(">>> Thinking"), Some(">>> ")); + assert_eq!(terminal_title_prefix("⠋ Running"), Some("⠋ ")); + assert_eq!(terminal_title_prefix("* Claude"), Some("* ")); + assert_eq!(terminal_title_prefix("✳Thinking"), None); + assert_eq!(terminal_title_prefix("Thinking"), None); + assert_eq!(terminal_title_prefix(" Thinking"), None); + assert_eq!(terminal_title_prefix("✳"), None); + assert_eq!(terminal_title_prefix("v1 Running"), None); + } + + #[test] + fn test_terminal_thread_display_title_combines_raw_and_custom_titles() { + let mut metadata = metadata( + "⠋ Thinking", + WorktreePaths::from_folder_paths(&PathList::default()), + ); + metadata.custom_title = Some("Fix bug".into()); + assert_eq!(metadata.display_title().as_ref(), "⠋ Fix bug"); + + metadata.title = "Thinking".into(); + assert_eq!(metadata.display_title().as_ref(), "Fix bug"); + } + #[gpui::test] async fn test_change_worktree_paths_reindexes_terminal_metadata(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 81d65e63366..0a83af342ff 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -1735,7 +1735,8 @@ impl Sidebar { let mut matched_terminals: Vec = Vec::new(); for mut terminal in terminals { let mut terminal_matched = false; - if let Some(positions) = fuzzy_match_positions(&query, &terminal.metadata.title) + let terminal_title = terminal.metadata.display_title(); + if let Some(positions) = fuzzy_match_positions(&query, terminal_title.as_ref()) { terminal.highlight_positions = positions; terminal_matched = true; @@ -5858,7 +5859,7 @@ impl Sidebar { ); let is_remote = terminal.workspace.is_remote(cx); - ThreadItem::new(id, terminal.metadata.title.clone()) + ThreadItem::new(id, terminal.metadata.display_title()) .base_bg(sidebar_bg) .icon(IconName::Terminal) .is_remote(is_remote) diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 40cfc62fa39..c1a06095333 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -595,7 +595,7 @@ fn visible_entries_as_strings( } } ListEntry::Terminal(terminal) => { - let title = &terminal.metadata.title; + let title = terminal.metadata.display_title(); let worktree = format_linked_worktree_chips(&terminal.worktrees); format!(" {title}{worktree}{selected}") } @@ -1712,7 +1712,7 @@ async fn test_agent_panel_terminals_appear_in_sidebar_and_search(cx: &mut TestAp ); assert!( sidebar.contents.entries.iter().any(|entry| { - matches!(entry, ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id && terminal.metadata.title.as_ref() == "Dev Server") + matches!(entry, ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id && terminal.metadata.display_title().as_ref() == "Dev Server") }), "expected the inserted terminal to appear in sidebar contents", ); @@ -1722,7 +1722,12 @@ async fn test_agent_panel_terminals_appear_in_sidebar_and_search(cx: &mut TestAp let metadata = store .entry(terminal_id) .expect("terminal metadata should be persisted"); - assert_eq!(metadata.title.as_ref(), "Dev Server"); + assert_eq!(metadata.title.as_ref(), ""); + assert_eq!( + metadata.custom_title.as_ref().map(|title| title.as_ref()), + Some("Dev Server") + ); + assert_eq!(metadata.display_title().as_ref(), "Dev Server"); assert!( metadata .folder_paths() diff --git a/crates/sidebar/src/thread_switcher.rs b/crates/sidebar/src/thread_switcher.rs index e2f9cddd747..aa947416082 100644 --- a/crates/sidebar/src/thread_switcher.rs +++ b/crates/sidebar/src/thread_switcher.rs @@ -89,7 +89,7 @@ impl ThreadSwitcherEntry { fn title(&self) -> SharedString { match self { Self::Thread(entry) => entry.title.clone(), - Self::Terminal(entry) => entry.metadata.title.clone(), + Self::Terminal(entry) => entry.metadata.display_title(), } }