Cargo.lock 🔗
@@ -16329,6 +16329,7 @@ dependencies = [
"git",
"gpui",
"http_client",
+ "itertools 0.14.0",
"language",
"language_model",
"log",
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>
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(-)
@@ -16329,6 +16329,7 @@ dependencies = [
"git",
"gpui",
"http_client",
+ "itertools 0.14.0",
"language",
"language_model",
"log",
@@ -1247,6 +1247,12 @@
"ctrl->": "agent::AddSelectionToThread",
},
},
+ {
+ "context": "AgentPanel && Terminal",
+ "bindings": {
+ "ctrl-n": "agent::NewThread",
+ },
+ },
{
"context": "ZedPredictModal",
"bindings": {
@@ -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,
@@ -1262,6 +1262,13 @@
"ctrl-shift-.": "agent::AddSelectionToThread",
},
},
+ {
+ "context": "AgentPanel > Terminal",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-n": "agent::NewThread",
+ },
+ },
{
"context": "Terminal && selection",
"bindings": {
@@ -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)]
@@ -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;
@@ -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 { .. } => {}
},
));
@@ -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 {
@@ -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]
@@ -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
@@ -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);
}
@@ -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(());
}
@@ -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