sidebar: Experimental Terminal Mode (#56063)

Ben Brandt and Bennet Bo Fenner created

Experiment with allowing users to manage terminal sessions along with
threads in the sidebar.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>

Change summary

Cargo.lock                                |   1 
assets/keymaps/default-linux.json         |   6 
assets/keymaps/default-macos.json         |   7 
assets/keymaps/default-windows.json       |   7 
crates/agent_ui/src/agent_panel.rs        | 711 +++++++++++++++++++++++-
crates/agent_ui/src/agent_ui.rs           |   4 
crates/agent_ui/src/conversation_view.rs  |   4 
crates/feature_flags/src/flags.rs         |  12 
crates/project/src/project.rs             |   9 
crates/sidebar/Cargo.toml                 |   1 
crates/sidebar/src/sidebar.rs             | 604 ++++++++++++++++----
crates/sidebar/src/sidebar_tests.rs       | 193 ++++++
crates/terminal_view/src/terminal_view.rs |   2 
13 files changed, 1,369 insertions(+), 192 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -16329,6 +16329,7 @@ dependencies = [
  "git",
  "gpui",
  "http_client",
+ "itertools 0.14.0",
  "language",
  "language_model",
  "log",

assets/keymaps/default-linux.json 🔗

@@ -1247,6 +1247,12 @@
       "ctrl->": "agent::AddSelectionToThread",
     },
   },
