diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index cd1aee29c7c..63d3538030c 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -225,24 +225,9 @@ "bindings": { "ctrl-n": "agent::NewThread", "ctrl-alt-c": "agent::OpenSettings", - "ctrl-alt-p": "agent::ManageProfiles", - "ctrl-alt-l": "agent::OpenRulesLibrary", - "ctrl-i": "agent::ToggleProfileSelector", - "shift-tab": "agent::CycleModeSelector", - "ctrl-alt-/": "agent::ToggleModelSelector", - "alt-tab": "agent::CycleFavoriteModels", - // `alt-l` is provided as an alternative to `alt-tab` as the latter breaks on Linux under the `AgentPanel` context - "alt-l": "agent::CycleFavoriteModels", "shift-alt-i": "agent::ToggleOptionsMenu", "ctrl-alt-shift-n": "agent::ToggleNewThreadMenu", - "shift-alt-escape": "agent::ExpandMessageEditor", - "ctrl->": "agent::AddSelectionToThread", "ctrl-shift-e": "project_panel::ToggleFocus", - "ctrl-shift-enter": "agent::ContinueThread", - "shift-alt-q": "agent::AllowAlways", - "shift-alt-a": "agent::AllowOnce", - "ctrl-alt-a": "agent::OpenPermissionDropdown", - "shift-alt-x": "agent::RejectOnce", "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, @@ -255,14 +240,6 @@ "ctrl-c": "markdown::CopyAsMarkdown", }, }, - { - "context": "AgentPanel && acp_thread", - "use_key_equivalents": true, - "bindings": { - "ctrl-n": "agent::NewExternalAgentThread", - "ctrl-alt-t": "agent::NewThread", - }, - }, { "context": "AgentFeedbackMessageEditor > Editor", "bindings": { @@ -279,8 +256,24 @@ }, { "context": "AcpThread", + "use_key_equivalents": true, "bindings": { + "ctrl-n": "agent::NewThread", "ctrl--": "pane::GoBack", + "ctrl-alt-p": "agent::ManageProfiles", + "ctrl-alt-l": "agent::OpenRulesLibrary", + "ctrl-i": "agent::ToggleProfileSelector", + "shift-tab": "agent::CycleModeSelector", + "ctrl-alt-/": "agent::ToggleModelSelector", + "alt-tab": "agent::CycleFavoriteModels", + // `alt-l` is provided as an alternative to `alt-tab` as the latter breaks on Linux under the `AcpThread` context + "alt-l": "agent::CycleFavoriteModels", + "shift-alt-escape": "agent::ExpandMessageEditor", + "ctrl->": "agent::AddSelectionToThread", + "shift-alt-q": "agent::AllowAlways", + "shift-alt-a": "agent::AllowOnce", + "ctrl-alt-a": "agent::OpenPermissionDropdown", + "shift-alt-x": "agent::RejectOnce", "pageup": "agent::ScrollOutputPageUp", "pagedown": "agent::ScrollOutputPageDown", "home": "agent::ScrollOutputToTop", @@ -1248,7 +1241,7 @@ }, }, { - "context": "AgentPanel && Terminal", + "context": "AgentPanel > Terminal", "bindings": { "ctrl-n": "agent::NewThread", }, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index bf96104f65e..ba31a6ef5ff 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -265,21 +265,9 @@ "bindings": { "cmd-n": "agent::NewThread", "cmd-alt-c": "agent::OpenSettings", - "cmd-alt-l": "agent::OpenRulesLibrary", - "cmd-alt-p": "agent::ManageProfiles", - "cmd-i": "agent::ToggleProfileSelector", - "shift-tab": "agent::CycleModeSelector", - "cmd-alt-/": "agent::ToggleModelSelector", - "alt-tab": "agent::CycleFavoriteModels", "cmd-alt-m": "agent::ToggleOptionsMenu", "cmd-alt-shift-n": "agent::ToggleNewThreadMenu", - "shift-alt-escape": "agent::ExpandMessageEditor", - "cmd->": "agent::AddSelectionToThread", "cmd-shift-e": "project_panel::ToggleFocus", - "cmd-shift-enter": "agent::ContinueThread", - "cmd-y": "agent::AllowOnce", - "cmd-alt-a": "agent::OpenPermissionDropdown", - "cmd-alt-z": "agent::RejectOnce", "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, @@ -291,14 +279,6 @@ "cmd-c": "markdown::CopyAsMarkdown", }, }, - { - "context": "AgentPanel && acp_thread", - "use_key_equivalents": true, - "bindings": { - "cmd-n": "agent::NewExternalAgentThread", - "cmd-alt-t": "agent::NewThread", - }, - }, { "context": "AgentFeedbackMessageEditor > Editor", "use_key_equivalents": true, @@ -322,8 +302,21 @@ }, { "context": "AcpThread", + "use_key_equivalents": true, "bindings": { + "cmd-n": "agent::NewThread", "ctrl--": "pane::GoBack", + "cmd-alt-l": "agent::OpenRulesLibrary", + "cmd-alt-p": "agent::ManageProfiles", + "cmd-i": "agent::ToggleProfileSelector", + "shift-tab": "agent::CycleModeSelector", + "cmd-alt-/": "agent::ToggleModelSelector", + "alt-tab": "agent::CycleFavoriteModels", + "shift-alt-escape": "agent::ExpandMessageEditor", + "cmd->": "agent::AddSelectionToThread", + "cmd-y": "agent::AllowOnce", + "cmd-alt-a": "agent::OpenPermissionDropdown", + "cmd-alt-z": "agent::RejectOnce", "pageup": "agent::ScrollOutputPageUp", "pagedown": "agent::ScrollOutputPageDown", "home": "agent::ScrollOutputToTop", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index ce293452d2d..a84b269e96e 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -226,24 +226,9 @@ "bindings": { "ctrl-n": "agent::NewThread", "shift-alt-c": "agent::OpenSettings", - "shift-alt-l": "agent::OpenRulesLibrary", - "shift-alt-p": "agent::ManageProfiles", - "ctrl-i": "agent::ToggleProfileSelector", - "shift-tab": "agent::CycleModeSelector", - "alt-tab": "agent::CycleFavoriteModels", - // `alt-l` is provided as an alternative to `alt-tab` as the latter breaks on Windows under the `AgentPanel` context - "alt-l": "agent::CycleFavoriteModels", - "shift-alt-/": "agent::ToggleModelSelector", "shift-alt-i": "agent::ToggleOptionsMenu", "ctrl-shift-alt-n": "agent::ToggleNewThreadMenu", - "shift-alt-escape": "agent::ExpandMessageEditor", - "ctrl-shift-.": "agent::AddSelectionToThread", "ctrl-shift-e": "project_panel::ToggleFocus", - "ctrl-shift-enter": "agent::ContinueThread", - "shift-alt-q": "agent::AllowAlways", - "shift-alt-a": "agent::AllowOnce", - "ctrl-alt-a": "agent::OpenPermissionDropdown", - "shift-alt-x": "agent::RejectOnce", "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher", "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }], }, @@ -255,14 +240,6 @@ "ctrl-c": "markdown::CopyAsMarkdown", }, }, - { - "context": "AgentPanel && acp_thread", - "use_key_equivalents": true, - "bindings": { - "ctrl-n": "agent::NewExternalAgentThread", - "ctrl-alt-t": "agent::NewThread", - }, - }, { "context": "AgentFeedbackMessageEditor > Editor", "use_key_equivalents": true, @@ -280,8 +257,24 @@ }, { "context": "AcpThread", + "use_key_equivalents": true, "bindings": { + "ctrl-n": "agent::NewThread", "ctrl--": "pane::GoBack", + "shift-alt-l": "agent::OpenRulesLibrary", + "shift-alt-p": "agent::ManageProfiles", + "ctrl-i": "agent::ToggleProfileSelector", + "shift-tab": "agent::CycleModeSelector", + "shift-alt-/": "agent::ToggleModelSelector", + "alt-tab": "agent::CycleFavoriteModels", + // `alt-l` is provided as an alternative to `alt-tab` as the latter breaks on Windows under the `AcpThread` context + "alt-l": "agent::CycleFavoriteModels", + "shift-alt-escape": "agent::ExpandMessageEditor", + "ctrl-shift-.": "agent::AddSelectionToThread", + "shift-alt-q": "agent::AllowAlways", + "shift-alt-a": "agent::AllowOnce", + "ctrl-alt-a": "agent::OpenPermissionDropdown", + "shift-alt-x": "agent::RejectOnce", "pageup": "agent::ScrollOutputPageUp", "pagedown": "agent::ScrollOutputPageDown", "home": "agent::ScrollOutputToTop", diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index a13cd4a762a..2b35c489dd2 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1310,6 +1310,11 @@ impl AgentPanel { self.new_entry(None, window, cx); } + fn new_agent_thread(&mut self, agent: Agent, window: &mut Window, cx: &mut Context) { + self.selected_agent = agent; + self.activate_new_thread(true, "agent_panel", window, cx); + } + pub fn activate_new_thread( &mut self, focus: bool, @@ -1327,10 +1332,7 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { - if let Some(agent) = action.agent.clone() { - self.selected_agent = agent; - } - self.activate_new_thread(true, "agent_panel", window, cx); + self.new_agent_thread(action.agent.clone().into(), window, cx); } pub fn new_terminal( @@ -3366,6 +3368,7 @@ impl AgentPanel { cx: &mut Context, ) -> impl IntoElement { let focus_handle = self.focus_handle(cx); + let showing_terminal = matches!(self.visible_surface(), VisibleSurface::Terminal(_)); let conversation_view = match &self.base_view { BaseView::AgentThread { conversation_view } => Some(conversation_view.clone()), @@ -3427,21 +3430,26 @@ impl AgentPanel { } } + if !showing_terminal { + menu = menu + .header("MCP Servers") + .action( + "View Server Extensions", + Box::new(zed_actions::Extensions { + category_filter: Some( + zed_actions::ExtensionCategoryFilter::ContextServers, + ), + id: None, + }), + ) + .action("Add Custom Server…", Box::new(AddContextServer)) + .separator() + .action("Rules", Box::new(OpenRulesLibrary::default())) + .action("Profiles", Box::new(ManageProfiles::default())) + .separator(); + } + menu = menu - .header("MCP Servers") - .action( - "View Server Extensions", - Box::new(zed_actions::Extensions { - category_filter: Some( - zed_actions::ExtensionCategoryFilter::ContextServers, - ), - id: None, - }), - ) - .action("Add Custom Server…", Box::new(AddContextServer)) - .separator() - .action("Rules", Box::new(OpenRulesLibrary::default())) - .action("Profiles", Box::new(ManageProfiles::default())) .action("Settings", Box::new(OpenSettings)) .separator() .action("Toggle Threads Sidebar", Box::new(ToggleWorkspaceSidebar)); @@ -3544,7 +3552,7 @@ impl AgentPanel { .item( ContextMenuEntry::new("Zed Agent") .when(is_agent_selected(Agent::NativeAgent), |this| { - this.action(Box::new(NewExternalAgentThread { agent: None })) + this.action(Box::new(NewThread)) }) .icon(IconName::ZedAgent) .icon_color(Color::Muted) @@ -3557,10 +3565,8 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.new_external_agent_thread( - &NewExternalAgentThread { - agent: Some(Agent::NativeAgent), - }, + panel.new_agent_thread( + Agent::NativeAgent, window, cx, ); @@ -3574,6 +3580,7 @@ impl AgentPanel { .when(supports_terminal, |menu| { menu.item( ContextMenuEntry::new("Terminal") + .when(showing_terminal, |this| this.action(Box::new(NewThread))) .icon(IconName::Terminal) .icon_color(Color::Muted) .handler({ @@ -3653,11 +3660,7 @@ impl AgentPanel { is_agent_selected(Agent::Custom { id: item.id.clone(), }), - |this| { - this.action(Box::new(NewExternalAgentThread { - agent: None, - })) - }, + |this| this.action(Box::new(NewThread)), ) .icon_color(Color::Muted) .disabled(is_via_collab) @@ -3673,9 +3676,7 @@ impl AgentPanel { panel.update(cx, |panel, cx| { panel.new_external_agent_thread( &NewExternalAgentThread { - agent: Some(Agent::Custom { - id: agent_id.clone(), - }), + agent: agent_id.clone(), }, window, cx, @@ -4124,12 +4125,6 @@ impl AgentPanel { fn key_context(&self) -> KeyContext { let mut key_context = KeyContext::new_with_defaults(); key_context.add("AgentPanel"); - match self.visible_surface() { - VisibleSurface::AgentThread(_) => key_context.add("acp_thread"), - VisibleSurface::Terminal(_) - | VisibleSurface::Configuration(_) - | VisibleSurface::Uninitialized => {} - } key_context } } @@ -6601,7 +6596,7 @@ mod tests { }); // Press cmd-n (activate_draft again with the same agent). - cx.dispatch_action(NewExternalAgentThread { agent: None }); + cx.dispatch_action(NewThread); cx.run_until_parked(); // The draft entity should not have changed. @@ -6680,7 +6675,7 @@ mod tests { // Switch to a different agent. ensure_draft should extract the typed // content from the old draft and pre-fill the new one. cx.dispatch_action(NewExternalAgentThread { - agent: Some(Agent::Stub), + agent: Agent::Stub.id(), }); cx.run_until_parked(); diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index bcb991d4e8c..e81ce8b8d76 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -149,8 +149,6 @@ actions!( ResetTrialEndUpsell, /// Opens the "Add Context" menu in the message editor. OpenAddContextMenu, - /// Continues the current thread. - ContinueThread, /// Interrupts the current generation and sends the message immediately. SendImmediately, /// Sends the next queued message immediately. @@ -246,12 +244,33 @@ pub struct ToggleCommandPattern { pub struct NewThread; /// Creates a new external agent conversation thread. -#[derive(Default, Clone, PartialEq, Deserialize, JsonSchema, Action)] +#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] #[action(namespace = agent)] #[serde(deny_unknown_fields)] pub struct NewExternalAgentThread { - /// Which agent to use for the conversation. - agent: Option, + /// The agent id to use for the conversation. + #[serde(deserialize_with = "deserialize_external_agent_id")] + agent: AgentId, +} + +fn deserialize_external_agent_id<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum AgentIdOrLegacyAgent { + LegacyAgent(Agent), + AgentId(AgentId), + } + + match AgentIdOrLegacyAgent::deserialize(deserializer)? { + AgentIdOrLegacyAgent::AgentId(agent_id) => Ok(agent_id), + AgentIdOrLegacyAgent::LegacyAgent(Agent::Custom { id }) => Ok(id), + AgentIdOrLegacyAgent::LegacyAgent(Agent::NativeAgent) => Ok(Agent::NativeAgent.id()), + #[cfg(any(test, feature = "test-support"))] + AgentIdOrLegacyAgent::LegacyAgent(Agent::Stub) => Ok(Agent::Stub.id()), + } } #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] @@ -280,10 +299,13 @@ pub enum Agent { impl From for Agent { fn from(id: AgentId) -> Self { if id.as_ref() == agent::ZED_AGENT_ID.as_ref() { - Self::NativeAgent - } else { - Self::Custom { id } + return Self::NativeAgent; } + #[cfg(any(test, feature = "test-support"))] + if id.as_ref() == "stub" { + return Self::Stub; + } + Self::Custom { id } } } @@ -910,4 +932,23 @@ mod tests { }, ); } + + #[test] + fn test_deserialize_new_external_agent_thread() { + let action = serde_json::from_str::(r#"{"agent":"gemini"}"#) + .expect("should deserialize agent id"); + assert_eq!(action.agent, AgentId::from("gemini")); + + let action = serde_json::from_str::( + r#"{"agent":{"custom":{"name":"gemini"}}}"#, + ) + .expect("should deserialize legacy custom agent payload"); + assert_eq!(action.agent, AgentId::from("gemini")); + + let action = serde_json::from_str::(r#"{"agent":"NativeAgent"}"#) + .expect("should deserialize legacy native agent payload"); + assert_eq!(action.agent, Agent::NativeAgent.id()); + + assert!(serde_json::from_str::(r#"{}"#).is_err()); + } } diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 59b712dff25..c5b326d5523 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -66,7 +66,10 @@ use zed_actions::editor::{MoveDown, MoveUp}; use zed_actions::agents_sidebar::{FocusSidebarFilter, ToggleThreadSwitcher}; -use crate::thread_switcher::{ThreadSwitcher, ThreadSwitcherEntry, ThreadSwitcherEvent}; +use crate::thread_switcher::{ + ThreadSwitcher, ThreadSwitcherEntry, ThreadSwitcherEvent, ThreadSwitcherSelection, + ThreadSwitcherTerminalEntry, ThreadSwitcherThreadEntry, +}; #[cfg(test)] mod sidebar_tests; @@ -376,6 +379,10 @@ impl SidebarContents { fn is_thread_notified(&self, thread_id: &agent_ui::ThreadId) -> bool { self.notified_threads.contains(thread_id) } + + fn is_terminal_notified(&self, terminal_id: TerminalId) -> bool { + self.notified_terminals.contains(&terminal_id) + } } // TODO: The mapping from workspace root paths to git repositories needs a @@ -572,6 +579,7 @@ pub struct Sidebar { /// thread, confirming in the thread switcher, etc.) — never from /// background data changes. Used to sort the thread switcher popup. thread_last_accessed: HashMap>, + terminal_last_accessed: HashMap>, thread_switcher: Option>, _thread_switcher_subscriptions: Vec, pending_thread_activation: Option, @@ -678,6 +686,7 @@ impl Sidebar { hovered_thread_index: None, thread_last_accessed: HashMap::new(), + terminal_last_accessed: HashMap::new(), thread_switcher: None, _thread_switcher_subscriptions: Vec::new(), pending_thread_activation: None, @@ -1123,6 +1132,7 @@ impl Sidebar { let mut notified_terminals: HashSet = HashSet::new(); let mut current_session_ids: HashSet = HashSet::new(); let mut current_thread_ids: HashSet = HashSet::new(); + let mut current_terminal_ids: HashSet = HashSet::new(); let mut project_header_indices: Vec = Vec::new(); let mut seen_thread_ids: HashSet = HashSet::new(); @@ -1188,6 +1198,7 @@ impl Sidebar { .iter() .flat_map(|workspace| terminal_entries_for_workspace(workspace, cx)) .collect(); + current_terminal_ids.extend(terminals.iter().map(|terminal| terminal.id)); notified_terminals.extend( terminals .iter() @@ -1530,6 +1541,8 @@ impl Sidebar { self.thread_last_accessed .retain(|id, _| current_thread_ids.contains(id)); + self.terminal_last_accessed + .retain(|id, _| current_terminal_ids.contains(id)); self.contents = SidebarContents { entries, @@ -3275,6 +3288,7 @@ impl Sidebar { return; }; + self.record_terminal_access(terminal_id); self.active_entry = Some(ActiveEntry::Terminal { terminal_id, workspace: workspace.clone(), @@ -3873,6 +3887,11 @@ impl Sidebar { self.archive_thread(&session_id, window, cx); } } + Some(ListEntry::Terminal(terminal)) => { + let workspace = terminal.workspace.clone(); + let terminal_id = terminal.id; + self.close_terminal(&workspace, terminal_id, window, cx); + } _ => {} } } @@ -3881,6 +3900,10 @@ impl Sidebar { self.thread_last_accessed.insert(*id, Utc::now()); } + fn record_terminal_access(&mut self, id: TerminalId) { + self.terminal_last_accessed.insert(id, Utc::now()); + } + fn record_thread_interacted(&mut self, thread_id: &agent_ui::ThreadId, cx: &mut App) { let store = ThreadMetadataStore::global(cx); store.update(cx, |store, cx| { @@ -3925,20 +3948,30 @@ impl Sidebar { } /// The sort order used by the ctrl-tab switcher - fn thread_cmp_for_switcher(&self, left: &ThreadMetadata, right: &ThreadMetadata) -> Ordering { - let sort_time = |x: &ThreadMetadata| { - self.thread_last_accessed - .get(&x.thread_id) + fn switcher_entry_cmp( + &self, + left: &ThreadSwitcherEntry, + right: &ThreadSwitcherEntry, + ) -> Ordering { + let sort_time = |entry: &ThreadSwitcherEntry| match entry { + ThreadSwitcherEntry::Thread(entry) => self + .thread_last_accessed + .get(&entry.metadata.thread_id) .copied() - .or(x.interacted_at) - .unwrap_or(x.updated_at) + .or(entry.metadata.interacted_at) + .unwrap_or(entry.metadata.updated_at), + ThreadSwitcherEntry::Terminal(entry) => self + .terminal_last_accessed + .get(&entry.terminal_id) + .copied() + .unwrap_or(entry.created_at), }; // .reverse() = most recent first sort_time(left).cmp(&sort_time(right)).reverse() } - fn mru_threads_for_switcher(&self, cx: &App) -> Vec { + fn mru_entries_for_switcher(&self, cx: &App) -> Vec { let mut current_header_label: Option = None; let mut current_header_key: Option = None; let mut entries: Vec = self @@ -3971,7 +4004,7 @@ impl Sidebar { let timestamp: SharedString = format_history_entry_timestamp(Self::thread_display_time(&thread.metadata)) .into(); - Some(ThreadSwitcherEntry { + Some(ThreadSwitcherEntry::Thread(ThreadSwitcherThreadEntry { session_id, title: thread.metadata.display_title(), icon: thread.icon, @@ -3993,13 +4026,25 @@ impl Sidebar { is_title_generating: thread.is_title_generating, notified, timestamp, - }) + })) + } + ListEntry::Terminal(terminal) => { + let timestamp: SharedString = + format_history_entry_timestamp(terminal.created_at).into(); + Some(ThreadSwitcherEntry::Terminal(ThreadSwitcherTerminalEntry { + terminal_id: terminal.id, + title: terminal.title.clone(), + workspace: terminal.workspace.clone(), + project_name: current_header_label.clone(), + created_at: terminal.created_at, + notified: self.contents.is_terminal_notified(terminal.id), + timestamp, + })) } - ListEntry::Terminal(_) => None, }) .collect(); - entries.sort_by(|a, b| self.thread_cmp_for_switcher(&a.metadata, &b.metadata)); + entries.sort_by(|a, b| self.switcher_entry_cmp(a, b)); entries } @@ -4023,6 +4068,96 @@ impl Sidebar { self.toggle_thread_switcher_impl(action.select_last, window, cx); } + fn preview_switcher_selection( + &mut self, + selection: &ThreadSwitcherSelection, + window: &mut Window, + cx: &mut Context, + ) { + match selection { + ThreadSwitcherSelection::Thread { + metadata, + workspace, + } => { + if let Some(multi_workspace) = self.multi_workspace.upgrade() { + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate(workspace.clone(), None, window, cx); + }); + } + self.active_entry = Some(ActiveEntry::Thread { + thread_id: metadata.thread_id, + session_id: metadata.session_id.clone(), + workspace: workspace.clone(), + }); + self.update_entries(cx); + Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx); + } + ThreadSwitcherSelection::Terminal { + terminal_id, + workspace, + } => { + if !cx.has_flag::() { + return; + } + if let Some(multi_workspace) = self.multi_workspace.upgrade() { + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate(workspace.clone(), None, window, cx); + }); + } + self.active_entry = Some(ActiveEntry::Terminal { + terminal_id: *terminal_id, + workspace: workspace.clone(), + }); + self.update_entries(cx); + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.activate_terminal(*terminal_id, false, window, cx); + }); + } + workspace.reveal_panel::(window, cx); + }); + } + } + } + + fn confirm_switcher_selection( + &mut self, + selection: &ThreadSwitcherSelection, + window: &mut Window, + cx: &mut Context, + ) { + match selection { + ThreadSwitcherSelection::Thread { + metadata, + workspace, + } => { + if let Some(multi_workspace) = self.multi_workspace.upgrade() { + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate(workspace.clone(), None, window, cx); + multi_workspace.retain_active_workspace(cx); + }); + } + self.record_thread_access(&metadata.thread_id); + self.active_entry = Some(ActiveEntry::Thread { + thread_id: metadata.thread_id, + session_id: metadata.session_id.clone(), + workspace: workspace.clone(), + }); + self.update_entries(cx); + self.dismiss_thread_switcher(cx); + Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx); + } + ThreadSwitcherSelection::Terminal { + terminal_id, + workspace, + } => { + self.dismiss_thread_switcher(cx); + self.activate_terminal(workspace, *terminal_id, true, window, cx); + } + } + } + fn toggle_thread_switcher_impl( &mut self, select_last: bool, @@ -4040,21 +4175,27 @@ impl Sidebar { return; } - let entries = self.mru_threads_for_switcher(cx); + let entries = self.mru_entries_for_switcher(cx); if entries.len() < 2 { return; } let weak_multi_workspace = self.multi_workspace.clone(); - // Capture the full active entry so dismissal can restore terminal - // entries too, not just threads. + // Snapshot the active entry (thread or terminal) so dismissal can + // restore it. let original_active_entry = self.active_entry.clone(); let original_metadata = match &original_active_entry { - Some(ActiveEntry::Thread { thread_id, .. }) => entries - .iter() - .find(|e| *thread_id == e.metadata.thread_id) - .map(|e| e.metadata.clone()), + Some(ActiveEntry::Thread { thread_id, .. }) => { + entries.iter().find_map(|entry| match entry { + ThreadSwitcherEntry::Thread(entry) + if *thread_id == entry.metadata.thread_id => + { + Some(entry.metadata.clone()) + } + _ => None, + }) + } _ => None, }; let original_workspace = self @@ -4069,44 +4210,13 @@ impl Sidebar { subscriptions.push(cx.subscribe_in(&thread_switcher, window, { let thread_switcher = thread_switcher.clone(); move |this, _emitter, event: &ThreadSwitcherEvent, window, cx| match event { - ThreadSwitcherEvent::Preview { - metadata, - workspace, - } => { - if let Some(mw) = weak_multi_workspace.upgrade() { - mw.update(cx, |mw, cx| { - mw.activate(workspace.clone(), None, window, cx); - }); - } - this.active_entry = Some(ActiveEntry::Thread { - thread_id: metadata.thread_id, - session_id: metadata.session_id.clone(), - workspace: workspace.clone(), - }); - this.update_entries(cx); - Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx); + ThreadSwitcherEvent::Preview(selection) => { + this.preview_switcher_selection(selection, window, cx); let focus = thread_switcher.focus_handle(cx); window.focus(&focus, cx); } - ThreadSwitcherEvent::Confirmed { - metadata, - workspace, - } => { - if let Some(mw) = weak_multi_workspace.upgrade() { - mw.update(cx, |mw, cx| { - mw.activate(workspace.clone(), None, window, cx); - mw.retain_active_workspace(cx); - }); - } - this.record_thread_access(&metadata.thread_id); - this.active_entry = Some(ActiveEntry::Thread { - thread_id: metadata.thread_id, - session_id: metadata.session_id.clone(), - workspace: workspace.clone(), - }); - this.update_entries(cx); - this.dismiss_thread_switcher(cx); - Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx); + ThreadSwitcherEvent::Confirmed(selection) => { + this.confirm_switcher_selection(selection, window, cx); } ThreadSwitcherEvent::Dismissed => { if let Some(mw) = weak_multi_workspace.upgrade() { @@ -4178,7 +4288,7 @@ impl Sidebar { let initial_preview = thread_switcher .read(cx) .selected_entry() - .map(|entry| (entry.metadata.clone(), entry.workspace.clone())); + .map(ThreadSwitcherEntry::selection); self.thread_switcher = Some(thread_switcher); self._thread_switcher_subscriptions = subscriptions; @@ -4188,19 +4298,8 @@ impl Sidebar { }); } - if let Some((metadata, workspace)) = initial_preview { - if let Some(mw) = self.multi_workspace.upgrade() { - mw.update(cx, |mw, cx| { - mw.activate(workspace.clone(), None, window, cx); - }); - } - self.active_entry = Some(ActiveEntry::Thread { - thread_id: metadata.thread_id, - session_id: metadata.session_id.clone(), - workspace: workspace.clone(), - }); - self.update_entries(cx); - Self::load_agent_thread_in_workspace(&workspace, &metadata, false, window, cx); + if let Some(selection) = initial_preview { + self.preview_switcher_selection(&selection, window, cx); } window.focus(&focus, cx); @@ -4359,6 +4458,7 @@ impl Sidebar { .blend(color.panel_background.opacity(0.25)); let terminal_id = terminal.id; let workspace = terminal.workspace.clone(); + let focus_handle = self.focus_handle.clone(); ThreadItem::new(id, terminal.title.clone()) .base_bg(sidebar_bg) @@ -4382,7 +4482,17 @@ impl Sidebar { IconButton::new("close-terminal", IconName::Close) .icon_size(IconSize::Small) .icon_color(Color::Muted) - .tooltip(Tooltip::text("Close Terminal")) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |_window, cx| { + Tooltip::for_action_in( + "Close Terminal", + &ArchiveSelectedThread, + &focus_handle, + cx, + ) + } + }) .on_click(cx.listener(move |this, _, window, cx| { this.close_terminal(&workspace, terminal_id, window, cx); })), diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 8939992f64c..697c260e108 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -1522,6 +1522,123 @@ async fn test_agent_panel_terminal_notifications_update_sidebar(cx: &mut TestApp }); } +#[gpui::test] +async fn test_thread_switcher_can_activate_agent_panel_terminal(cx: &mut TestAppContext) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + enable_agent_panel_terminal(cx); + 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 build_terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Build", true, window, cx) + }) + .expect("build test terminal should be inserted"); + let server_terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Server", true, window, cx) + }) + .expect("server test terminal should be inserted"); + cx.run_until_parked(); + + focus_sidebar(&sidebar, cx); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.on_toggle_thread_switcher(&ToggleThreadSwitcher::default(), window, cx); + }); + cx.run_until_parked(); + + let (entry_terminal_ids, selected_terminal_id) = sidebar.read_with(cx, |sidebar, cx| { + let switcher = sidebar + .thread_switcher + .as_ref() + .expect("switcher should be open"); + let switcher = switcher.read(cx); + let entry_terminal_ids = switcher + .entries() + .iter() + .map(|entry| { + entry + .terminal_id() + .expect("expected terminal switcher entry") + }) + .collect::>(); + let selected_terminal_id = switcher + .selected_entry() + .expect("switcher should have selected entry") + .terminal_id() + .expect("expected selected terminal switcher entry"); + (entry_terminal_ids, selected_terminal_id) + }); + + assert_eq!(entry_terminal_ids.len(), 2); + assert!(entry_terminal_ids.contains(&build_terminal_id)); + assert!(entry_terminal_ids.contains(&server_terminal_id)); + + sidebar.update_in(cx, |sidebar, window, cx| { + let switcher = sidebar + .thread_switcher + .as_ref() + .expect("switcher should be open"); + let focus = switcher.focus_handle(cx); + focus.dispatch_action(&menu::Confirm, window, cx); + }); + cx.run_until_parked(); + + panel.read_with(cx, |panel, _cx| { + assert_eq!(panel.active_terminal_id(), Some(selected_terminal_id)); + }); + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + matches!(&sidebar.active_entry, Some(ActiveEntry::Terminal { terminal_id, .. }) if *terminal_id == selected_terminal_id), + "expected selected terminal to become active, got {:?}", + sidebar.active_entry, + ); + }); +} + +#[gpui::test] +async fn test_archive_selected_thread_closes_selected_agent_panel_terminal( + cx: &mut TestAppContext, +) { + let project = init_test_project_with_agent_panel("/my-project", cx).await; + enable_agent_panel_terminal(cx); + 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(); + + focus_sidebar(&sidebar, cx); + let terminal_index = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .position(|entry| matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id)) + .expect("terminal should be visible in sidebar") + }); + sidebar.update_in(cx, |sidebar, _window, _cx| { + sidebar.selection = Some(terminal_index); + }); + cx.dispatch_action(ArchiveSelectedThread); + cx.run_until_parked(); + + panel.read_with(cx, |panel, _cx| { + assert!(!panel.has_terminal(terminal_id)); + }); + sidebar.read_with(cx, |sidebar, _cx| { + assert!(sidebar.contents.entries.iter().all(|entry| { + !matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id) + })); + }); +} + #[gpui::test] async fn test_closing_active_agent_panel_terminal_activates_neighbor(cx: &mut TestAppContext) { let project = init_test_project_with_agent_panel("/my-project", cx).await; @@ -5944,7 +6061,7 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { .read(cx) .entries() .iter() - .map(|e| e.metadata.thread_id) + .map(|entry| entry.thread_id().expect("expected thread switcher entry")) .collect() }) }; @@ -5959,8 +6076,8 @@ async fn test_thread_switcher_ordering(cx: &mut TestAppContext) { let s = switcher.read(cx); s.selected_entry() .expect("should have selection") - .metadata - .thread_id + .thread_id() + .expect("expected selected thread entry") }) }; diff --git a/crates/sidebar/src/thread_switcher.rs b/crates/sidebar/src/thread_switcher.rs index c74cdedc9fc..2e998a2501d 100644 --- a/crates/sidebar/src/thread_switcher.rs +++ b/crates/sidebar/src/thread_switcher.rs @@ -1,6 +1,6 @@ use action_log::DiffStats; use agent_client_protocol::schema as acp; -use agent_ui::thread_metadata_store::ThreadMetadata; +use agent_ui::{TerminalId, thread_metadata_store::ThreadMetadata}; use gpui::{ Action as _, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Modifiers, ModifiersChangedEvent, Render, ScrollHandle, SharedString, prelude::*, @@ -9,7 +9,8 @@ use ui::{AgentThreadStatus, ThreadItem, ThreadItemWorktreeInfo, WithScrollbar, p use workspace::{ModalView, Workspace}; use zed_actions::agents_sidebar::ToggleThreadSwitcher; -pub(crate) struct ThreadSwitcherEntry { +#[derive(Clone)] +pub(crate) struct ThreadSwitcherThreadEntry { pub session_id: acp::SessionId, pub title: SharedString, pub icon: IconName, @@ -25,15 +26,150 @@ pub(crate) struct ThreadSwitcherEntry { pub timestamp: SharedString, } +#[derive(Clone)] +pub(crate) struct ThreadSwitcherTerminalEntry { + pub terminal_id: TerminalId, + pub title: SharedString, + pub workspace: Entity, + pub project_name: Option, + pub created_at: chrono::DateTime, + pub notified: bool, + pub timestamp: SharedString, +} + +#[derive(Clone)] +pub(crate) enum ThreadSwitcherEntry { + Thread(ThreadSwitcherThreadEntry), + Terminal(ThreadSwitcherTerminalEntry), +} + +#[derive(Clone)] +pub(crate) enum ThreadSwitcherSelection { + Thread { + metadata: ThreadMetadata, + workspace: Entity, + }, + Terminal { + terminal_id: TerminalId, + workspace: Entity, + }, +} + +impl ThreadSwitcherEntry { + pub(crate) fn selection(&self) -> ThreadSwitcherSelection { + match self { + Self::Thread(entry) => ThreadSwitcherSelection::Thread { + metadata: entry.metadata.clone(), + workspace: entry.workspace.clone(), + }, + Self::Terminal(entry) => ThreadSwitcherSelection::Terminal { + terminal_id: entry.terminal_id, + workspace: entry.workspace.clone(), + }, + } + } + + fn element_id(&self) -> SharedString { + match self { + Self::Thread(entry) => { + SharedString::from(format!("thread-switcher-thread-{}", entry.session_id)) + } + Self::Terminal(entry) => { + SharedString::from(format!("thread-switcher-terminal-{}", entry.terminal_id)) + } + } + } + + fn title(&self) -> SharedString { + match self { + Self::Thread(entry) => entry.title.clone(), + Self::Terminal(entry) => entry.title.clone(), + } + } + + fn icon(&self) -> IconName { + match self { + Self::Thread(entry) => entry.icon, + Self::Terminal(_) => IconName::Terminal, + } + } + + fn icon_from_external_svg(&self) -> Option { + match self { + Self::Thread(entry) => entry.icon_from_external_svg.clone(), + Self::Terminal(_) => None, + } + } + + fn status(&self) -> AgentThreadStatus { + match self { + Self::Thread(entry) => entry.status, + Self::Terminal(_) => AgentThreadStatus::default(), + } + } + + fn project_name(&self) -> Option { + match self { + Self::Thread(entry) => entry.project_name.clone(), + Self::Terminal(entry) => entry.project_name.clone(), + } + } + + fn worktrees(&self) -> Vec { + match self { + Self::Thread(entry) => entry.worktrees.clone(), + Self::Terminal(_) => Vec::new(), + } + } + + fn timestamp(&self) -> SharedString { + match self { + Self::Thread(entry) => entry.timestamp.clone(), + Self::Terminal(entry) => entry.timestamp.clone(), + } + } + + fn is_title_generating(&self) -> bool { + match self { + Self::Thread(entry) => entry.is_title_generating, + Self::Terminal(_) => false, + } + } + + fn notified(&self) -> bool { + match self { + Self::Thread(entry) => entry.notified, + Self::Terminal(entry) => entry.notified, + } + } + + fn diff_stats(&self) -> DiffStats { + match self { + Self::Thread(entry) => entry.diff_stats, + Self::Terminal(_) => DiffStats::default(), + } + } + + #[cfg(test)] + pub fn thread_id(&self) -> Option { + match self { + Self::Thread(entry) => Some(entry.metadata.thread_id), + Self::Terminal(_) => None, + } + } + + #[cfg(test)] + pub fn terminal_id(&self) -> Option { + match self { + Self::Thread(_) => None, + Self::Terminal(entry) => Some(entry.terminal_id), + } + } +} + pub(crate) enum ThreadSwitcherEvent { - Preview { - metadata: ThreadMetadata, - workspace: Entity, - }, - Confirmed { - metadata: ThreadMetadata, - workspace: Entity, - }, + Preview(ThreadSwitcherSelection), + Confirmed(ThreadSwitcherSelection), Dismissed, } @@ -62,10 +198,7 @@ impl ThreadSwitcher { }; if let Some(entry) = entries.get(selected_index) { - cx.emit(ThreadSwitcherEvent::Preview { - metadata: entry.metadata.clone(), - workspace: entry.workspace.clone(), - }); + cx.emit(ThreadSwitcherEvent::Preview(entry.selection())); } let focus_handle = cx.focus_handle(); @@ -124,10 +257,7 @@ impl ThreadSwitcher { fn emit_preview(&mut self, cx: &mut Context) { self.scroll_handle.scroll_to_item(self.selected_index); if let Some(entry) = self.entries.get(self.selected_index) { - cx.emit(ThreadSwitcherEvent::Preview { - metadata: entry.metadata.clone(), - workspace: entry.workspace.clone(), - }); + cx.emit(ThreadSwitcherEvent::Preview(entry.selection())); } } @@ -137,10 +267,7 @@ impl ThreadSwitcher { fn confirm_selected(&mut self, cx: &mut Context) { if let Some(entry) = self.entries.get(self.selected_index) { - cx.emit(ThreadSwitcherEvent::Confirmed { - metadata: entry.metadata.clone(), - workspace: entry.workspace.clone(), - }); + cx.emit(ThreadSwitcherEvent::Confirmed(entry.selection())); } cx.emit(DismissEvent); } @@ -233,28 +360,25 @@ impl Render for ThreadSwitcher { .overflow_y_scroll() .track_scroll(&self.scroll_handle) .children(self.entries.iter().enumerate().map(|(ix, entry)| { - let id = - SharedString::from(format!("thread-switcher-{}", entry.session_id)); + let diff_stats = entry.diff_stats(); - ThreadItem::new(id, entry.title.clone()) + ThreadItem::new(entry.element_id(), entry.title()) .rounded(true) - .icon(entry.icon) - .status(entry.status) - .when_some(entry.icon_from_external_svg.clone(), |this, svg| { + .icon(entry.icon()) + .status(entry.status()) + .when_some(entry.icon_from_external_svg(), |this, svg| { this.custom_icon_from_external_svg(svg) }) - .when_some(entry.project_name.clone(), |this, name| { - this.project_name(name) + .when_some(entry.project_name(), |this, name| this.project_name(name)) + .worktrees(entry.worktrees()) + .timestamp(entry.timestamp()) + .title_generating(entry.is_title_generating()) + .notified(entry.notified()) + .when(diff_stats.lines_added > 0, |this| { + this.added(diff_stats.lines_added as usize) }) - .worktrees(entry.worktrees.clone()) - .timestamp(entry.timestamp.clone()) - .title_generating(entry.is_title_generating) - .notified(entry.notified) - .when(entry.diff_stats.lines_added > 0, |this| { - this.added(entry.diff_stats.lines_added as usize) - }) - .when(entry.diff_stats.lines_removed > 0, |this| { - this.removed(entry.diff_stats.lines_removed as usize) + .when(diff_stats.lines_removed > 0, |this| { + this.removed(diff_stats.lines_removed as usize) }) .selected(ix == selected_index) .base_bg(cx.theme().colors().elevated_surface_background) diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index 5d75fcf653e..76e055666cf 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -41,7 +41,7 @@ From the "New Thread…" menu you can: - Pick **Zed Agent** or any installed [external agent](./external-agents.md) to start a new thread with that agent. - Choose **New From Summary** to start a fresh Zed Agent thread seeded with a summary of the current conversation — useful for compacting long threads as you approach the context window limit. -{#action agent::NewExternalAgentThread} creates another thread with the currently selected agent. +{#action agent::NewExternalAgentThread} creates a new thread with the specified external agent id. You can also start a new thread from the [Threads Sidebar](./parallel-agents.md#threads-sidebar), scoped to a specific project — see [Running Multiple Threads](./parallel-agents.md#running-multiple-threads). diff --git a/docs/src/ai/external-agents.md b/docs/src/ai/external-agents.md index 50d1a1ce197..86df81e0a6d 100644 --- a/docs/src/ai/external-agents.md +++ b/docs/src/ai/external-agents.md @@ -29,10 +29,7 @@ If you'd like to bind this to a keyboard shortcut, you can do so by editing your [ { "bindings": { - "cmd-alt-g": [ - "agent::NewExternalAgentThread", - { "agent": { "custom": { "name": "gemini" } } } - ] + "cmd-alt-g": ["agent::NewExternalAgentThread", { "agent": "gemini" }] } } ] @@ -75,10 +72,7 @@ If you'd like to bind this to a keyboard shortcut, you can do so by editing your [ { "bindings": { - "cmd-alt-c": [ - "agent::NewExternalAgentThread", - { "agent": { "custom": { "name": "claude-acp" } } } - ] + "cmd-alt-c": ["agent::NewExternalAgentThread", { "agent": "claude-acp" }] } } ] @@ -150,10 +144,7 @@ If you'd like to bind this to a keyboard shortcut, you can do so by editing your [ { "bindings": { - "cmd-alt-c": [ - "agent::NewExternalAgentThread", - { "agent": { "custom": { "name": "codex-acp" } } } - ] + "cmd-alt-c": ["agent::NewExternalAgentThread", { "agent": "codex-acp" }] } } ]