diff --git a/Cargo.lock b/Cargo.lock index d9a42436e3d6c40e92716fe7adfd4534aa4ce395..f505b58b5f7d82ca69c29d13f9d09e464c6b5bd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16329,6 +16329,7 @@ dependencies = [ "git", "gpui", "http_client", + "itertools 0.14.0", "language", "language_model", "log", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 9c49646b5a786ca16e94906148fb11140fedb513..cd1aee29c7c9a6dc1f232dadf8532d0f1c7cf59e 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1247,6 +1247,12 @@ "ctrl->": "agent::AddSelectionToThread", }, }, + { + "context": "AgentPanel && Terminal", + "bindings": { + "ctrl-n": "agent::NewThread", + }, + }, { "context": "ZedPredictModal", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index d0ac2c22e03a01443eda868f155154c364167f69..bf96104f65e7406e8025c8bd22549c12fa1f539b 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1316,6 +1316,13 @@ "cmd->": "agent::AddSelectionToThread", }, }, + { + "context": "AgentPanel > Terminal", + "use_key_equivalents": true, + "bindings": { + "cmd-n": "agent::NewThread", + }, + }, { "context": "RatePredictionsModal", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 66195c604fef0f6805b46184b0748d8319e91185..ce293452d2d6bdfeca54c0ad99e9694335bd5ac9 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1262,6 +1262,13 @@ "ctrl-shift-.": "agent::AddSelectionToThread", }, }, + { + "context": "AgentPanel > Terminal", + "use_key_equivalents": true, + "bindings": { + "ctrl-n": "agent::NewThread", + }, + }, { "context": "Terminal && selection", "bindings": { diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index e60a4834ae2b063ed7a65de2450fae373937392e..b17c52818be8ada163588b7eece0ecaba1e65d84 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1,4 +1,5 @@ use std::{ + fmt, path::PathBuf, rc::Rc, sync::{ @@ -57,6 +58,7 @@ use collections::HashMap; use editor::{Editor, MultiBuffer}; use extension::ExtensionEvents; use extension_host::ExtensionStore; +use feature_flags::{AgentPanelTerminalFeatureFlag, FeatureFlagAppExt as _}; use fs::Fs; use gpui::{ Action, Anchor, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, @@ -68,7 +70,10 @@ use language_model::LanguageModelRegistry; use project::{Project, ProjectPath, Worktree}; use prompt_store::{PromptStore, UserPromptId}; use rules_library::{RulesLibrary, open_rules_library}; +use settings::TerminalDockPosition; use settings::{Settings, update_settings_file}; +use terminal::{Event as TerminalEvent, terminal_settings::TerminalSettings}; +use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use theme_settings::ThemeSettings; use ui::{ Button, ContextMenu, ContextMenuEntry, IconButton, PopoverMenu, PopoverMenuHandle, Tab, @@ -79,6 +84,7 @@ use workspace::{ CollaboratorId, DraggedSelection, DraggedTab, PathList, SerializedPathList, ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId, dock::{DockPosition, Panel, PanelEvent}, + item::ItemEvent, }; const AGENT_PANEL_KEY: &str = "agent_panel"; @@ -96,6 +102,29 @@ impl MaxIdleRetainedThreads { } } +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +pub struct TerminalId(uuid::Uuid); + +impl TerminalId { + fn new() -> Self { + Self(uuid::Uuid::new_v4()) + } +} + +impl fmt::Display for TerminalId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +#[derive(Clone, Debug)] +pub struct AgentPanelTerminalInfo { + pub id: TerminalId, + pub title: SharedString, + pub created_at: DateTime, + pub has_notification: bool, +} + #[derive(Serialize, Deserialize)] struct LastUsedAgent { agent: Agent, @@ -152,10 +181,19 @@ fn read_legacy_serialized_panel(kvp: &KeyValueStore) -> Option(&json).log_err()) } +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +enum AgentPanelEntryKind { + #[default] + Thread, + Terminal, +} + #[derive(Serialize, Deserialize, Debug)] struct SerializedAgentPanel { selected_agent: Option, #[serde(default)] + last_created_entry_kind: AgentPanelEntryKind, + #[serde(default)] last_active_thread: Option, draft_thread_prompt: Option>, } @@ -172,9 +210,9 @@ pub fn init(cx: &mut App) { cx.observe_new( |workspace: &mut Workspace, _window, _cx: &mut Context| { workspace - .register_action(|workspace, action: &NewThread, window, cx| { + .register_action(|workspace, _: &NewThread, window, cx| { if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| panel.new_thread(action, window, cx)); + panel.update(cx, |panel, cx| panel.new_entry(Some(workspace), window, cx)); workspace.focus_panel::(window, cx); } }) @@ -411,6 +449,48 @@ pub fn init(cx: &mut App) { ) .register_action( |workspace: &mut Workspace, _: &AddSelectionToThread, window, cx| { + let active_editor = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)); + let has_editor_selection = active_editor.is_some_and(|editor| { + editor.update(cx, |editor, cx| { + editor.has_non_empty_selection(&editor.display_snapshot(cx)) + }) + }); + + let has_terminal_selection = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + .is_some_and(|terminal_view| { + terminal_view + .read(cx) + .terminal() + .read(cx) + .last_content + .selection_text + .as_ref() + .is_some_and(|text| !text.is_empty()) + }); + + let has_terminal_panel_selection = + workspace.panel::(cx).is_some_and(|panel| { + let position = match TerminalSettings::get_global(cx).dock { + TerminalDockPosition::Left => DockPosition::Left, + TerminalDockPosition::Bottom => DockPosition::Bottom, + TerminalDockPosition::Right => DockPosition::Right, + }; + let dock_is_open = + workspace.dock_at_position(position).read(cx).is_open(); + dock_is_open && !panel.read(cx).terminal_selections(cx).is_empty() + }); + + if !has_editor_selection + && !has_terminal_selection + && !has_terminal_panel_selection + { + return; + } + let Some(agent_panel) = workspace.panel::(cx) else { return; }; @@ -603,11 +683,60 @@ pub(crate) struct AgentThread { conversation_view: Entity, } +struct AgentTerminal { + view: Entity, + title_editor: Entity, + last_known_title: String, + created_at: DateTime, + has_notification: bool, + _subscriptions: Vec, +} + +impl AgentTerminal { + fn display_title(&self, cx: &App) -> SharedString { + let view = self.view.read(cx); + view.custom_title() + .map(SharedString::from) + .or_else(|| { + let breadcrumb_text = &view.terminal().read(cx).breadcrumb_text; + if breadcrumb_text.is_empty() { + None + } else { + Some(breadcrumb_text.clone().into()) + } + }) + .unwrap_or_else(|| SharedString::from(view.terminal().read(cx).title(true))) + } + + fn refresh_title(&mut self, window: &mut Window, cx: &mut App) -> bool { + let title = self.display_title(cx).to_string(); + let changed = self.last_known_title != title; + if changed { + self.last_known_title = title.clone(); + } + + let should_update_editor = { + let title_editor = self.title_editor.read(cx); + !title_editor.is_focused(window) && title_editor.text(cx) != title + }; + if should_update_editor { + self.title_editor.update(cx, |title_editor, cx| { + title_editor.set_text(title, window, cx); + }); + } + + changed + } +} + enum BaseView { Uninitialized, AgentThread { conversation_view: Entity, }, + Terminal { + terminal_id: TerminalId, + }, } impl From for BaseView { @@ -625,6 +754,7 @@ enum OverlayView { enum VisibleSurface<'a> { Uninitialized, AgentThread(&'a Entity), + Terminal(&'a Entity), Configuration(Option<&'a Entity>), } @@ -635,7 +765,10 @@ enum WhichFontSize { impl BaseView { pub fn which_font_size_used(&self) -> WhichFontSize { - WhichFontSize::AgentFont + match self { + BaseView::AgentThread { .. } => WhichFontSize::AgentFont, + BaseView::Terminal { .. } | BaseView::Uninitialized => WhichFontSize::None, + } } } @@ -663,9 +796,11 @@ pub struct AgentPanel { configuration_subscription: Option, focus_handle: FocusHandle, base_view: BaseView, + last_created_entry_kind: AgentPanelEntryKind, overlay_view: Option, draft_thread: Option>, retained_threads: HashMap>, + terminals: HashMap, new_thread_menu_handle: PopoverMenuHandle, agent_panel_menu_handle: PopoverMenuHandle, _extension_subscription: Option, @@ -690,6 +825,7 @@ impl AgentPanel { }; let selected_agent = self.selected_agent.clone(); + let last_created_entry_kind = self.last_created_entry_kind; let is_draft_active = self.active_thread_is_draft(cx); let last_active_thread = self @@ -748,6 +884,7 @@ impl AgentPanel { workspace_id, SerializedAgentPanel { selected_agent: Some(selected_agent), + last_created_entry_kind, last_active_thread, draft_thread_prompt, }, @@ -840,6 +977,7 @@ impl AgentPanel { global_last_used_agent.filter(|agent| !is_via_collab || agent.is_native()); if let Some(serialized_panel) = &serialized_panel { + panel.last_created_entry_kind = serialized_panel.last_created_entry_kind; if let Some(selected_agent) = serialized_panel.selected_agent.clone() { panel.selected_agent = selected_agent; } else if let Some(agent) = global_fallback { @@ -1009,6 +1147,7 @@ impl AgentPanel { let mut panel = Self { workspace_id, base_view, + last_created_entry_kind: AgentPanelEntryKind::Thread, overlay_view: None, workspace, user_store, @@ -1023,6 +1162,7 @@ impl AgentPanel { context_server_registry, draft_thread: None, retained_threads: HashMap::default(), + terminals: HashMap::default(), new_thread_menu_handle: PopoverMenuHandle::default(), agent_panel_menu_handle: PopoverMenuHandle::default(), @@ -1163,8 +1303,32 @@ impl AgentPanel { cx.notify(); } + pub fn new_entry( + &mut self, + workspace: Option<&Workspace>, + window: &mut Window, + cx: &mut Context, + ) { + if self.should_create_terminal_for_new_entry(cx) { + self.new_terminal(workspace, window, cx); + } else { + self.activate_new_thread(true, "agent_panel", window, cx); + } + } + pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context) { - self.activate_draft(true, "agent_panel", window, cx); + self.new_entry(None, window, cx); + } + + pub fn activate_new_thread( + &mut self, + focus: bool, + trigger: &'static str, + window: &mut Window, + cx: &mut Context, + ) { + self.set_last_created_entry_kind(AgentPanelEntryKind::Thread, cx); + self.activate_draft(focus, trigger, window, cx); } pub fn new_external_agent_thread( @@ -1176,7 +1340,311 @@ impl AgentPanel { if let Some(agent) = action.agent.clone() { self.selected_agent = agent; } - self.activate_draft(true, "agent_panel", window, cx); + self.activate_new_thread(true, "agent_panel", window, cx); + } + + pub fn new_terminal( + &mut self, + workspace: Option<&Workspace>, + window: &mut Window, + cx: &mut Context, + ) { + if !cx.has_flag::() { + return; + } + let working_directory = workspace + .map(|workspace| terminal_view::default_working_directory(workspace, cx)) + .unwrap_or_else(|| self.default_terminal_working_directory(cx)); + self.spawn_terminal(TerminalId::new(), working_directory, true, window, cx); + } + + pub fn supports_terminal(&self, cx: &App) -> bool { + cx.has_flag::() + && self.project.read(cx).supports_terminal(cx) + } + + pub fn should_create_terminal_for_new_entry(&self, cx: &App) -> bool { + self.last_created_entry_kind == AgentPanelEntryKind::Terminal && self.supports_terminal(cx) + } + + fn set_last_created_entry_kind( + &mut self, + entry_kind: AgentPanelEntryKind, + cx: &mut Context, + ) { + if self.last_created_entry_kind != entry_kind { + self.last_created_entry_kind = entry_kind; + self.serialize(cx); + } + } + + fn spawn_terminal( + &mut self, + terminal_id: TerminalId, + working_directory: Option, + focus: bool, + window: &mut Window, + cx: &mut Context, + ) { + let terminal_task = self.project.update(cx, |project, cx| { + project.create_terminal_shell(working_directory, cx) + }); + let workspace = self.workspace.clone(); + let workspace_id = self.workspace_id; + let project = self.project.downgrade(); + + cx.spawn_in(window, async move |this, cx| { + let terminal = match terminal_task.await { + Ok(terminal) => terminal, + Err(error) => { + log::error!("failed to spawn agent panel terminal: {error:#}"); + workspace + .update(cx, |workspace, cx| workspace.show_error(&error, cx)) + .log_err(); + return anyhow::Ok(()); + } + }; + this.update_in(cx, |this, window, cx| { + let terminal_view = cx.new(|cx| { + TerminalView::new(terminal, workspace, workspace_id, project, window, cx) + }); + this.insert_terminal(terminal_id, terminal_view, focus, window, cx); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn insert_terminal( + &mut self, + terminal_id: TerminalId, + terminal_view: Entity, + focus: bool, + window: &mut Window, + cx: &mut Context, + ) { + if !cx.has_flag::() { + return; + } + let terminal_entity = terminal_view.read(cx).terminal().clone(); + let title = { + let terminal_view = terminal_view.read(cx); + terminal_view + .custom_title() + .map(ToString::to_string) + .unwrap_or_else(|| terminal_view.terminal().read(cx).title(true)) + }; + let title_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_text(title, window, cx); + editor + }); + let title_editor_subscription = cx.subscribe_in( + &title_editor, + window, + move |this, title_editor, event: &editor::EditorEvent, window, cx| { + this.handle_terminal_title_editor_event( + terminal_id, + title_editor, + event, + window, + cx, + ); + }, + ); + let view_subscription = cx.subscribe_in( + &terminal_view, + window, + move |this, _terminal_view, event: &ItemEvent, window, cx| match event { + ItemEvent::UpdateTab | ItemEvent::UpdateBreadcrumbs => { + this.refresh_terminal_title(terminal_id, window, cx); + } + ItemEvent::CloseItem | ItemEvent::Edit => {} + }, + ); + // Listen on the underlying `Terminal` entity for shell-driven metadata + // changes and bell. + let terminal_subscription = cx.subscribe_in( + &terminal_entity, + window, + move |this, _terminal, event: &TerminalEvent, window, cx| match event { + TerminalEvent::TitleChanged + | TerminalEvent::Wakeup + | TerminalEvent::BreadcrumbsChanged => { + this.refresh_terminal_title(terminal_id, window, cx); + } + TerminalEvent::Bell => this.mark_terminal_notification(terminal_id, window, cx), + TerminalEvent::CloseTerminal => { + this.close_terminal(terminal_id, window, cx); + } + TerminalEvent::BlinkChanged(_) + | TerminalEvent::SelectionsChanged + | TerminalEvent::NewNavigationTarget(_) + | TerminalEvent::Open(_) => {} + }, + ); + + let mut terminal = AgentTerminal { + view: terminal_view, + title_editor, + last_known_title: String::new(), + created_at: Utc::now(), + has_notification: false, + _subscriptions: vec![ + view_subscription, + terminal_subscription, + title_editor_subscription, + ], + }; + self.set_last_created_entry_kind(AgentPanelEntryKind::Terminal, cx); + terminal.refresh_title(window, cx); + self.terminals.insert(terminal_id, terminal); + if focus { + self.set_base_view(BaseView::Terminal { terminal_id }, true, window, cx); + } + cx.emit(AgentPanelEvent::EntryChanged); + cx.notify(); + } + + pub fn activate_terminal( + &mut self, + terminal_id: TerminalId, + focus: bool, + window: &mut Window, + cx: &mut Context, + ) { + if !cx.has_flag::() { + return; + } + let Some(terminal) = self.terminals.get_mut(&terminal_id) else { + return; + }; + let had_notification = terminal.has_notification; + terminal.has_notification = false; + self.set_base_view(BaseView::Terminal { terminal_id }, focus, window, cx); + if had_notification { + cx.emit(AgentPanelEvent::EntryChanged); + cx.notify(); + } + } + + pub fn close_terminal( + &mut self, + terminal_id: TerminalId, + window: &mut Window, + cx: &mut Context, + ) { + let was_active = self.active_terminal_id() == Some(terminal_id); + + if self.terminals.remove(&terminal_id).is_none() { + return; + } + if was_active { + self.base_view = BaseView::Uninitialized; + self.refresh_base_view_subscriptions(window, cx); + self.activate_draft(false, "agent_panel", window, cx); + } + + cx.emit(AgentPanelEvent::EntryChanged); + cx.notify(); + } + + fn refresh_terminal_title( + &mut self, + terminal_id: TerminalId, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(terminal) = self.terminals.get_mut(&terminal_id) + && terminal.refresh_title(window, cx) + { + cx.emit(AgentPanelEvent::EntryChanged); + cx.notify(); + } + } + + fn handle_terminal_title_editor_event( + &mut self, + terminal_id: TerminalId, + title_editor: &Entity, + event: &editor::EditorEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + editor::EditorEvent::BufferEdited => { + if !title_editor.read(cx).is_focused(window) { + return; + } + let Some(terminal_view) = self + .terminals + .get(&terminal_id) + .map(|terminal| terminal.view.clone()) + else { + return; + }; + let new_title = title_editor.read(cx).text(cx).trim().to_string(); + let label = if new_title.is_empty() { + None + } else { + let terminal_title = terminal_view.read(cx).terminal().read(cx).title(true); + if new_title == terminal_title { + None + } else { + Some(new_title) + } + }; + + cx.defer(move |cx| { + terminal_view.update(cx, |terminal_view, cx| { + terminal_view.set_custom_title(label, cx); + }); + }); + } + editor::EditorEvent::Blurred => { + if let Some(terminal) = self.terminals.get_mut(&terminal_id) { + terminal.refresh_title(window, cx); + } + } + _ => {} + } + } + + fn mark_terminal_notification( + &mut self, + terminal_id: TerminalId, + window: &mut Window, + cx: &mut Context, + ) { + let is_active = self.active_terminal_id() == Some(terminal_id); + // Only suppress when the user can actually see the bell, i.e. the + // terminal is focused AND the OS window is active. A bell delivered to + // a background window should still be marked unseen. + let user_is_looking = is_active + && window.is_window_active() + && self.terminals.get(&terminal_id).is_some_and(|terminal| { + terminal.view.focus_handle(cx).contains_focused(window, cx) + }); + if user_is_looking { + return; + } + let Some(terminal) = self.terminals.get_mut(&terminal_id) else { + return; + }; + if !terminal.has_notification { + terminal.has_notification = true; + cx.emit(AgentPanelEvent::EntryChanged); + cx.notify(); + } + } + + fn default_terminal_working_directory(&self, cx: &App) -> Option { + // Reuse the workspace-based helper so behavior matches the regular + // terminal panel (e.g. `WorkingDirectory::FirstProjectDirectory` falling + // back to a file's parent directory when the worktree root is a file). + self.workspace + .upgrade() + .and_then(|workspace| terminal_view::default_working_directory(workspace.read(cx), cx)) } pub fn activate_draft( @@ -1287,6 +1755,33 @@ impl AgentPanel { } } + pub fn active_terminal_id(&self) -> Option { + match &self.base_view { + BaseView::Terminal { terminal_id } => Some(*terminal_id), + _ => None, + } + } + + pub fn has_terminal(&self, terminal_id: TerminalId) -> bool { + self.terminals.contains_key(&terminal_id) + } + + pub fn terminals(&self, cx: &App) -> Vec { + if !cx.has_flag::() { + return Vec::new(); + } + + self.terminals + .iter() + .map(|(id, terminal)| AgentPanelTerminalInfo { + id: *id, + title: terminal.display_title(cx), + created_at: terminal.created_at, + has_notification: terminal.has_notification, + }) + .collect() + } + pub fn editor_text(&self, id: ThreadId, cx: &App) -> Option { let cv = self .retained_threads @@ -1844,7 +2339,7 @@ impl AgentPanel { }); } - self.new_thread(&NewThread, window, cx); + self.activate_new_thread(true, "agent_panel", window, cx); if let Some((thread, model)) = self .active_native_agent_thread(cx) .zip(provider.default_model(cx)) @@ -2022,7 +2517,8 @@ impl AgentPanel { self.retain_running_thread(old_view, cx); if let BaseView::AgentThread { conversation_view } = &self.base_view { - let thread_agent = conversation_view.read(cx).agent_key().clone(); + let conversation_view = conversation_view.read(cx); + let thread_agent = conversation_view.agent_key().clone(); if self.selected_agent != thread_agent { self.selected_agent = thread_agent; self.serialize(cx); @@ -2074,7 +2570,7 @@ impl AgentPanel { let focus_handle = conversation_view.focus_handle(cx); self._active_thread_focus_subscription = Some(cx.on_focus_in(&focus_handle, window, |_this, _window, cx| { - cx.emit(AgentPanelEvent::ThreadFocused); + cx.emit(AgentPanelEvent::ActiveViewFocused); cx.notify(); })); Some(cx.observe_in( @@ -2089,6 +2585,26 @@ impl AgentPanel { }, )) } + BaseView::Terminal { terminal_id } => { + self._thread_view_subscription = None; + if let Some(terminal) = self.terminals.get(terminal_id) { + let terminal_id = *terminal_id; + let focus_handle = terminal.view.focus_handle(cx); + self._active_thread_focus_subscription = + Some( + cx.on_focus_in(&focus_handle, window, move |this, _window, cx| { + if let Some(terminal) = this.terminals.get_mut(&terminal_id) { + terminal.has_notification = false; + } + cx.emit(AgentPanelEvent::ActiveViewFocused); + cx.notify(); + }), + ); + } else { + self._active_thread_focus_subscription = None; + } + None + } BaseView::Uninitialized => { self._thread_view_subscription = None; self._active_thread_focus_subscription = None; @@ -2112,6 +2628,11 @@ impl AgentPanel { BaseView::AgentThread { conversation_view } => { VisibleSurface::AgentThread(conversation_view) } + BaseView::Terminal { terminal_id } => self + .terminals + .get(terminal_id) + .map(|terminal| VisibleSurface::Terminal(&terminal.view)) + .unwrap_or(VisibleSurface::Uninitialized), } } @@ -2368,7 +2889,7 @@ impl AgentPanel { cx.emit(AgentPanelEvent::ActiveViewChanged); this.serialize(cx); } else { - cx.emit(AgentPanelEvent::RetainedThreadChanged); + cx.emit(AgentPanelEvent::EntryChanged); } cx.notify(); }) @@ -2395,6 +2916,7 @@ impl Focusable for AgentPanel { match self.visible_surface() { VisibleSurface::Uninitialized => self.focus_handle.clone(), VisibleSurface::AgentThread(conversation_view) => conversation_view.focus_handle(cx), + VisibleSurface::Terminal(terminal_view) => terminal_view.focus_handle(cx), VisibleSurface::Configuration(configuration) => { if let Some(configuration) = configuration { configuration.focus_handle(cx) @@ -2412,8 +2934,8 @@ fn agent_panel_dock_position(cx: &App) -> DockPosition { pub enum AgentPanelEvent { ActiveViewChanged, - ThreadFocused, - RetainedThreadChanged, + ActiveViewFocused, + EntryChanged, ThreadInteracted { thread_id: ThreadId }, } @@ -2535,12 +3057,16 @@ impl AgentPanel { } fn destination_has_meaningful_state(&self, cx: &App) -> bool { - if self.overlay_view.is_some() || !self.retained_threads.is_empty() { + if self.overlay_view.is_some() + || !self.retained_threads.is_empty() + || !self.terminals.is_empty() + { return true; } match &self.base_view { BaseView::Uninitialized => false, + BaseView::Terminal { .. } => true, BaseView::AgentThread { conversation_view } => { let has_entries = conversation_view .read(cx) @@ -2725,6 +3251,30 @@ impl AgentPanel { .into_any_element() } } + VisibleSurface::Terminal(_) => { + if let Some((title_editor, terminal_view)) = self + .active_terminal_id() + .and_then(|terminal_id| self.terminals.get(&terminal_id)) + .map(|terminal| (terminal.title_editor.clone(), terminal.view.clone())) + { + let terminal_view_cancel = terminal_view.clone(); + div() + .flex_1() + .on_action(move |_: &menu::Confirm, window, cx| { + terminal_view.focus_handle(cx).focus(window, cx); + }) + .on_action(move |_: &editor::actions::Cancel, window, cx| { + terminal_view_cancel.focus_handle(cx).focus(window, cx); + }) + .child(title_editor) + .into_any_element() + } else { + Label::new("Terminal") + .color(Color::Muted) + .truncate() + .into_any_element() + } + } VisibleSurface::Configuration(_) => { Label::new("Settings").truncate().into_any_element() } @@ -2870,25 +3420,28 @@ impl AgentPanel { let agent_server_store = self.project.read(cx).agent_server_store().clone(); let focus_handle = self.focus_handle(cx); - - let (selected_agent_custom_icon, selected_agent_label) = - if let Agent::Custom { id, .. } = &self.selected_agent { - let store = agent_server_store.read(cx); - let icon = store.agent_icon(&id); - - let label = store - .agent_display_name(&id) - .unwrap_or_else(|| self.selected_agent.label()); - (icon, label) - } else { - (None, self.selected_agent.label()) - }; + let supports_terminal = self.supports_terminal(cx); + + let showing_terminal = matches!(self.visible_surface(), VisibleSurface::Terminal(_)); + let (selected_agent_custom_icon, selected_agent_label) = if showing_terminal { + (None, SharedString::from("Terminal")) + } else if let Agent::Custom { id, .. } = &self.selected_agent { + let store = agent_server_store.read(cx); + let icon = store.agent_icon(&id); + + let label = store + .agent_display_name(&id) + .unwrap_or_else(|| self.selected_agent.label()); + (icon, label) + } else { + (None, self.selected_agent.label()) + }; let active_thread = match &self.base_view { BaseView::AgentThread { conversation_view } => { conversation_view.read(cx).as_native_thread(cx) } - BaseView::Uninitialized => None, + BaseView::Terminal { .. } | BaseView::Uninitialized => None, }; let new_thread_menu_builder: Rc< @@ -2963,6 +3516,33 @@ impl AgentPanel { } }), ) + .when(supports_terminal, |menu| { + menu.item( + ContextMenuEntry::new("Terminal") + .icon(IconName::Terminal) + .icon_color(Color::Muted) + .handler({ + let workspace = workspace.clone(); + move |window, cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = + workspace.panel::(cx) + { + panel.update(cx, |panel, cx| { + panel.new_terminal( + Some(workspace), + window, + cx, + ); + }); + } + }); + } + } + }), + ) + }) .map(|mut menu| { let agent_server_store = agent_server_store.read(cx); let registry_store = project::AgentRegistryStore::try_global(cx); @@ -3080,7 +3660,11 @@ impl AgentPanel { let has_custom_icon = selected_agent_custom_icon.is_some(); let selected_agent_custom_icon_for_button = selected_agent_custom_icon.clone(); - let selected_agent_builtin_icon = self.selected_agent.icon(); + let selected_agent_builtin_icon = if showing_terminal { + Some(IconName::Terminal) + } else { + self.selected_agent.icon() + }; let selected_agent_label_for_tooltip = selected_agent_label.clone(); let selected_agent = div() @@ -3120,7 +3704,8 @@ impl AgentPanel { selected_agent.into_any_element() }; - let is_empty_state = !self.active_thread_has_messages(cx); + let is_empty_state = !matches!(self.base_view, BaseView::Terminal { .. }) + && !self.active_thread_has_messages(cx); let is_in_history_or_config = self.is_overlay_open(); @@ -3296,7 +3881,7 @@ impl AgentPanel { return false; } } - BaseView::Uninitialized => { + BaseView::Terminal { .. } | BaseView::Uninitialized => { return false; } } @@ -3348,7 +3933,7 @@ impl AgentPanel { }); match &self.base_view { - BaseView::Uninitialized => false, + BaseView::Uninitialized | BaseView::Terminal { .. } => false, BaseView::AgentThread { conversation_view } => { if conversation_view.read(cx).as_native_thread(cx).is_some() { let history_is_empty = ThreadStore::global(cx).read(cx).is_empty(); @@ -3477,16 +4062,18 @@ impl AgentPanel { conversation_view.insert_dragged_files(paths, added_worktrees, window, cx); }); } - BaseView::Uninitialized => {} + BaseView::Terminal { .. } | BaseView::Uninitialized => {} } } fn key_context(&self) -> KeyContext { let mut key_context = KeyContext::new_with_defaults(); key_context.add("AgentPanel"); - match &self.base_view { - BaseView::AgentThread { .. } => key_context.add("acp_thread"), - BaseView::Uninitialized => {} + match self.visible_surface() { + VisibleSurface::AgentThread(_) => key_context.add("acp_thread"), + VisibleSurface::Terminal(_) + | VisibleSurface::Configuration(_) + | VisibleSurface::Uninitialized => {} } key_context } @@ -3536,6 +4123,7 @@ impl Render for AgentPanel { VisibleSurface::AgentThread(conversation_view) => parent .child(conversation_view.clone()) .child(self.render_drag_target(cx)), + VisibleSurface::Terminal(terminal_view) => parent.child(terminal_view.clone()), VisibleSurface::Configuration(configuration) => { parent.children(configuration.cloned()) } @@ -3715,6 +4303,61 @@ impl AgentPanel { self.draft_thread = Some(thread.conversation_view.clone()); self.set_base_view(thread.into(), true, window, cx); } + + #[cfg(any(test, feature = "test-support"))] + pub fn insert_test_terminal( + &mut self, + title: impl Into, + focus: bool, + window: &mut Window, + cx: &mut Context, + ) -> Result { + if !cx.has_flag::() { + anyhow::bail!("agent-panel-terminal feature flag must be enabled"); + } + + let terminal_id = TerminalId::new(); + let settings = TerminalSettings::get_global(cx).clone(); + let path_style = self.project.read(cx).path_style(cx); + let builder = terminal::TerminalBuilder::new_display_only( + settings.cursor_shape, + settings.alternate_scroll, + settings.max_scroll_history_lines, + cx.entity_id().as_u64(), + cx.background_executor(), + path_style, + )?; + let terminal = cx.new(|cx| builder.subscribe(cx)); + let terminal_view = cx.new(|cx| { + TerminalView::new( + terminal, + self.workspace.clone(), + self.workspace_id, + self.project.downgrade(), + window, + cx, + ) + }); + terminal_view.update(cx, |terminal_view, cx| { + terminal_view.set_custom_title(Some(title.into()), cx); + }); + self.insert_terminal(terminal_id, terminal_view, focus, window, cx); + Ok(terminal_id) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn emit_test_terminal_bell(&mut self, terminal_id: TerminalId, cx: &mut Context) { + let Some(terminal_entity) = self + .terminals + .get(&terminal_id) + .map(|terminal| terminal.view.read(cx).terminal().clone()) + else { + return; + }; + terminal_entity.update(cx, |_terminal, cx| { + cx.emit(TerminalEvent::Bell); + }); + } } #[cfg(test)] @@ -4689,6 +5332,7 @@ mod tests { }); let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); let project = Project::test(fs.clone(), [], cx).await; let multi_workspace = @@ -4707,6 +5351,98 @@ mod tests { (panel, cx) } + #[gpui::test] + async fn test_terminal_entry_kind_controls_new_entry(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + cx.update(|_, cx| { + cx.update_flags(true, vec!["agent-panel-terminal".to_string()]); + }); + + panel.read_with(&cx, |panel, cx| { + assert!(panel.supports_terminal(cx)); + assert!(!panel.should_create_terminal_for_new_entry(cx)); + }); + + let terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + assert_eq!(panel.active_terminal_id(), Some(terminal_id)); + assert!(panel.has_terminal(terminal_id)); + assert!(panel.should_create_terminal_for_new_entry(cx)); + let terminals = panel.terminals(cx); + assert_eq!(terminals.len(), 1); + assert_eq!(terminals[0].title.as_ref(), "Dev Server"); + }); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.activate_new_thread(false, "test", window, cx); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + assert_eq!(panel.active_terminal_id(), None); + assert!(panel.has_terminal(terminal_id)); + assert!(!panel.should_create_terminal_for_new_entry(cx)); + }); + } + + #[gpui::test] + async fn test_terminal_bell_marks_and_activation_clears_notification(cx: &mut TestAppContext) { + let (panel, mut cx) = setup_panel(cx).await; + cx.update(|_, cx| { + cx.update_flags(true, vec!["agent-panel-terminal".to_string()]); + }); + + let first_terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Build", true, window, cx) + }) + .expect("first test terminal should be inserted"); + let second_terminal_id = panel + .update_in(&mut cx, |panel, window, cx| { + panel.insert_test_terminal("Server", true, window, cx) + }) + .expect("second test terminal should be inserted"); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, _cx| { + assert_eq!(panel.active_terminal_id(), Some(second_terminal_id)); + }); + + panel.update(&mut cx, |panel, cx| { + panel.emit_test_terminal_bell(first_terminal_id, cx); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + let first_terminal = panel + .terminals(cx) + .into_iter() + .find(|terminal| terminal.id == first_terminal_id) + .expect("first terminal should remain in the panel"); + assert!(first_terminal.has_notification); + }); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.activate_terminal(first_terminal_id, true, window, cx); + }); + cx.run_until_parked(); + + panel.read_with(&cx, |panel, cx| { + let first_terminal = panel + .terminals(cx) + .into_iter() + .find(|terminal| terminal.id == first_terminal_id) + .expect("first terminal should remain in the panel"); + assert!(!first_terminal.has_notification); + }); + } + #[gpui::test] async fn test_running_thread_retained_when_navigating_away(cx: &mut TestAppContext) { let (panel, mut cx) = setup_panel(cx).await; diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 226471fc0242942f577b0f9de8bb3feb7adeed1e..758622411935a9ce1201320d0595adb9a8eaea0d 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -61,7 +61,9 @@ use workspace::Workspace; use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal}; pub use crate::agent_connection_store::{ActiveAcpConnection, AgentConnectionStore}; -pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, MaxIdleRetainedThreads}; +pub use crate::agent_panel::{ + AgentPanel, AgentPanelEvent, AgentPanelTerminalInfo, MaxIdleRetainedThreads, TerminalId, +}; use crate::agent_registry_ui::AgentRegistryPage; pub use crate::inline_assistant::InlineAssistant; pub use crate::thread_metadata_store::ThreadId; diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index 00cc74a9b87db451c5af3521644294755f6a26b3..773507e2af1d5986a1f22b25ac61ac62992f2a21 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -2708,10 +2708,10 @@ impl ConversationView { &panel, window, move |this, _, event: &AgentPanelEvent, window, cx| match event { - AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ThreadFocused => { + AgentPanelEvent::ActiveViewChanged | AgentPanelEvent::ActiveViewFocused => { dismiss_if_visible(this, window, cx); } - AgentPanelEvent::RetainedThreadChanged + AgentPanelEvent::EntryChanged | AgentPanelEvent::ThreadInteracted { .. } => {} }, )); diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index d9af542efeabec8d535a3a3502a115fadd9e89cb..cb21626737625741746eabf22bffe8b902ab35b3 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -35,6 +35,18 @@ impl FeatureFlag for AgentSharingFeatureFlag { } register_feature_flag!(AgentSharingFeatureFlag); +pub struct AgentPanelTerminalFeatureFlag; + +impl FeatureFlag for AgentPanelTerminalFeatureFlag { + const NAME: &'static str = "agent-panel-terminal"; + type Value = PresenceFlag; + + fn enabled_for_staff() -> bool { + false + } +} +register_feature_flag!(AgentPanelTerminalFeatureFlag); + pub struct DiffReviewFeatureFlag; impl FeatureFlag for DiffReviewFeatureFlag { diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index ac34cbdd0610c20884bfe7c91811419a605887dc..dde7d4f1b3907f72bb3e0161a5db76cbe49e494e 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -2264,14 +2264,7 @@ impl Project { #[inline] pub fn supports_terminal(&self, _cx: &App) -> bool { - if self.is_local() { - return true; - } - if self.is_via_remote_server() { - return true; - } - - false + self.is_local() || self.is_via_remote_server() } #[inline] diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index be525a5c6e5802c4c7e5b6dae2d21f61b4a8815a..f9ae2ed5241d964cd9ac2dd979009ae431dc1ea5 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -29,6 +29,7 @@ feature_flags.workspace = true fs.workspace = true git.workspace = true gpui.workspace = true +itertools.workspace = true log.workspace = true menu.workspace = true platform_title_bar.workspace = true diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 0000aac3f36026b57ac32f74e81709e58e081664..a25fbac1513ae2d546af066ef0288660c205b1db 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -12,20 +12,23 @@ use agent_ui::threads_archive_view::{ ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp, }; use agent_ui::{ - AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, ArchiveSelectedThread, - CrossChannelImportOnboarding, DEFAULT_THREAD_TITLE, NewThread, ThreadId, ThreadImportModal, - channels_with_threads, import_threads_from_other_channels, + AcpThreadImportOnboarding, Agent, AgentPanel, AgentPanelEvent, AgentPanelTerminalInfo, + ArchiveSelectedThread, CrossChannelImportOnboarding, DEFAULT_THREAD_TITLE, NewThread, + TerminalId, ThreadId, ThreadImportModal, channels_with_threads, + import_threads_from_other_channels, }; use chrono::{DateTime, Utc}; use editor::Editor; use feature_flags::{ - AgentThreadWorktreeLabel, AgentThreadWorktreeLabelFlag, FeatureFlag, FeatureFlagAppExt as _, + AgentPanelTerminalFeatureFlag, AgentThreadWorktreeLabel, AgentThreadWorktreeLabelFlag, + FeatureFlag, FeatureFlagAppExt as _, FeatureFlagViewExt as _, }; use gpui::{ Action as _, AnyElement, App, ClickEvent, Context, DismissEvent, Entity, EntityId, FocusHandle, Focusable, KeyContext, ListState, Modifiers, Pixels, Render, SharedString, Task, TaskExt, WeakEntity, Window, WindowHandle, linear_color_stop, linear_gradient, list, prelude::*, px, }; +use itertools::Itertools; use menu::{ Cancel, Confirm, SelectChild, SelectFirst, SelectLast, SelectNext, SelectParent, SelectPrevious, }; @@ -118,34 +121,57 @@ enum ArchiveWorktreeOutcome { } #[derive(Clone, Debug)] -struct ActiveEntry { - thread_id: agent_ui::ThreadId, - /// Stable remote identifier, used for matching when thread_id - /// differs (e.g. after cross-window activation creates a new - /// local ThreadId). - session_id: Option, - workspace: Entity, +enum ActiveEntry { + Thread { + thread_id: agent_ui::ThreadId, + /// Stable remote identifier, used for matching when thread_id + /// differs (e.g. after cross-window activation creates a new + /// local ThreadId). + session_id: Option, + workspace: Entity, + }, + Terminal { + terminal_id: TerminalId, + workspace: Entity, + }, } impl ActiveEntry { fn workspace(&self) -> &Entity { - &self.workspace + match self { + ActiveEntry::Thread { workspace, .. } | ActiveEntry::Terminal { workspace, .. } => { + workspace + } + } } fn is_active_thread(&self, thread_id: &agent_ui::ThreadId) -> bool { - self.thread_id == *thread_id + matches!(self, ActiveEntry::Thread { thread_id: active_thread_id, .. } if active_thread_id == thread_id) + } + + fn is_active_terminal(&self, terminal_id: TerminalId) -> bool { + matches!(self, ActiveEntry::Terminal { terminal_id: active_terminal_id, .. } if *active_terminal_id == terminal_id) } fn matches_entry(&self, entry: &ListEntry) -> bool { - match entry { - ListEntry::Thread(thread) => { - self.thread_id == thread.metadata.thread_id - || self - .session_id + match (self, entry) { + ( + ActiveEntry::Thread { + thread_id, + session_id, + .. + }, + ListEntry::Thread(thread), + ) => { + *thread_id == thread.metadata.thread_id + || session_id .as_ref() .zip(thread.metadata.session_id.as_ref()) .is_some_and(|(a, b)| a == b) } + (ActiveEntry::Terminal { terminal_id, .. }, ListEntry::Terminal(terminal)) => { + *terminal_id == terminal.id + } _ => false, } } @@ -202,6 +228,16 @@ struct ThreadEntry { diff_stats: DiffStats, } +#[derive(Clone)] +struct TerminalEntry { + id: TerminalId, + title: SharedString, + workspace: Entity, + created_at: DateTime, + has_notification: bool, + highlight_positions: Vec, +} + impl ThreadEntry { /// Updates this thread entry with active thread information. /// @@ -232,6 +268,59 @@ enum ListEntry { has_threads: bool, }, Thread(ThreadEntry), + Terminal(TerminalEntry), +} + +#[derive(Clone)] +enum ActivatableEntry { + Thread { + metadata: ThreadMetadata, + workspace: ThreadEntryWorkspace, + }, + Terminal { + terminal_id: TerminalId, + workspace: Entity, + }, +} + +impl ActivatableEntry { + fn from_list_entry(entry: &ListEntry) -> Option { + match entry { + ListEntry::Thread(thread) => Some(Self::Thread { + metadata: thread.metadata.clone(), + workspace: thread.workspace.clone(), + }), + ListEntry::Terminal(terminal) => Some(Self::Terminal { + terminal_id: terminal.id, + workspace: terminal.workspace.clone(), + }), + ListEntry::ProjectHeader { .. } => None, + } + } + + fn project_location(&self, cx: &App) -> (PathList, ProjectGroupKey) { + match self { + Self::Thread { + workspace: ThreadEntryWorkspace::Open(workspace), + .. + } => ( + PathList::new(&workspace.read(cx).root_paths(cx)), + workspace.read(cx).project_group_key(cx), + ), + Self::Thread { + workspace: + ThreadEntryWorkspace::Closed { + folder_paths, + project_group_key, + }, + .. + } => (folder_paths.clone(), project_group_key.clone()), + Self::Terminal { workspace, .. } => ( + PathList::new(&workspace.read(cx).root_paths(cx)), + workspace.read(cx).project_group_key(cx), + ), + } + } } #[cfg(test)] @@ -239,7 +328,7 @@ impl ListEntry { fn session_id(&self) -> Option<&acp::SessionId> { match self { ListEntry::Thread(thread_entry) => thread_entry.metadata.session_id.as_ref(), - _ => None, + ListEntry::Terminal(_) | ListEntry::ProjectHeader { .. } => None, } } @@ -253,6 +342,7 @@ impl ListEntry { ThreadEntryWorkspace::Open(ws) => vec![ws.clone()], ThreadEntryWorkspace::Closed { .. } => Vec::new(), }, + ListEntry::Terminal(terminal) => vec![terminal.workspace.clone()], ListEntry::ProjectHeader { key, .. } => multi_workspace .workspaces_for_project_group(key, cx) .unwrap_or_default(), @@ -266,10 +356,17 @@ impl From for ListEntry { } } +impl From for ListEntry { + fn from(terminal: TerminalEntry) -> Self { + ListEntry::Terminal(terminal) + } +} + #[derive(Default)] struct SidebarContents { entries: Vec, notified_threads: HashSet, + notified_terminals: HashSet, project_header_indices: Vec, has_open_projects: bool, } @@ -328,6 +425,25 @@ fn workspace_path_list(workspace: &Entity, cx: &App) -> PathList { PathList::new(&workspace.read(cx).root_paths(cx)) } +fn workspace_has_agent_panel_terminals(workspace: &Entity, cx: &App) -> bool { + workspace + .read(cx) + .panel::(cx) + .is_some_and(|panel| !panel.read(cx).terminals(cx).is_empty()) +} + +fn workspace_contains_worktree_path( + workspace: &Entity, + worktree_path: &Path, + cx: &App, +) -> bool { + let project = workspace.read(cx).project().clone(); + project + .read(cx) + .visible_worktrees(cx) + .any(|worktree| worktree.read(cx).abs_path().as_ref() == worktree_path) +} + #[derive(Clone)] struct WorkspaceMenuWorktreeLabel { icon: Option, @@ -505,6 +621,17 @@ impl Sidebar { .detach(); AgentThreadWorktreeLabelFlag::watch(cx); + cx.observe_flag::( + window, + |enabled, this, _window, cx| { + if !*enabled && matches!(this.active_entry, Some(ActiveEntry::Terminal { .. })) { + this.active_entry = None; + } + this.sync_active_entry_from_active_workspace(cx); + this.update_entries(cx); + }, + ) + .detach(); let filter_editor = cx.new(|cx| { let mut editor = Editor::single_line(window, cx); @@ -749,13 +876,11 @@ impl Sidebar { cx.subscribe_in( agent_panel, window, - |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event { - AgentPanelEvent::ActiveViewChanged => { - this.sync_active_entry_from_panel(_agent_panel, cx); - this.update_entries(cx); - } - AgentPanelEvent::ThreadFocused | AgentPanelEvent::RetainedThreadChanged => { - this.sync_active_entry_from_panel(_agent_panel, cx); + |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event { + AgentPanelEvent::ActiveViewChanged + | AgentPanelEvent::ActiveViewFocused + | AgentPanelEvent::EntryChanged => { + this.sync_active_entry_from_panel(agent_panel, cx); this.update_entries(cx); } AgentPanelEvent::ThreadInteracted { thread_id } => { @@ -830,7 +955,7 @@ impl Sidebar { let session_id = panel .active_agent_thread(cx) .map(|thread| thread.read(cx).session_id().clone()); - self.active_entry = Some(ActiveEntry { + self.active_entry = Some(ActiveEntry::Thread { thread_id: pending_thread_id, session_id, workspace: active_workspace, @@ -842,7 +967,14 @@ impl Sidebar { return false; } - if let Some(thread_id) = panel.active_thread_id(cx) { + if cx.has_flag::() + && let Some(terminal_id) = panel.active_terminal_id() + { + self.active_entry = Some(ActiveEntry::Terminal { + terminal_id, + workspace: active_workspace, + }); + } else if let Some(thread_id) = panel.active_thread_id(cx) { let is_archived = ThreadMetadataStore::global(cx) .read(cx) .entry(thread_id) @@ -851,7 +983,7 @@ impl Sidebar { let session_id = panel .active_agent_thread(cx) .map(|thread| thread.read(cx).session_id().clone()); - self.active_entry = Some(ActiveEntry { + self.active_entry = Some(ActiveEntry::Thread { thread_id, session_id, workspace: active_workspace, @@ -925,7 +1057,7 @@ impl Sidebar { .detach_and_log_err(cx); } - fn open_workspace_and_create_draft( + fn open_workspace_and_create_entry( &mut self, project_group_key: &ProjectGroupKey, window: &mut Window, @@ -957,7 +1089,7 @@ impl Sidebar { cx.spawn_in(window, async move |this, cx| { let workspace = task.await?; this.update_in(cx, |this, window, cx| { - this.create_new_thread(&workspace, window, cx); + this.create_new_entry(&workspace, window, cx); })?; anyhow::Ok(()) }) @@ -1009,6 +1141,7 @@ impl Sidebar { let mut entries = Vec::new(); let mut notified_threads = previous.notified_threads; + 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 project_header_indices: Vec = Vec::new(); @@ -1072,6 +1205,15 @@ impl Sidebar { for group in &groups { let group_key = &group.key; let group_workspaces = &group.workspaces; + let terminals: Vec = group_workspaces + .iter() + .flat_map(|workspace| terminal_entries_for_workspace(workspace, cx)) + .collect(); + notified_terminals.extend( + terminals + .iter() + .filter_map(|terminal| terminal.has_notification.then_some(terminal.id)), + ); if group_key.path_list().paths().is_empty() { continue; } @@ -1297,7 +1439,7 @@ impl Sidebar { } } - let has_threads = if !threads.is_empty() { + let has_threads = if !threads.is_empty() || !terminals.is_empty() { true } else { let store = ThreadMetadataStore::global(cx).read(cx); @@ -1344,7 +1486,20 @@ impl Sidebar { } } - if matched_threads.is_empty() && !workspace_matched { + 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.title) { + terminal.highlight_positions = positions; + terminal_matched = true; + } + if workspace_matched || terminal_matched { + matched_terminals.push(terminal); + } + } + + if matched_threads.is_empty() && matched_terminals.is_empty() && !workspace_matched + { continue; } @@ -1359,13 +1514,13 @@ impl Sidebar { has_threads, }); - for thread in matched_threads { - if let Some(sid) = thread.metadata.session_id.clone() { - current_session_ids.insert(sid); - } - current_thread_ids.insert(thread.metadata.thread_id); - entries.push(thread.into()); - } + Self::push_entries_by_display_time( + &mut entries, + matched_terminals, + matched_threads, + &mut current_session_ids, + &mut current_thread_ids, + ); } else { project_header_indices.push(entries.len()); entries.push(ListEntry::ProjectHeader { @@ -1382,13 +1537,13 @@ impl Sidebar { continue; } - for thread in threads { - if let Some(sid) = &thread.metadata.session_id { - current_session_ids.insert(sid.clone()); - } - current_thread_ids.insert(thread.metadata.thread_id); - entries.push(thread.into()); - } + Self::push_entries_by_display_time( + &mut entries, + terminals, + threads, + &mut current_session_ids, + &mut current_thread_ids, + ); } } @@ -1400,6 +1555,7 @@ impl Sidebar { self.contents = SidebarContents { entries, notified_threads, + notified_terminals, project_header_indices, has_open_projects, }; @@ -1436,7 +1592,7 @@ impl Sidebar { .contents .entries .iter() - .position(|entry| matches!(entry, ListEntry::Thread(_))) + .position(|entry| matches!(entry, ListEntry::Thread(_) | ListEntry::Terminal(_))) .or_else(|| { if self.contents.entries.is_empty() { None @@ -1483,8 +1639,10 @@ impl Sidebar { .and_then(|ws| ws.read(cx).panel::(cx)) .is_some_and(|panel| { let panel = panel.read(cx); - panel.active_thread_is_draft(cx) - || panel.active_conversation_view().is_none() + // An active terminal is its own surface, not a draft. + panel.active_terminal_id().is_none() + && (panel.active_thread_is_draft(cx) + || panel.active_conversation_view().is_none()) }); self.project_header_menu_handles.entry(ix).or_default(); self.render_project_header( @@ -1503,6 +1661,9 @@ impl Sidebar { ) } ListEntry::Thread(thread) => self.render_thread(ix, thread, is_active, is_selected, cx), + ListEntry::Terminal(terminal) => { + self.render_terminal(ix, terminal, is_active, is_selected, cx) + } }; if is_group_header_after_first { @@ -1705,9 +1866,9 @@ impl Sidebar { this.set_group_expanded(&key, true, cx); this.selection = None; if let Some(workspace) = this.workspace_for_group(&key, cx) { - this.create_new_thread(&workspace, window, cx); + this.create_new_entry(&workspace, window, cx); } else { - this.open_workspace_and_create_draft(&key, window, cx); + this.open_workspace_and_create_entry(&key, window, cx); } }, )) @@ -2127,7 +2288,10 @@ impl Sidebar { .and_then(|ws| ws.read(cx).panel::(cx)) .is_some_and(|panel| { let panel = panel.read(cx); - panel.active_thread_is_draft(cx) || panel.active_conversation_view().is_none() + // An active terminal is its own surface, not a draft. + panel.active_terminal_id().is_none() + && (panel.active_thread_is_draft(cx) + || panel.active_conversation_view().is_none()) }); let header_element = self.render_project_header( header_idx, @@ -2391,6 +2555,10 @@ impl Sidebar { } } } + ListEntry::Terminal(terminal) => { + let workspace = terminal.workspace.clone(); + self.activate_terminal(&workspace, terminal.id, false, window, cx); + } } } @@ -2514,7 +2682,7 @@ impl Sidebar { // Set active_entry eagerly so the sidebar highlight updates // immediately, rather than waiting for a deferred AgentPanel // event which can race with ActiveWorkspaceChanged clearing it. - self.active_entry = Some(ActiveEntry { + self.active_entry = Some(ActiveEntry::Thread { thread_id: metadata.thread_id, session_id: metadata.session_id.clone(), workspace: workspace.clone(), @@ -2583,7 +2751,7 @@ impl Sidebar { { target_sidebar.update(cx, |sidebar, cx| { sidebar.pending_thread_activation = Some(metadata_thread_id); - sidebar.active_entry = Some(ActiveEntry { + sidebar.active_entry = Some(ActiveEntry::Thread { thread_id: metadata_thread_id, session_id: target_session_id.clone(), workspace: workspace_for_entry.clone(), @@ -2957,7 +3125,7 @@ impl Sidebar { self.update_entries(cx); } } - Some(ListEntry::Thread(_)) => { + Some(ListEntry::Thread(_) | ListEntry::Terminal(_)) => { for i in (0..ix).rev() { if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(i) { @@ -2984,7 +3152,7 @@ impl Sidebar { // Find the group header for the current selection. let header_ix = match self.contents.entries.get(ix) { Some(ListEntry::ProjectHeader { .. }) => Some(ix), - Some(ListEntry::Thread(_)) => (0..ix).rev().find(|&i| { + Some(ListEntry::Thread(_) | ListEntry::Terminal(_)) => (0..ix).rev().find(|&i| { matches!( self.contents.entries.get(i), Some(ListEntry::ProjectHeader { .. }) @@ -3053,6 +3221,148 @@ impl Sidebar { } } + /// Find the neighbor thread in the sidebar (by display position). + /// Look below first, then above, for the nearest thread that isn't + /// the one being archived. We capture both the neighbor's metadata + /// (for activation) and its workspace paths (for the workspace + /// removal fallback). + fn neighboring_activatable_entry(&self, current_position: usize) -> Option { + let after = self + .contents + .entries + .get(current_position.checked_add(1)?..)?; + let before = self.contents.entries.get(..current_position)?; + after + .iter() + .chain(before.iter().rev()) + .find_map(ActivatableEntry::from_list_entry) + } + + fn activate_entry( + &mut self, + entry: &ActivatableEntry, + window: &mut Window, + cx: &mut Context, + ) -> bool { + match entry { + ActivatableEntry::Thread { metadata, .. } => { + let Some(workspace) = self.multi_workspace.upgrade().and_then(|multi_workspace| { + multi_workspace + .read(cx) + .workspace_for_paths(metadata.folder_paths(), None, cx) + }) else { + return false; + }; + + self.active_entry = Some(ActiveEntry::Thread { + thread_id: metadata.thread_id, + session_id: metadata.session_id.clone(), + workspace: workspace.clone(), + }); + self.activate_workspace(&workspace, window, cx); + Self::load_agent_thread_in_workspace(&workspace, metadata, true, window, cx); + true + } + ActivatableEntry::Terminal { + terminal_id, + workspace, + } => { + if !cx.has_flag::() { + return false; + } + let Some(workspace) = self + .find_workspace_in_current_window(cx, |candidate, _| candidate == workspace) + else { + return false; + }; + self.activate_terminal(&workspace, *terminal_id, false, window, cx); + true + } + } + } + + fn activate_terminal( + &mut self, + workspace: &Entity, + terminal_id: TerminalId, + retain: bool, + window: &mut Window, + cx: &mut Context, + ) { + if !cx.has_flag::() { + return; + } + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + + self.active_entry = Some(ActiveEntry::Terminal { + terminal_id, + workspace: workspace.clone(), + }); + + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate(workspace.clone(), None, window, cx); + if retain { + multi_workspace.retain_active_workspace(cx); + } + }); + + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.activate_terminal(terminal_id, true, window, cx); + }); + } + workspace.focus_panel::(window, cx); + }); + + self.update_entries(cx); + } + + fn close_terminal( + &mut self, + workspace: &Entity, + terminal_id: TerminalId, + window: &mut Window, + cx: &mut Context, + ) { + let is_active = self + .active_entry + .as_ref() + .is_some_and(|entry| entry.is_active_terminal(terminal_id)); + let neighbor = self + .contents + .entries + .iter() + .position(|entry| matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id)) + .and_then(|position| { + self.neighboring_activatable_entry(position) + }); + + // Closing from the sidebar must not steal focus, since the row's + // workspace may not be the active workspace. + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.close_terminal(terminal_id, window, cx); + }); + } + }); + + if is_active { + self.active_entry = None; + if neighbor + .as_ref() + .is_some_and(|neighbor| self.activate_entry(neighbor, window, cx)) + { + return; + } + self.sync_active_entry_from_active_workspace(cx); + } + self.update_entries(cx); + } + fn archive_thread( &mut self, session_id: &acp::SessionId, @@ -3064,7 +3374,7 @@ impl Sidebar { let active_workspace = metadata.as_ref().and_then(|metadata| { self.active_entry.as_ref().and_then(|entry| { if entry.is_active_thread(&metadata.thread_id) { - Some(entry.workspace.clone()) + Some(entry.workspace().clone()) } else { None } @@ -3126,15 +3436,20 @@ impl Sidebar { ) }) }) + .filter(|root| { + !workspaces.iter().any(|workspace| { + workspace_has_agent_panel_terminals(workspace, cx) + && workspace_contains_worktree_path( + workspace, + root.root_path.as_path(), + cx, + ) + }) + }) .collect::>() }) .unwrap_or_default(); - // Find the neighbor thread in the sidebar (by display position). - // Look below first, then above, for the nearest thread that isn't - // the one being archived. We capture both the neighbor's metadata - // (for activation) and its workspace paths (for the workspace - // removal fallback). let current_pos = self.contents.entries.iter().position(|entry| match entry { ListEntry::Thread(thread) => thread_id.map_or_else( || thread.metadata.session_id.as_ref() == Some(session_id), @@ -3142,27 +3457,8 @@ impl Sidebar { ), _ => false, }); - let neighbor = current_pos.and_then(|pos| { - self.contents.entries[pos + 1..] - .iter() - .chain(self.contents.entries[..pos].iter().rev()) - .find_map(|entry| match entry { - ListEntry::Thread(t) if t.metadata.session_id.as_ref() != Some(session_id) => { - let (workspace_paths, project_group_key) = match &t.workspace { - ThreadEntryWorkspace::Open(ws) => ( - PathList::new(&ws.read(cx).root_paths(cx)), - ws.read(cx).project_group_key(cx), - ), - ThreadEntryWorkspace::Closed { - folder_paths, - project_group_key, - } => (folder_paths.clone(), project_group_key.clone()), - }; - Some((t.metadata.clone(), workspace_paths, project_group_key)) - } - _ => None, - }) - }); + let neighbor = + current_pos.and_then(|position| self.neighboring_activatable_entry(position)); // Check if archiving this thread would leave its worktree workspace // with no threads, requiring workspace removal. @@ -3188,6 +3484,10 @@ impl Sidebar { .read(cx) .workspace_for_paths(folder_paths, None, cx)?; + if workspace_has_agent_panel_terminals(&workspace, cx) { + return None; + } + let group_key = workspace.read(cx).project_group_key(cx); let is_linked_worktree = group_key.path_list() != folder_paths; @@ -3278,12 +3578,12 @@ impl Sidebar { let (fallback_paths, project_group_key) = neighbor .as_ref() - .map(|(_, paths, project_group_key)| (paths.clone(), project_group_key.clone())) + .map(|neighbor| neighbor.project_location(cx)) .unwrap_or_else(|| { workspaces_to_remove .first() - .map(|ws| { - let key = ws.read(cx).project_group_key(cx); + .map(|workspace| { + let key = workspace.read(cx).project_group_key(cx); (key.path_list().clone(), key) }) .unwrap_or_default() @@ -3314,7 +3614,6 @@ impl Sidebar { ) }); - let neighbor_metadata = neighbor.map(|(metadata, _, _)| metadata); let thread_folder_paths = thread_folder_paths.clone(); cx.spawn_in(window, async move |this, cx| { if !remove_task.await? { @@ -3333,7 +3632,7 @@ impl Sidebar { this.archive_and_activate( &session_id, thread_id, - neighbor_metadata.as_ref(), + neighbor.as_ref(), thread_folder_paths.as_ref(), in_flight, window, @@ -3345,7 +3644,6 @@ impl Sidebar { .detach_and_log_err(cx); } else if !close_item_tasks.is_empty() { let session_id = session_id.clone(); - let neighbor_metadata = neighbor.map(|(metadata, _, _)| metadata); let thread_folder_paths = thread_folder_paths.clone(); cx.spawn_in(window, async move |this, cx| { for task in close_item_tasks { @@ -3360,7 +3658,7 @@ impl Sidebar { this.archive_and_activate( &session_id, thread_id, - neighbor_metadata.as_ref(), + neighbor.as_ref(), thread_folder_paths.as_ref(), in_flight, window, @@ -3371,13 +3669,12 @@ impl Sidebar { }) .detach_and_log_err(cx); } else { - let neighbor_metadata = neighbor.map(|(metadata, _, _)| metadata); let in_flight = thread_id .and_then(|tid| self.start_archive_worktree_task(tid, roots_to_archive, cx)); self.archive_and_activate( session_id, thread_id, - neighbor_metadata.as_ref(), + neighbor.as_ref(), thread_folder_paths.as_ref(), in_flight, window, @@ -3406,7 +3703,7 @@ impl Sidebar { &mut self, _session_id: &acp::SessionId, thread_id: Option, - neighbor: Option<&ThreadMetadata>, + neighbor: Option<&ActivatableEntry>, thread_folder_paths: Option<&PathList>, in_flight_archive: Option<(Task<()>, async_channel::Sender<()>)>, window: &mut Window, @@ -3456,25 +3753,8 @@ impl Sidebar { return; } - // Try to activate the neighbor thread. If its workspace is open, - // tell the panel to load it and activate that workspace. - // `rebuild_contents` will reconcile `active_entry` once the thread - // finishes loading. - - if let Some(metadata) = neighbor { - if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| { - mw.read(cx) - .workspace_for_paths(metadata.folder_paths(), None, cx) - }) { - self.active_entry = Some(ActiveEntry { - thread_id: metadata.thread_id, - session_id: metadata.session_id.clone(), - workspace: workspace.clone(), - }); - self.activate_workspace(&workspace, window, cx); - Self::load_agent_thread_in_workspace(&workspace, metadata, true, window, cx); - return; - } + if neighbor.is_some_and(|neighbor| self.activate_entry(neighbor, window, cx)) { + return; } // No neighbor or its workspace isn't open — just clear the @@ -3633,6 +3913,38 @@ impl Sidebar { metadata.interacted_at.unwrap_or(metadata.updated_at) } + fn push_entries_by_display_time( + entries: &mut Vec, + terminals: Vec, + threads: Vec, + current_session_ids: &mut HashSet, + current_thread_ids: &mut HashSet, + ) { + fn display_time(entry: &ListEntry) -> DateTime { + match entry { + ListEntry::Thread(thread) => Sidebar::thread_display_time(&thread.metadata), + ListEntry::Terminal(terminal) => terminal.created_at, + ListEntry::ProjectHeader { .. } => unreachable!(), + } + } + + let row_entries = terminals + .into_iter() + .map(ListEntry::Terminal) + .chain(threads.into_iter().map(ListEntry::Thread)) + .sorted_by_key(|right| std::cmp::Reverse(display_time(right))); + + for entry in row_entries { + if let ListEntry::Thread(thread) = &entry { + if let Some(session_id) = &thread.metadata.session_id { + current_session_ids.insert(session_id.clone()); + } + current_thread_ids.insert(thread.metadata.thread_id); + } + entries.push(entry); + } + } + /// 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| { @@ -3704,6 +4016,7 @@ impl Sidebar { timestamp, }) } + ListEntry::Terminal(_) => None, }) .collect(); @@ -3755,8 +4068,11 @@ impl Sidebar { let weak_multi_workspace = self.multi_workspace.clone(); - let original_metadata = match &self.active_entry { - Some(ActiveEntry { thread_id, .. }) => entries + // Capture the full active entry so dismissal can restore terminal + // entries too, not just threads. + 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()), @@ -3783,7 +4099,7 @@ impl Sidebar { mw.activate(workspace.clone(), None, window, cx); }); } - this.active_entry = Some(ActiveEntry { + this.active_entry = Some(ActiveEntry::Thread { thread_id: metadata.thread_id, session_id: metadata.session_id.clone(), workspace: workspace.clone(), @@ -3804,7 +4120,7 @@ impl Sidebar { }); } this.record_thread_access(&metadata.thread_id); - this.active_entry = Some(ActiveEntry { + this.active_entry = Some(ActiveEntry::Thread { thread_id: metadata.thread_id, session_id: metadata.session_id.clone(), workspace: workspace.clone(), @@ -3821,24 +4137,46 @@ impl Sidebar { }); } } - if let Some(metadata) = &original_metadata { - if let Some(original_ws) = &original_workspace { - this.active_entry = Some(ActiveEntry { - thread_id: metadata.thread_id, - session_id: metadata.session_id.clone(), - workspace: original_ws.clone(), - }); + match &original_active_entry { + Some(ActiveEntry::Thread { .. }) => { + if let (Some(metadata), Some(original_ws)) = + (&original_metadata, &original_workspace) + { + this.active_entry = Some(ActiveEntry::Thread { + thread_id: metadata.thread_id, + session_id: metadata.session_id.clone(), + workspace: original_ws.clone(), + }); + this.update_entries(cx); + Self::load_agent_thread_in_workspace( + original_ws, + metadata, + false, + window, + cx, + ); + } } - this.update_entries(cx); - if let Some(original_ws) = &original_workspace { - Self::load_agent_thread_in_workspace( - original_ws, - metadata, - false, - window, - cx, - ); + Some(ActiveEntry::Terminal { + terminal_id, + workspace, + }) => { + let terminal_id = *terminal_id; + let workspace = workspace.clone(); + this.active_entry = Some(ActiveEntry::Terminal { + terminal_id, + workspace: workspace.clone(), + }); + this.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); + }); + } + }); } + None => {} } this.dismiss_thread_switcher(cx); } @@ -3877,7 +4215,7 @@ impl Sidebar { mw.activate(workspace.clone(), None, window, cx); }); } - self.active_entry = Some(ActiveEntry { + self.active_entry = Some(ActiveEntry::Thread { thread_id: metadata.thread_id, session_id: metadata.session_id.clone(), workspace: workspace.clone(), @@ -4025,6 +4363,61 @@ impl Sidebar { .into_any_element() } + fn render_terminal( + &self, + ix: usize, + terminal: &TerminalEntry, + is_active: bool, + is_focused: bool, + cx: &mut Context, + ) -> AnyElement { + let id = ElementId::from(format!("terminal-{}", terminal.id)); + let timestamp = format_history_entry_timestamp(terminal.created_at); + let is_hovered = self.hovered_thread_index == Some(ix); + let color = cx.theme().colors(); + let sidebar_bg = color + .title_bar_background + .blend(color.panel_background.opacity(0.25)); + let terminal_id = terminal.id; + let workspace = terminal.workspace.clone(); + + ThreadItem::new(id, terminal.title.clone()) + .base_bg(sidebar_bg) + .icon(IconName::Terminal) + .timestamp(timestamp) + .notified(terminal.has_notification) + .highlight_positions(terminal.highlight_positions.clone()) + .selected(is_active) + .focused(is_focused) + .hovered(is_hovered) + .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| { + if *is_hovered { + this.hovered_thread_index = Some(ix); + } else if this.hovered_thread_index == Some(ix) { + this.hovered_thread_index = None; + } + cx.notify(); + })) + .when(is_hovered, |this| { + this.action_slot( + IconButton::new("close-terminal", IconName::Close) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Close Terminal")) + .on_click(cx.listener(move |this, _, window, cx| { + this.close_terminal(&workspace, terminal_id, window, cx); + })), + ) + }) + .on_click(cx.listener({ + let workspace = terminal.workspace.clone(); + move |this, _, window, cx| { + this.activate_terminal(&workspace, terminal_id, false, window, cx); + } + })) + .into_any_element() + } + fn render_filter_input(&self, cx: &mut Context) -> impl IntoElement { div() .min_w_0() @@ -4101,15 +4494,39 @@ impl Sidebar { self.set_group_expanded(&key, true, cx); self.selection = None; if let Some(workspace) = self.workspace_for_group(&key, cx) { - self.create_new_thread(&workspace, window, cx); + self.create_new_entry(&workspace, window, cx); } else { - self.open_workspace_and_create_draft(&key, window, cx); + self.open_workspace_and_create_entry(&key, window, cx); } } else if let Some(workspace) = self.active_workspace(cx) { - self.create_new_thread(&workspace, window, cx); + self.create_new_entry(&workspace, window, cx); + } + } + + fn create_new_entry( + &mut self, + workspace: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + if self.should_create_terminal_for_workspace(workspace, cx) { + self.create_new_terminal(workspace, window, cx); + } else { + self.create_new_thread(workspace, window, cx); } } + fn should_create_terminal_for_workspace( + &self, + workspace: &Entity, + cx: &App, + ) -> bool { + workspace + .read(cx) + .panel::(cx) + .is_some_and(|panel| panel.read(cx).should_create_terminal_for_new_entry(cx)) + } + fn create_new_thread( &mut self, workspace: &Entity, @@ -4127,7 +4544,7 @@ impl Sidebar { let draft_id = workspace.update(cx, |workspace, cx| { let panel = workspace.panel::(cx)?; let draft_id = panel.update(cx, |panel, cx| { - panel.activate_draft(true, "sidebar", window, cx); + panel.activate_new_thread(true, "sidebar", window, cx); panel.active_thread_id(cx) }); workspace.focus_panel::(window, cx); @@ -4135,7 +4552,7 @@ impl Sidebar { }); if let Some(draft_id) = draft_id { - self.active_entry = Some(ActiveEntry { + self.active_entry = Some(ActiveEntry::Thread { thread_id: draft_id, session_id: None, workspace: workspace.clone(), @@ -4143,11 +4560,35 @@ impl Sidebar { } } + fn create_new_terminal( + &mut self, + workspace: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + let Some(multi_workspace) = self.multi_workspace.upgrade() else { + return; + }; + + multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate(workspace.clone(), None, window, cx); + }); + + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.new_terminal(Some(workspace), window, cx); + }); + } + workspace.focus_panel::(window, cx); + }); + } + fn selected_group_key(&self) -> Option { let ix = self.selection?; match self.contents.entries.get(ix) { Some(ListEntry::ProjectHeader { key, .. }) => Some(key.clone()), - Some(ListEntry::Thread(_)) => { + Some(ListEntry::Thread(_) | ListEntry::Terminal(_)) => { (0..ix) .rev() .find_map(|i| match self.contents.entries.get(i) { @@ -4279,7 +4720,7 @@ impl Sidebar { .iter() .enumerate() .filter_map(|(ix, entry)| match entry { - ListEntry::Thread(_) => Some(ix), + ListEntry::Thread(_) | ListEntry::Terminal(_) => Some(ix), _ => None, }) .collect(); @@ -4307,30 +4748,35 @@ impl Sidebar { }; let entry_ix = thread_indices[next_pos]; - let ListEntry::Thread(thread) = &self.contents.entries[entry_ix] else { - return; - }; - - let metadata = thread.metadata.clone(); - match &thread.workspace { - ThreadEntryWorkspace::Open(workspace) => { - let workspace = workspace.clone(); - self.activate_thread(metadata, &workspace, true, window, cx); + match &self.contents.entries[entry_ix] { + ListEntry::Thread(thread) => { + let metadata = thread.metadata.clone(); + match &thread.workspace { + ThreadEntryWorkspace::Open(workspace) => { + let workspace = workspace.clone(); + self.activate_thread(metadata, &workspace, true, window, cx); + } + ThreadEntryWorkspace::Closed { + folder_paths, + project_group_key, + } => { + let folder_paths = folder_paths.clone(); + let project_group_key = project_group_key.clone(); + self.open_workspace_and_activate_thread( + metadata, + folder_paths, + &project_group_key, + window, + cx, + ); + } + } } - ThreadEntryWorkspace::Closed { - folder_paths, - project_group_key, - } => { - let folder_paths = folder_paths.clone(); - let project_group_key = project_group_key.clone(); - self.open_workspace_and_activate_thread( - metadata, - folder_paths, - &project_group_key, - window, - cx, - ); + ListEntry::Terminal(terminal) => { + let workspace = terminal.workspace.clone(); + self.activate_terminal(&workspace, terminal.id, true, window, cx); } + ListEntry::ProjectHeader { .. } => {} } } @@ -4868,7 +5314,7 @@ impl WorkspaceSidebar for Sidebar { } fn has_notifications(&self, _cx: &App) -> bool { - !self.contents.notified_threads.is_empty() + !self.contents.notified_threads.is_empty() || !self.contents.notified_terminals.is_empty() } fn is_threads_list_view_active(&self) -> bool { @@ -5044,6 +5490,33 @@ impl Render for Sidebar { } } +fn terminal_entries_for_workspace( + workspace: &Entity, + cx: &App, +) -> impl Iterator { + if !cx.has_flag::() { + return None.into_iter().flatten(); + } + let Some(agent_panel) = workspace.read(cx).panel::(cx) else { + return None.into_iter().flatten(); + }; + let terminals = + agent_panel + .read(cx) + .terminals(cx) + .into_iter() + .map(|terminal: AgentPanelTerminalInfo| TerminalEntry { + id: terminal.id, + title: terminal.title, + workspace: workspace.clone(), + created_at: terminal.created_at, + has_notification: terminal.has_notification, + highlight_positions: Vec::new(), + }); + + Some(terminals).into_iter().flatten() +} + fn all_thread_infos_for_workspace( workspace: &Entity, cx: &App, diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index 3747a7a4d3940dac1e91aa5f179a34e0ff995520..dc806009a9fc05950e47c0e79307096986bf47af 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -33,13 +33,17 @@ fn init_test(cx: &mut TestAppContext) { }); } +fn enable_agent_panel_terminal(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.update_flags(true, vec!["agent-panel-terminal".to_string()]); + }); +} + #[track_caller] fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &str) { let active = sidebar.active_entry.as_ref(); let matches = active.is_some_and(|entry| { - // Match by session_id directly on active_entry. - entry.session_id.as_ref() == Some(session_id) - // Or match by finding the thread in sidebar entries. + matches!(entry, ActiveEntry::Thread { session_id: Some(active_session_id), .. } if active_session_id == session_id) || sidebar.contents.entries.iter().any(|list_entry| { matches!(list_entry, ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id) @@ -67,7 +71,7 @@ fn is_active_session(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool { }); match thread_id { Some(tid) => { - matches!(&sidebar.active_entry, Some(ActiveEntry { thread_id, .. }) if *thread_id == tid) + matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { thread_id, .. }) if *thread_id == tid) } // Thread not in sidebar entries — can't confirm it's active. None => false, @@ -77,7 +81,7 @@ fn is_active_session(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool { #[track_caller] fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity, msg: &str) { assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == workspace), + matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { workspace: ws, .. }) if ws == workspace), "{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}", workspace.entity_id(), sidebar.active_entry, @@ -147,6 +151,12 @@ fn assert_remote_project_integration_sidebar_state( title ); } + ListEntry::Terminal(terminal) => { + panic!( + "unexpected sidebar terminal while simulating remote project integration flicker: title=`{}`", + terminal.title + ); + } } } @@ -517,6 +527,10 @@ fn visible_entries_as_strings( format!(" {title}{worktree}{live}{status_str}{notified}{selected}") } } + ListEntry::Terminal(terminal) => { + let title = &terminal.title; + format!(" {title}{selected}") + } } }) .collect() @@ -1408,6 +1422,150 @@ fn setup_sidebar_with_agent_panel( (sidebar, panel) } +#[gpui::test] +async fn test_agent_panel_terminals_appear_in_sidebar_and_search(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(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Dev Server"] + ); + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + matches!(&sidebar.active_entry, Some(ActiveEntry::Terminal { terminal_id: active_terminal_id, .. }) if *active_terminal_id == terminal_id), + "expected active terminal entry, got {:?}", + sidebar.active_entry, + ); + assert!( + sidebar.contents.entries.iter().any(|entry| { + matches!(entry, ListEntry::Terminal(terminal) if terminal.id == terminal_id && terminal.title.as_ref() == "Dev Server") + }), + "expected the inserted terminal to appear in sidebar contents", + ); + }); + + type_in_search(&sidebar, "server", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Dev Server <== selected"] + ); + + type_in_search(&sidebar, "missing", cx); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + Vec::::new() + ); +} + +#[gpui::test] +async fn test_agent_panel_terminal_notifications_update_sidebar(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(); + + panel.read_with(cx, |panel, _cx| { + assert_eq!(panel.active_terminal_id(), Some(server_terminal_id)); + }); + + panel.update(cx, |panel, cx| { + panel.emit_test_terminal_bell(build_terminal_id, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, cx| { + assert!(sidebar.has_notifications(cx)); + assert!(sidebar.contents.notified_terminals.contains(&build_terminal_id)); + assert!(sidebar.contents.entries.iter().any(|entry| { + matches!(entry, ListEntry::Terminal(terminal) if terminal.id == build_terminal_id && terminal.has_notification) + })); + }); + + panel.update_in(cx, |panel, window, cx| { + panel.activate_terminal(build_terminal_id, true, window, cx); + }); + cx.run_until_parked(); + + sidebar.read_with(cx, |sidebar, cx| { + assert!(!sidebar.has_notifications(cx)); + assert!( + !sidebar + .contents + .notified_terminals + .contains(&build_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; + 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 workspace = multi_workspace.read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }); + + 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(); + + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.close_terminal(&workspace, server_terminal_id, window, cx); + }); + cx.run_until_parked(); + + panel.read_with(cx, |panel, _cx| { + assert!(!panel.has_terminal(server_terminal_id)); + assert_eq!(panel.active_terminal_id(), Some(build_terminal_id)); + }); + sidebar.read_with(cx, |sidebar, _cx| { + assert!( + matches!(&sidebar.active_entry, Some(ActiveEntry::Terminal { terminal_id, .. }) if *terminal_id == build_terminal_id), + "expected remaining terminal to become active, got {:?}", + sidebar.active_entry, + ); + }); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]", " Build"] + ); +} + #[gpui::test] async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) { let project = init_test_project_with_agent_panel("/my-project", cx).await; @@ -2740,7 +2898,7 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex // because the panel has a thread with messages. sidebar.read_with(cx, |sidebar, _cx| { assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry { .. })), + matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })), "Panel has a thread with messages, so active_entry should be Thread, got {:?}", sidebar.active_entry, ); @@ -2776,7 +2934,7 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex // false — the panel still has the old thread with messages. sidebar.read_with(cx, |sidebar, _cx| { assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry { .. })), + matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })), "After adding a folder the panel still has a thread with messages, \ so active_entry should be Thread, got {:?}", sidebar.active_entry, @@ -3873,6 +4031,12 @@ async fn test_clicking_worktree_thread_does_not_briefly_render_as_separate_proje title, worktree_name ); } + ListEntry::Terminal(terminal) => { + panic!( + "unexpected sidebar terminal while opening linked worktree thread: title=`{}`", + terminal.title + ); + } } } @@ -6241,7 +6405,7 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) { // active_entry should still be a draft on workspace_b (the active one). sidebar.read_with(cx, |sidebar, _| { assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b), + matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { workspace: ws, .. }) if ws == &workspace_b), "expected Draft(workspace_b) after archiving non-active thread, got: {:?}", sidebar.active_entry, ); @@ -6278,7 +6442,7 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) { // sidebar row but active_entry tracks it. sidebar.read_with(cx, |sidebar, _| { assert!( - matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b), + matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { workspace: ws, .. }) if ws == &workspace_b), "expected draft on workspace_b after archiving active thread, got: {:?}", sidebar.active_entry, ); @@ -9773,7 +9937,7 @@ mod property_test { // 3. The entry must match the agent panel's current state. if panel.read(cx).active_thread_id(cx).is_some() { anyhow::ensure!( - matches!(entry, ActiveEntry { .. }), + matches!(entry, ActiveEntry::Thread { .. }), "panel shows a tracked draft but active_entry is {:?}", entry, ); @@ -9783,7 +9947,7 @@ mod property_test { .map(|cv| cv.read(cx).parent_id()) { anyhow::ensure!( - matches!(entry, ActiveEntry { thread_id: tid, .. } if *tid == thread_id), + matches!(entry, ActiveEntry::Thread { thread_id: tid, .. } if *tid == thread_id), "panel has thread {:?} but active_entry is {:?}", thread_id, entry, @@ -9795,8 +9959,11 @@ mod property_test { // a draft, which is represented by the + button's active state // rather than a sidebar row. // TODO: Make this check more complete - let is_draft = panel.read(cx).active_thread_is_draft(cx) - || panel.read(cx).active_conversation_view().is_none(); + // Active terminals must still match a row, so don't treat the absence + // of a conversation view as "draft" when a terminal is active. + let is_draft = panel.read(cx).active_terminal_id().is_none() + && (panel.read(cx).active_thread_is_draft(cx) + || panel.read(cx).active_conversation_view().is_none()); if is_draft { return Ok(()); } diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 07c638c16048c3c5f6707768a7cfb1ce521ea18d..37c4c165836fa9531ec5eb86f0ad35659cb1d851 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -2005,7 +2005,7 @@ impl SearchableItem for TerminalView { /// For remote projects, local-only resolution (home dir fallback, shell expansion, /// local `is_dir` checks) is skipped -- returning `None` lets the remote shell /// open in the remote user's home directory by default. -pub(crate) fn default_working_directory(workspace: &Workspace, cx: &App) -> Option { +pub fn default_working_directory(workspace: &Workspace, cx: &App) -> Option { let is_remote = workspace.project().read(cx).is_remote(); let directory = match &TerminalSettings::get_global(cx).working_directory { WorkingDirectory::CurrentFileDirectory => workspace