+  {
+    "context": "AgentPanel && Terminal",
+    "bindings": {
+      "ctrl-n": "agent::NewThread",
+    },
+  },
   {
     "context": "ZedPredictModal",
     "bindings": {

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,

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": {

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<Utc>,
+    pub has_notification: bool,
+}
+
 #[derive(Serialize, Deserialize)]
 struct LastUsedAgent {
     agent: Agent,
@@ -152,10 +181,19 @@ fn read_legacy_serialized_panel(kvp: &KeyValueStore) -> Option<SerializedAgentPa
         .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&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<Agent>,
     #[serde(default)]
+    last_created_entry_kind: AgentPanelEntryKind,
+    #[serde(default)]
     last_active_thread: Option<SerializedActiveThread>,
     draft_thread_prompt: Option<Vec<acp::ContentBlock>>,
 }
@@ -172,9 +210,9 @@ pub fn init(cx: &mut App) {
     cx.observe_new(
         |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
             workspace
-                .register_action(|workspace, action: &NewThread, window, cx| {
+                .register_action(|workspace, _: &NewThread, window, cx| {
                     if let Some(panel) = workspace.panel::<AgentPanel>(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::<AgentPanel>(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::<Editor>(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::<TerminalView>(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::<TerminalPanel>(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::<AgentPanel>(cx) else {
                             return;
                         };
@@ -603,11 +683,60 @@ pub(crate) struct AgentThread {
     conversation_view: Entity<ConversationView>,
 }
 
+struct AgentTerminal {
+    view: Entity<TerminalView>,
+    title_editor: Entity<Editor>,
+    last_known_title: String,
+    created_at: DateTime<Utc>,
+    has_notification: bool,
+    _subscriptions: Vec<Subscription>,
+}
+
+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<ConversationView>,
     },
+    Terminal {
+        terminal_id: TerminalId,
+    },
 }
 
 impl From<AgentThread> for BaseView {
@@ -625,6 +754,7 @@ enum OverlayView {
 enum VisibleSurface<'a> {
     Uninitialized,
     AgentThread(&'a Entity<ConversationView>),
+    Terminal(&'a Entity<TerminalView>),
     Configuration(Option<&'a Entity<AgentConfiguration>>),
 }
 
@@ -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<Subscription>,
     focus_handle: FocusHandle,
     base_view: BaseView,
+    last_created_entry_kind: AgentPanelEntryKind,
     overlay_view: Option<OverlayView>,
     draft_thread: Option<Entity<ConversationView>>,
     retained_threads: HashMap<ThreadId, Entity<ConversationView>>,
+    terminals: HashMap<TerminalId, AgentTerminal>,
     new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
     agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
     _extension_subscription: Option<Subscription>,
@@ -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<Self>,
+    ) {
+        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>) {
-        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>,
+    ) {
+        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<Self>,
+    ) {
+        if !cx.has_flag::<AgentPanelTerminalFeatureFlag>() {
+            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::<AgentPanelTerminalFeatureFlag>()
+            && 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<Self>,
+    ) {
+        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<PathBuf>,
+        focus: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        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<TerminalView>,
+        focus: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if !cx.has_flag::<AgentPanelTerminalFeatureFlag>() {
+            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<Self>,
+    ) {
+        if !cx.has_flag::<AgentPanelTerminalFeatureFlag>() {
+            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<Self>,
+    ) {
+        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<Self>,
+    ) {
+        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<Editor>,
+        event: &editor::EditorEvent,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        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<Self>,
+    ) {
+        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<PathBuf> {
+        // 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<TerminalId> {
+        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<AgentPanelTerminalInfo> {
+        if !cx.has_flag::<AgentPanelTerminalFeatureFlag>() {
+            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<String> {
         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::<AgentPanel>(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<String>,
+        focus: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Result<TerminalId> {
+        if !cx.has_flag::<AgentPanelTerminalFeatureFlag>() {
+            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<Self>) {
+        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)]

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;

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 { .. } => {}
                     },
                 ));

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 {

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]

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

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<acp::SessionId>,
-    workspace: Entity<Workspace>,
+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<acp::SessionId>,
+        workspace: Entity<Workspace>,
+    },
+    Terminal {
+        terminal_id: TerminalId,
+        workspace: Entity<Workspace>,
+    },
 }
 
 impl ActiveEntry {
     fn workspace(&self) -> &Entity<Workspace> {
-        &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<Workspace>,
+    created_at: DateTime<Utc>,
+    has_notification: bool,
+    highlight_positions: Vec<usize>,
+}
+
 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<Workspace>,
+    },
+}
+
+impl ActivatableEntry {
+    fn from_list_entry(entry: &ListEntry) -> Option<Self> {
+        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<ThreadEntry> for ListEntry {
     }
 }
 
+impl From<TerminalEntry> for ListEntry {
+    fn from(terminal: TerminalEntry) -> Self {
+        ListEntry::Terminal(terminal)
+    }
+}
+
 #[derive(Default)]
 struct SidebarContents {
     entries: Vec<ListEntry>,
     notified_threads: HashSet<agent_ui::ThreadId>,
+    notified_terminals: HashSet<TerminalId>,
     project_header_indices: Vec<usize>,
     has_open_projects: bool,
 }
@@ -328,6 +425,25 @@ fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
     PathList::new(&workspace.read(cx).root_paths(cx))
 }
 
+fn workspace_has_agent_panel_terminals(workspace: &Entity<Workspace>, cx: &App) -> bool {
+    workspace
+        .read(cx)
+        .panel::<AgentPanel>(cx)
+        .is_some_and(|panel| !panel.read(cx).terminals(cx).is_empty())
+}
+
+fn workspace_contains_worktree_path(
+    workspace: &Entity<Workspace>,
+    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<IconName>,
@@ -505,6 +621,17 @@ impl Sidebar {
             .detach();
 
         AgentThreadWorktreeLabelFlag::watch(cx);
+        cx.observe_flag::<AgentPanelTerminalFeatureFlag, _>(
+            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::<AgentPanelTerminalFeatureFlag>()
+            && 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<TerminalId> = HashSet::new();
         let mut current_session_ids: HashSet<acp::SessionId> = HashSet::new();
         let mut current_thread_ids: HashSet<agent_ui::ThreadId> = HashSet::new();
         let mut project_header_indices: Vec<usize> = 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<TerminalEntry> = 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<TerminalEntry> = 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::<AgentPanel>(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::<AgentPanel>(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<ActivatableEntry> {
+        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<Self>,
+    ) -> 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::<AgentPanelTerminalFeatureFlag>() {
+                    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<Workspace>,
+        terminal_id: TerminalId,
+        retain: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if !cx.has_flag::<AgentPanelTerminalFeatureFlag>() {
+            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::<AgentPanel>(cx) {
+                panel.update(cx, |panel, cx| {
+                    panel.activate_terminal(terminal_id, true, window, cx);
+                });
+            }
+            workspace.focus_panel::<AgentPanel>(window, cx);
+        });
+
+        self.update_entries(cx);
+    }
+
+    fn close_terminal(
+        &mut self,
+        workspace: &Entity<Workspace>,
+        terminal_id: TerminalId,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        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::<AgentPanel>(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::<Vec<_>>()
             })
             .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<agent_ui::ThreadId>,
-        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<ListEntry>,
+        terminals: Vec<TerminalEntry>,
+        threads: Vec<ThreadEntry>,
+        current_session_ids: &mut HashSet<acp::SessionId>,
+        current_thread_ids: &mut HashSet<agent_ui::ThreadId>,
+    ) {
+        fn display_time(entry: &ListEntry) -> DateTime<Utc> {
+            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::<AgentPanel>(cx) {
+                                    panel.update(cx, |panel, cx| {
+                                        panel.activate_terminal(terminal_id, false, window, cx);
+                                    });
+                                }
+                            });
                         }
+                        None => {}
                     }
                     this.dismiss_thread_switcher(cx);
                 }

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<Workspace>, 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::<String>::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(());
         }

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<PathBuf> {
+pub fn default_working_directory(workspace: &Workspace, cx: &App) -> Option<PathBuf> {
     let is_remote = workspace.project().read(cx).is_remote();
     let directory = match &TerminalSettings::get_global(cx).working_directory {
         WorkingDirectory::CurrentFileDirectory => workspace