diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 9435b3b684..998c1fd787 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -41,7 +41,7 @@ use crate::agent_connection_store::AgentConnectionStore; use crate::completion_provider::{AgentContextSelection, AgentContextSource}; use crate::terminal_thread_metadata_store::{ TerminalThreadMetadata, TerminalThreadMetadataStore, compose_terminal_thread_title, - terminal_title_without_prefix, + normalize_terminal_custom_title, terminal_title_without_prefix, }; use crate::thread_metadata_store::{ThreadId, ThreadMetadataStore, ThreadMetadataStoreEvent}; use crate::{ @@ -2612,17 +2612,15 @@ impl AgentPanel { if initial_title.as_deref() == Some(new_title.as_str()) { return; } - let label = if new_title.trim().is_empty() - || new_title == terminal_title_without_prefix(terminal_title.as_ref()) - { - None - } else { - Some(new_title) - }; + let custom_title = normalize_terminal_custom_title( + terminal_title.as_ref(), + SharedString::from(new_title), + ); cx.defer(move |cx| { terminal_view.update(cx, |terminal_view, cx| { - terminal_view.set_custom_title(label, cx); + terminal_view + .set_custom_title(custom_title.map(|title| title.to_string()), cx); }); }); } @@ -3373,6 +3371,26 @@ impl AgentPanel { self.terminals.contains_key(&terminal_id) } + pub fn rename_terminal( + &mut self, + terminal_id: TerminalId, + title: SharedString, + cx: &mut Context, + ) -> bool { + let Some(terminal) = self.terminals.get(&terminal_id) else { + return false; + }; + let terminal_title = terminal.terminal_title(cx); + let custom_title = normalize_terminal_custom_title(terminal_title.as_ref(), title); + let terminal_view = terminal.view.clone(); + cx.defer(move |cx| { + terminal_view.update(cx, |terminal_view, cx| { + terminal_view.set_custom_title(custom_title.map(|title| title.to_string()), cx); + }); + }); + true + } + pub fn terminals(&self, cx: &App) -> Vec { self.terminals .iter() diff --git a/crates/agent_ui/src/terminal_thread_metadata_store.rs b/crates/agent_ui/src/terminal_thread_metadata_store.rs index 70bfb8b26e..528f2e23e8 100644 --- a/crates/agent_ui/src/terminal_thread_metadata_store.rs +++ b/crates/agent_ui/src/terminal_thread_metadata_store.rs @@ -70,6 +70,12 @@ impl TerminalThreadMetadata { self.custom_title.as_ref().map(|title| title.as_ref()), ) } + + pub fn editable_title(&self) -> SharedString { + self.custom_title.clone().unwrap_or_else(|| { + SharedString::from(terminal_title_without_prefix(self.title.as_ref()).to_string()) + }) + } } pub(crate) fn compose_terminal_thread_title( @@ -93,6 +99,19 @@ pub(crate) fn terminal_title_without_prefix(title: &str) -> &str { .unwrap_or(title) } +pub(crate) fn normalize_terminal_custom_title( + terminal_title: &str, + edited_title: SharedString, +) -> Option { + if edited_title.trim().is_empty() + || edited_title == terminal_title_without_prefix(terminal_title) + { + None + } else { + Some(edited_title) + } +} + pub fn terminal_title_prefix(title: &str) -> Option<&str> { let mut prefix_byte_len = 0; let mut saw_prefix_character = false; @@ -264,6 +283,25 @@ impl TerminalThreadMetadataStore { cx.notify(); } + pub fn rename_terminal( + &mut self, + terminal_id: TerminalId, + title: SharedString, + cx: &mut Context, + ) { + let Some(mut metadata) = self.entry(terminal_id).cloned() else { + return; + }; + let custom_title = normalize_terminal_custom_title(metadata.title.as_ref(), title); + if metadata.custom_title == custom_title { + return; + } + + metadata.custom_title = custom_title; + self.save_internal(metadata); + cx.notify(); + } + pub fn change_worktree_paths( &mut self, current_folder_paths: &PathList, @@ -659,6 +697,47 @@ mod tests { assert_eq!(metadata.display_title().as_ref(), "Fix bug"); } + #[gpui::test] + async fn test_rename_terminal_updates_stored_custom_title(cx: &mut TestAppContext) { + init_test(cx); + + let metadata = metadata( + "⠋ Dev Server", + WorktreePaths::from_folder_paths(&PathList::default()), + ); + let terminal_id = metadata.terminal_id; + + cx.update(|cx| { + TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.save(metadata, cx); + store.rename_terminal(terminal_id, "Renamed Terminal".into(), cx); + }); + }); + + cx.update(|cx| { + let store = TerminalThreadMetadataStore::global(cx); + let metadata = store + .read(cx) + .entry(terminal_id) + .expect("renamed terminal metadata should exist"); + assert_eq!(metadata.custom_title.as_deref(), Some("Renamed Terminal")); + assert_eq!(metadata.display_title().as_ref(), "⠋ Renamed Terminal"); + }); + + cx.update(|cx| { + TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.rename_terminal(terminal_id, "Dev Server".into(), cx); + }); + let store = TerminalThreadMetadataStore::global(cx); + let metadata = store + .read(cx) + .entry(terminal_id) + .expect("renamed terminal metadata should exist"); + assert_eq!(metadata.custom_title, None); + assert_eq!(metadata.display_title().as_ref(), "⠋ Dev Server"); + }); + } + #[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 2181abc659..5046781f55 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -406,6 +406,28 @@ enum ListEntry { Terminal(TerminalEntry), } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RenameTarget { + Thread(ThreadId), + Terminal(TerminalId), +} + +impl RenameTarget { + fn from_entry(entry: &ListEntry) -> Option<(Self, SharedString)> { + match entry { + ListEntry::Thread(thread) => Some(( + Self::Thread(thread.metadata.thread_id), + thread.metadata.display_title(), + )), + ListEntry::Terminal(terminal) => Some(( + Self::Terminal(terminal.metadata.terminal_id), + terminal.metadata.editable_title(), + )), + ListEntry::ProjectHeader { .. } => None, + } + } +} + #[derive(Clone)] enum ActivatableEntry { Thread { @@ -743,7 +765,7 @@ pub struct Sidebar { width: Pixels, focus_handle: FocusHandle, filter_editor: Entity, - thread_rename_editor: Entity, + rename_editor: Entity, list_state: ListState, contents: SidebarContents, /// The index of the list item that currently has the keyboard focus @@ -753,11 +775,11 @@ pub struct Sidebar { /// Tracks which sidebar entry is currently active (highlighted). active_entry: Option, hovered_thread_index: Option, - renaming_thread_id: Option, + rename_target: Option, /// Threads in the database-backed regeneration path need their own loading /// state because they do not have a live `agent::Thread` to report it. regenerating_titles: HashSet, - /// start_renaming_thread must seed current title into the title editor + /// Starting a rename must seed the current title into the title editor, /// so this prevents that BufferEdited event from being interpreted as user input. suppress_next_rename_edit: bool, @@ -815,7 +837,7 @@ impl Sidebar { editor.set_placeholder_text("Search threads…", window, cx); editor }); - let thread_rename_editor = cx.new(|cx| Editor::single_line(window, cx)); + let rename_editor = cx.new(|cx| Editor::single_line(window, cx)); cx.subscribe_in( &multi_workspace, @@ -850,10 +872,10 @@ impl Sidebar { .detach(); cx.subscribe_in( - &thread_rename_editor, + &rename_editor, window, |this, title_editor, event, window, cx| { - this.handle_thread_rename_editor_event(title_editor, event, window, cx); + this.handle_rename_editor_event(title_editor, event, window, cx); }, ) .detach(); @@ -898,13 +920,13 @@ impl Sidebar { width: DEFAULT_WIDTH, focus_handle, filter_editor, - thread_rename_editor, + rename_editor, list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)), contents: SidebarContents::default(), selection: None, active_entry: None, hovered_thread_index: None, - renaming_thread_id: None, + rename_target: None, regenerating_titles: HashSet::new(), suppress_next_rename_edit: false, @@ -3251,16 +3273,13 @@ impl Sidebar { let is_archived_search_focused = matches!(&self.view, SidebarView::Archive(archive) if archive.read(cx).is_filter_editor_focused(window, cx)); - let is_renaming_thread = self - .thread_rename_editor - .focus_handle(cx) - .is_focused(window); + let is_renaming = self.rename_editor.focus_handle(cx).is_focused(window); let identifier = if self.filter_editor.focus_handle(cx).is_focused(window) || is_archived_search_focused { "searching" - } else if is_renaming_thread { + } else if is_renaming { "editing" } else { "not_searching" @@ -3286,8 +3305,8 @@ impl Sidebar { } fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { - if self.renaming_thread_id.is_some() { - self.finish_thread_rename(window, cx); + if self.rename_target.is_some() { + self.finish_entry_rename(window, cx); return; } @@ -3351,23 +3370,30 @@ impl Sidebar { !self.filter_editor.read(cx).text(cx).is_empty() } - fn start_renaming_thread( + fn start_renaming_entry( &mut self, ix: usize, - thread_id: ThreadId, + target: RenameTarget, title: SharedString, window: &mut Window, cx: &mut Context, ) { - if self.renaming_thread_id.is_some() && self.renaming_thread_id != Some(thread_id) { - self.finish_thread_rename(window, cx); + if self.rename_target == Some(target) { + self.rename_editor.update(cx, |editor, cx| { + editor.select_all(&editor::actions::SelectAll, window, cx); + editor.focus_handle(cx).focus(window, cx); + }); + return; + } + if self.rename_target.is_some() { + self.finish_entry_rename(window, cx); } self.selection = Some(ix); - self.renaming_thread_id = Some(thread_id); + self.rename_target = Some(target); self.suppress_next_rename_edit = true; self.list_state.scroll_to_reveal_item(ix); - self.thread_rename_editor.update(cx, |editor, cx| { + self.rename_editor.update(cx, |editor, cx| { editor.set_text(title, window, cx); editor.select_all(&editor::actions::SelectAll, window, cx); editor.focus_handle(cx).focus(window, cx); @@ -3375,7 +3401,7 @@ impl Sidebar { cx.notify(); } - fn handle_thread_rename_editor_event( + fn handle_rename_editor_event( &mut self, title_editor: &Entity, event: &editor::EditorEvent, @@ -3392,16 +3418,24 @@ impl Sidebar { return; } let new_title = title_editor.read(cx).text(cx); - if new_title.is_empty() { - return; - } - let Some(thread_id) = self.renaming_thread_id else { + let Some(target) = self.rename_target else { return; }; - self.apply_thread_rename(thread_id, SharedString::from(new_title), window, cx); + if matches!(target, RenameTarget::Thread(_)) && new_title.is_empty() { + return; + } + let new_title = SharedString::from(new_title); + match target { + RenameTarget::Thread(thread_id) => { + self.apply_thread_rename(thread_id, new_title, window, cx); + } + RenameTarget::Terminal(terminal_id) => { + self.apply_terminal_rename(terminal_id, new_title, cx); + } + } } editor::EditorEvent::Blurred => { - self.finish_thread_rename(window, cx); + self.finish_entry_rename(window, cx); } _ => {} } @@ -3440,8 +3474,35 @@ impl Sidebar { } } - fn finish_thread_rename(&mut self, window: &mut Window, cx: &mut Context) -> bool { - if self.renaming_thread_id.take().is_none() { + fn apply_terminal_rename( + &mut self, + terminal_id: TerminalId, + title: SharedString, + cx: &mut Context, + ) { + let mut found = false; + if let Some(multi_workspace) = self.multi_workspace.upgrade() { + let workspaces: Vec<_> = multi_workspace.read(cx).workspaces().cloned().collect(); + for workspace in workspaces { + if let Some(agent_panel) = workspace.read(cx).panel::(cx) + && agent_panel.update(cx, |agent_panel, cx| { + agent_panel.rename_terminal(terminal_id, title.clone(), cx) + }) + { + found = true; + } + } + } + + if !found { + TerminalThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.rename_terminal(terminal_id, title, cx); + }); + } + } + + fn finish_entry_rename(&mut self, window: &mut Window, cx: &mut Context) -> bool { + if self.rename_target.take().is_none() { return false; } self.focus_handle.focus(window, cx); @@ -3523,7 +3584,7 @@ impl Sidebar { } fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { - if self.finish_thread_rename(window, cx) { + if self.finish_entry_rename(window, cx) { return; } @@ -5684,12 +5745,15 @@ impl Sidebar { let Some(ix) = self.selection else { return; }; - let Some(ListEntry::Thread(thread)) = self.contents.entries.get(ix) else { + let Some((target, title)) = self + .contents + .entries + .get(ix) + .and_then(RenameTarget::from_entry) + else { return; }; - let thread_id = thread.metadata.thread_id; - let title = thread.metadata.display_title(); - self.start_renaming_thread(ix, thread_id, title, window, cx); + self.start_renaming_entry(ix, target, title, window, cx); } fn record_thread_access(&mut self, id: &ThreadId) { @@ -6107,6 +6171,28 @@ impl Sidebar { window.focus(&focus, cx); } + fn render_rename_title_editor(&self, cx: &mut Context) -> AnyElement { + div() + .h_full() + .min_w_0() + .flex_1() + .capture_action( + cx.listener(|this, _: &editor::actions::Newline, window, cx| { + this.finish_entry_rename(window, cx); + }), + ) + .on_action(cx.listener(|this, _: &Confirm, window, cx| { + this.finish_entry_rename(window, cx); + })) + .on_action( + cx.listener(|this, _: &editor::actions::Cancel, window, cx| { + this.finish_entry_rename(window, cx); + }), + ) + .child(self.rename_editor.clone()) + .into_any_element() + } + fn render_thread( &self, ix: usize, @@ -6129,12 +6215,13 @@ impl Sidebar { thread.status, AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation ); - let is_renaming = self.renaming_thread_id == Some(thread.metadata.thread_id); + let is_renaming = + self.rename_target == Some(RenameTarget::Thread(thread.metadata.thread_id)); let thread_id_for_actions = thread.metadata.thread_id; let session_id_for_delete = thread.metadata.session_id.clone(); let focus_handle = self.focus_handle.clone(); - let title_editor = self.thread_rename_editor.clone(); + let rename_title_editor = is_renaming.then(|| self.render_rename_title_editor(cx)); let id = SharedString::from(format!("thread-entry-{}", ix)); @@ -6200,27 +6287,8 @@ impl Sidebar { } cx.notify(); })) - .when(is_renaming, |this| { - this.is_truncated(false).title_slot( - div() - .h_full() - .min_w_0() - .flex_1() - .capture_action(cx.listener( - |this, _: &editor::actions::Newline, window, cx| { - this.finish_thread_rename(window, cx); - }, - )) - .on_action(cx.listener(|this, _: &Confirm, window, cx| { - this.finish_thread_rename(window, cx); - })) - .on_action( - cx.listener(|this, _: &editor::actions::Cancel, window, cx| { - this.finish_thread_rename(window, cx); - }), - ) - .child(title_editor), - ) + .when_some(rename_title_editor, |this, title_editor| { + this.is_truncated(false).title_slot(title_editor) }) .when(is_hovered && !is_renaming, |this| { let rename_button = IconButton::new(("rename-thread", ix), IconName::Pencil) @@ -6239,9 +6307,9 @@ impl Sidebar { .on_click({ let title = title.clone(); cx.listener(move |this, _, window, cx| { - this.start_renaming_thread( + this.start_renaming_entry( ix, - thread_id_for_actions, + RenameTarget::Thread(thread_id_for_actions), title.clone(), window, cx, @@ -6381,9 +6449,9 @@ impl Sidebar { move |window, cx| { sidebar .update(cx, |sidebar, cx| { - sidebar.start_renaming_thread( + sidebar.start_renaming_entry( ix, - thread_id, + RenameTarget::Thread(thread_id), rename_title.clone(), window, cx, @@ -6492,6 +6560,9 @@ impl Sidebar { cx.flag_value::(), ); let is_remote = terminal.workspace.is_remote(cx); + let is_renaming = + self.rename_target == Some(RenameTarget::Terminal(terminal.metadata.terminal_id)); + let rename_title_editor = is_renaming.then(|| self.render_rename_title_editor(cx)); let display_title = terminal.metadata.display_title(); let (icon_char, title, highlight_positions) = @@ -6500,7 +6571,7 @@ impl Sidebar { None => (None, display_title, terminal.highlight_positions.clone()), }; - ThreadItem::new(id, title) + let terminal_item = ThreadItem::new(id, title) .base_bg(sidebar_bg) .icon(IconName::Terminal) .when_some(icon_char, |this, icon_char| this.icon_char(icon_char)) @@ -6520,7 +6591,10 @@ impl Sidebar { } cx.notify(); })) - .when(is_hovered, |this| { + .when_some(rename_title_editor, |this, title_editor| { + this.is_truncated(false).title_slot(title_editor) + }) + .when(is_hovered && !is_renaming, |this| { this.action_slot( IconButton::new("close-terminal", IconName::Close) .icon_size(IconSize::Small) @@ -6553,7 +6627,34 @@ impl Sidebar { cx, ); } - })) + })); + + let context_menu_id = SharedString::from(format!("terminal-context-menu-{ix}")); + let terminal_id = terminal.metadata.terminal_id; + let rename_title = terminal.metadata.editable_title(); + let sidebar = cx.weak_entity(); + + right_click_menu(context_menu_id) + .trigger(move |_, _, _| terminal_item) + .menu(move |window, cx| { + let sidebar = sidebar.clone(); + let rename_title = rename_title.clone(); + ContextMenu::build(window, cx, move |menu, _window, _cx| { + menu.entry("Rename Title", None, move |window, cx| { + sidebar + .update(cx, |sidebar, cx| { + sidebar.start_renaming_entry( + ix, + RenameTarget::Terminal(terminal_id), + rename_title.clone(), + window, + cx, + ); + }) + .ok(); + }) + }) + }) .into_any_element() } diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 54a42ec134..9d1a0abc37 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -24,7 +24,23 @@ use std::{ }; use util::{path_list::PathList, rel_path::rel_path}; +fn use_unique_metadata_databases(cx: &mut TestAppContext) { + use std::sync::atomic::{AtomicUsize, Ordering}; + + static NEXT_TEST_DATABASE: AtomicUsize = AtomicUsize::new(0); + let test_database_id = NEXT_TEST_DATABASE.fetch_add(1, Ordering::SeqCst); + cx.update(|cx| { + cx.set_global(agent_ui::thread_metadata_store::TestMetadataDbName( + format!("SIDEBAR_THREAD_METADATA_{test_database_id}"), + )); + cx.set_global(TestTerminalMetadataDbName(format!( + "SIDEBAR_TERMINAL_THREAD_METADATA_{test_database_id}" + ))); + }); +} + fn init_test(cx: &mut TestAppContext) { + use_unique_metadata_databases(cx); cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); @@ -536,6 +552,27 @@ fn focus_sidebar(sidebar: &Entity, cx: &mut gpui::VisualTestContext) { cx.run_until_parked(); } +fn enter_renamed_title( + sidebar: &Entity, + target: RenameTarget, + renamed_title: &str, + cx: &mut gpui::VisualTestContext, +) { + sidebar.read_with(cx, |sidebar, _cx| { + assert_eq!(sidebar.rename_target, Some(target)); + }); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.rename_editor.update(cx, |editor, cx| { + editor.set_text(renamed_title, window, cx); + }); + }); + cx.run_until_parked(); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.finish_entry_rename(window, cx); + }); + cx.run_until_parked(); +} + fn request_test_tool_authorization( thread: &Entity, tool_call_id: &str, @@ -1765,6 +1802,7 @@ async fn init_test_project_with_agent_panel( worktree_path: &str, cx: &mut TestAppContext, ) -> Entity { + use_unique_metadata_databases(cx); agent_ui::test_support::init_test(cx); cx.update(|cx| { cx.set_global(agent_ui::MaxIdleRetainedThreads(1)); @@ -5124,17 +5162,17 @@ async fn test_rename_thread_from_sidebar_updates_title_override(cx: &mut TestApp let renamed_title = "abcdefghijklmnopqrstuvwxyé renamed"; sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.start_renaming_thread(entry_ix, thread_id, title, window, cx); + sidebar.start_renaming_entry(entry_ix, RenameTarget::Thread(thread_id), title, window, cx); }); cx.run_until_parked(); sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.thread_rename_editor.update(cx, |editor, cx| { + sidebar.rename_editor.update(cx, |editor, cx| { editor.set_text(renamed_title, window, cx); }); }); cx.run_until_parked(); sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.finish_thread_rename(window, cx); + sidebar.finish_entry_rename(window, cx); }); cx.run_until_parked(); @@ -5254,25 +5292,8 @@ async fn test_rename_selected_thread_action_renames_selected_thread(cx: &mut Tes cx.dispatch_action(RenameSelectedThread); cx.run_until_parked(); - sidebar.read_with(cx, |sidebar, _cx| { - assert_eq!( - sidebar.renaming_thread_id, - Some(thread_id), - "dispatching RenameSelectedThread should start renaming the selected thread" - ); - }); - let renamed_title = "Renamed via action"; - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.thread_rename_editor.update(cx, |editor, cx| { - editor.set_text(renamed_title, window, cx); - }); - }); - cx.run_until_parked(); - sidebar.update_in(cx, |sidebar, window, cx| { - sidebar.finish_thread_rename(window, cx); - }); - cx.run_until_parked(); + enter_renamed_title(&sidebar, RenameTarget::Thread(thread_id), renamed_title, cx); let metadata = cx.update(|_, cx| { ThreadMetadataStore::global(cx) @@ -5284,6 +5305,72 @@ async fn test_rename_selected_thread_action_renames_selected_thread(cx: &mut Tes assert_eq!(metadata.title_override.as_deref(), Some(renamed_title)); } +#[gpui::test] +async fn test_rename_selected_thread_action_renames_terminal(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx); + + let terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + let entry_ix = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| { + matches!( + entry, + ListEntry::Terminal(terminal) + if terminal.metadata.terminal_id == terminal_id + ) + }) + .expect("sidebar should have a terminal entry") + }); + + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(entry_ix); + }); + cx.dispatch_action(RenameSelectedThread); + cx.run_until_parked(); + + let renamed_title = "Renamed Terminal"; + enter_renamed_title( + &sidebar, + RenameTarget::Terminal(terminal_id), + renamed_title, + cx, + ); + + panel.read_with(cx, |panel, cx| { + let terminal = panel + .terminals(cx) + .into_iter() + .find(|terminal| terminal.id == terminal_id) + .expect("terminal should remain open after renaming"); + assert_eq!(terminal.custom_title.as_deref(), Some(renamed_title)); + }); + sidebar.read_with(cx, |_sidebar, cx| { + let metadata = TerminalThreadMetadataStore::global(cx) + .read(cx) + .entry(terminal_id) + .cloned() + .expect("renamed terminal metadata should exist"); + assert_eq!(metadata.custom_title.as_deref(), Some(renamed_title)); + }); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Renamed Terminal <== selected"] + ); +} + #[gpui::test] async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { let project_a = init_test_project_with_agent_panel("/project-a", cx).await; diff --git a/docs/src/ai/terminal-threads.md b/docs/src/ai/terminal-threads.md index a44759a238..b470b48ff2 100644 --- a/docs/src/ai/terminal-threads.md +++ b/docs/src/ai/terminal-threads.md @@ -54,7 +54,7 @@ You can also configure this from the Settings UI under **AI**, via the "Terminal ## Terminal Thread Titles {#terminal-thread-titles} -The terminal title in the toolbar updates automatically to reflect the running shell or process. You can also set a custom name by clicking the title or the pencil icon that appears on hover. +The terminal title in the toolbar updates automatically to reflect the running shell or process. You can set a custom name by clicking the title or the pencil icon that appears on hover. In the Threads Sidebar, right-click a Terminal Thread and select **Rename Title**, or select it and press {#kb agent::RenameSelectedThread}. ## Notifications {#terminal-thread-notifications}