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.
This commit is contained in:
Richard Feldman 2026-05-28 23:47:05 -04:00 committed by GitHub
parent 12aacf3cea
commit e5f5767d2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 348 additions and 43 deletions

View file

@ -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<String>,
title_editor_subscription: Option<Subscription>,
last_known_title: String,
last_known_terminal_title: String,
last_observed_program: Option<String>,
working_directory: Option<PathBuf>,
created_at: DateTime<Utc>,
@ -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<SharedString> {
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;

View file

@ -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);

View file

@ -1735,7 +1735,8 @@ impl Sidebar {
let mut matched_terminals: Vec<TerminalEntry> = 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)

View file

@ -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()

View file

@ -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(),
}
}