diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 1a0581d40b9f0acb6a9f8747813d1121d1c9c1f6..a327dfa55f35482ef2bea8d504ed003dfd2b82c5 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1132,6 +1132,7 @@ "ctrl-alt-r": "terminal::RerunTask", "alt-t": "terminal::RerunTask", "ctrl-shift-5": "pane::SplitRight", + "ctrl->": "agent::AddSelectionToThread", }, }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 0c02111c04d9452524d7f2847359e8657f23e32f..b8b7399fb0c3e72abe282ada19cb866c306d4f22 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1211,6 +1211,7 @@ "ctrl-alt-right": "pane::SplitRight", "cmd-d": "pane::SplitRight", "cmd-alt-r": "terminal::RerunTask", + "cmd->": "agent::AddSelectionToThread", }, }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index f232123ce951d57a864e9e4701c7e1d983186b89..b53fa4e7200617dfa43d438c3c04152e4eb0e559 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1150,6 +1150,7 @@ "ctrl-alt-r": "terminal::RerunTask", "alt-t": "terminal::RerunTask", "ctrl-shift-5": "pane::SplitRight", + "ctrl-shift-.": "agent::AddSelectionToThread", }, }, { diff --git a/crates/acp_thread/src/mention.rs b/crates/acp_thread/src/mention.rs index 2d34ffc98e2321e9feb9a0f43316da09f2403241..edfbe3443fef822ce34950f1630be6862df00b54 100644 --- a/crates/acp_thread/src/mention.rs +++ b/crates/acp_thread/src/mention.rs @@ -54,6 +54,7 @@ pub enum MentionUri { Fetch { url: Url, }, + TerminalSelection, } impl MentionUri { @@ -199,6 +200,8 @@ impl MentionUri { abs_path: Some(path.into()), line_range, }) + } else if path.starts_with("/agent/terminal-selection") { + Ok(Self::TerminalSelection) } else { bail!("invalid zed url: {:?}", input); } @@ -221,6 +224,7 @@ impl MentionUri { MentionUri::TextThread { name, .. } => name.clone(), MentionUri::Rule { name, .. } => name.clone(), MentionUri::Diagnostics { .. } => "Diagnostics".to_string(), + MentionUri::TerminalSelection => "Terminal".to_string(), MentionUri::Selection { abs_path: path, line_range, @@ -243,6 +247,7 @@ impl MentionUri { MentionUri::TextThread { .. } => IconName::Thread.path().into(), MentionUri::Rule { .. } => IconName::Reader.path().into(), MentionUri::Diagnostics { .. } => IconName::Warning.path().into(), + MentionUri::TerminalSelection => IconName::Terminal.path().into(), MentionUri::Selection { .. } => IconName::Reader.path().into(), MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(), } @@ -337,6 +342,7 @@ impl MentionUri { url } MentionUri::Fetch { url } => url.clone(), + MentionUri::TerminalSelection => Url::parse("zed:///agent/terminal-selection").unwrap(), } } } @@ -641,4 +647,16 @@ mod tests { _ => panic!("Expected Selection variant"), } } + + #[test] + fn test_parse_terminal_selection_uri() { + let terminal_uri = "zed:///agent/terminal-selection"; + let parsed = MentionUri::parse(terminal_uri, PathStyle::local()).unwrap(); + match &parsed { + MentionUri::TerminalSelection => {} + _ => panic!("Expected Terminal variant"), + } + assert_eq!(parsed.to_uri().to_string(), terminal_uri); + assert_eq!(parsed.name(), "Terminal"); + } } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index ea684f8625f0b6b2daaa6f817807c967a1c7d54e..bb5c09c38e3836dfdaba15436ad280f2a0cd5910 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -317,6 +317,17 @@ impl UserMessage { MentionUri::Diagnostics { .. } => { write!(&mut diagnostics_context, "\n{}\n", content).ok(); } + MentionUri::TerminalSelection => { + write!( + &mut selection_context, + "\n{}", + MarkdownCodeBlock { + tag: "console", + text: content + } + ) + .ok(); + } } language_model::MessageContent::Text(uri.as_link().to_string()) diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 9d25620c247bceeb039eb4be078ccd92f8b8db2b..f6d1eba70f84cc98b05ac9566d0260bd61996da8 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -1002,62 +1002,130 @@ impl MessageEditor { creases: Vec<(String, String)>, window: &mut Window, cx: &mut Context, + ) { + self.editor.update(cx, |editor, cx| { + editor.insert("\n", window, cx); + }); + for (text, crease_title) in creases { + self.insert_crease_impl(text, crease_title, IconName::TextSnippet, true, window, cx); + } + } + + pub fn insert_terminal_crease( + &mut self, + text: String, + window: &mut Window, + cx: &mut Context, + ) { + let mention_uri = MentionUri::TerminalSelection; + let mention_text = mention_uri.as_link().to_string(); + + let (excerpt_id, text_anchor, content_len) = self.editor.update(cx, |editor, cx| { + let buffer = editor.buffer().read(cx); + let snapshot = buffer.snapshot(cx); + let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap(); + let text_anchor = editor + .selections + .newest_anchor() + .start + .text_anchor + .bias_left(&buffer_snapshot); + + editor.insert(&mention_text, window, cx); + editor.insert(" ", window, cx); + + (*excerpt_id, text_anchor, mention_text.len()) + }); + + let Some((crease_id, tx)) = insert_crease_for_mention( + excerpt_id, + text_anchor, + content_len, + mention_uri.name().into(), + mention_uri.icon_path(cx), + None, + self.editor.clone(), + window, + cx, + ) else { + return; + }; + drop(tx); + + let mention_task = Task::ready(Ok(Mention::Text { + content: text, + tracked_buffers: vec![], + })) + .shared(); + + self.mention_set.update(cx, |mention_set, _| { + mention_set.insert_mention(crease_id, mention_uri, mention_task); + }); + } + + fn insert_crease_impl( + &mut self, + text: String, + title: String, + icon: IconName, + add_trailing_newline: bool, + window: &mut Window, + cx: &mut Context, ) { use editor::display_map::{Crease, FoldPlaceholder}; use multi_buffer::MultiBufferRow; use rope::Point; self.editor.update(cx, |editor, cx| { - editor.insert("\n", window, cx); - for (text, crease_title) in creases { - let point = editor - .selections - .newest::(&editor.display_snapshot(cx)) - .head(); - let start_row = MultiBufferRow(point.row); - - editor.insert(&text, window, cx); - - let snapshot = editor.buffer().read(cx).snapshot(cx); - let anchor_before = snapshot.anchor_after(point); - let anchor_after = editor - .selections - .newest_anchor() - .head() - .bias_left(&snapshot); + let point = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); + let start_row = MultiBufferRow(point.row); - editor.insert("\n", window, cx); + editor.insert(&text, window, cx); - let fold_placeholder = FoldPlaceholder { - render: Arc::new({ - let title = crease_title.clone(); - move |_fold_id, _fold_range, _cx| { - ButtonLike::new("code-crease") - .style(ButtonStyle::Filled) - .layer(ElevationIndex::ElevatedSurface) - .child(Icon::new(IconName::TextSnippet)) - .child(Label::new(title.clone()).single_line()) - .into_any_element() - } - }), - merge_adjacent: false, - ..Default::default() - }; + let snapshot = editor.buffer().read(cx).snapshot(cx); + let anchor_before = snapshot.anchor_after(point); + let anchor_after = editor + .selections + .newest_anchor() + .head() + .bias_left(&snapshot); - let crease = Crease::inline( - anchor_before..anchor_after, - fold_placeholder, - |row, is_folded, fold, _window, _cx| { - Disclosure::new(("code-crease-toggle", row.0 as u64), !is_folded) - .toggle_state(is_folded) - .on_click(move |_e, window, cx| fold(!is_folded, window, cx)) - .into_any_element() - }, - |_, _, _, _| gpui::Empty.into_any(), - ); - editor.insert_creases(vec![crease], cx); - editor.fold_at(start_row, window, cx); + if add_trailing_newline { + editor.insert("\n", window, cx); } + + let fold_placeholder = FoldPlaceholder { + render: Arc::new({ + let title = title.clone(); + move |_fold_id, _fold_range, _cx| { + ButtonLike::new("crease") + .style(ButtonStyle::Filled) + .layer(ElevationIndex::ElevatedSurface) + .child(Icon::new(icon)) + .child(Label::new(title.clone()).single_line()) + .into_any_element() + } + }), + merge_adjacent: false, + ..Default::default() + }; + + let crease = Crease::inline( + anchor_before..anchor_after, + fold_placeholder, + |row, is_folded, fold, _window, _cx| { + Disclosure::new(("crease-toggle", row.0 as u64), !is_folded) + .toggle_state(is_folded) + .on_click(move |_e, window, cx| fold(!is_folded, window, cx)) + .into_any_element() + }, + |_, _, _, _| gpui::Empty.into_any(), + ); + editor.insert_creases(vec![crease], cx); + editor.fold_at(start_row, window, cx); }); } diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 509d31e014bf8ce3ccd8c500c4c8fb105c186302..5f1b52d701f0337c8450726595543b9193298ea1 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -6870,6 +6870,7 @@ impl AcpThreadView { cx.open_url(url.as_str()); } MentionUri::Diagnostics { .. } => {} + MentionUri::TerminalSelection => {} }) } else { cx.open_url(&url); @@ -7651,6 +7652,18 @@ impl AcpThreadView { }); } + /// Inserts terminal text as a crease into the message editor. + pub(crate) fn insert_terminal_text( + &self, + text: String, + window: &mut Window, + cx: &mut Context, + ) { + self.message_editor.update(cx, |message_editor, cx| { + message_editor.insert_terminal_crease(text, window, cx); + }); + } + /// Inserts code snippets as creases into the message editor. pub(crate) fn insert_code_crease( &self, diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 83ba1be28b215aa927dba6c2ea15422be85899e8..772e7aeefb5bae93b6556782e4701b4201411adb 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -2964,6 +2964,38 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate { }); }); } + + fn quote_terminal_text( + &self, + workspace: &mut Workspace, + text: String, + window: &mut Window, + cx: &mut Context, + ) { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + + if !panel.focus_handle(cx).contains_focused(window, cx) { + workspace.toggle_panel_focus::(window, cx); + } + + panel.update(cx, |_, cx| { + // Wait to create a new context until the workspace is no longer + // being updated. + cx.defer_in(window, move |panel, window, cx| { + if let Some(thread_view) = panel.active_thread_view() { + thread_view.update(cx, |thread_view, cx| { + thread_view.insert_terminal_text(text, window, cx); + }); + } else if let Some(text_thread_editor) = panel.active_text_thread_editor() { + text_thread_editor.update(cx, |text_thread_editor, cx| { + text_thread_editor.quote_terminal_text(text, window, cx) + }); + } + }); + }); + } } struct OnboardingUpsell; diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index 26b39e6c70448aa544026e3503d23ffdc617e1c6..fe45be3e0b11f28c13a5bdb49235ca39101914d8 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -13,10 +13,12 @@ use editor::{ CompletionProvider, Editor, ExcerptId, code_context_menus::COMPLETION_MENU_MAX_WIDTH, }; use feature_flags::{FeatureFlagAppExt as _, UserSlashCommandsFeatureFlag}; +use futures::FutureExt as _; use fuzzy::{PathMatch, StringMatch, StringMatchCandidate}; use gpui::{App, BackgroundExecutor, Entity, SharedString, Task, WeakEntity}; use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId}; use lsp::CompletionContext; +use multi_buffer::ToOffset as _; use ordered_float::OrderedFloat; use project::lsp_store::{CompletionDocumentation, SymbolLocation}; use project::{ @@ -25,7 +27,10 @@ use project::{ }; use prompt_store::{PromptStore, UserPromptId}; use rope::Point; -use text::{Anchor, ToPoint as _}; +use settings::{Settings, TerminalDockPosition}; +use terminal::terminal_settings::TerminalSettings; +use terminal_view::terminal_panel::TerminalPanel; +use text::{Anchor, ToOffset as _, ToPoint as _}; use ui::IconName; use ui::prelude::*; use util::ResultExt as _; @@ -33,6 +38,7 @@ use util::paths::PathStyle; use util::rel_path::RelPath; use util::truncate_and_remove_front; use workspace::Workspace; +use workspace::dock::DockPosition; use crate::AgentPanel; use crate::mention_set::MentionSet; @@ -554,48 +560,129 @@ impl PromptCompletionProvider { ) -> Option { let (new_text, on_action) = match action { PromptContextAction::AddSelections => { - const PLACEHOLDER: &str = "selection "; - let selections = selection_ranges(workspace, cx) + // Collect non-empty editor selections + let editor_selections: Vec<_> = selection_ranges(workspace, cx) + .into_iter() + .filter(|(buffer, range)| { + let snapshot = buffer.read(cx).snapshot(); + range.start.to_offset(&snapshot) != range.end.to_offset(&snapshot) + }) + .collect(); + + // Collect terminal selections from all terminal views if the terminal panel is visible + let terminal_selections: Vec = + terminal_selections_if_panel_open(workspace, cx); + + const EDITOR_PLACEHOLDER: &str = "selection "; + const TERMINAL_PLACEHOLDER: &str = "terminal "; + + let selections = editor_selections .into_iter() .enumerate() .map(|(ix, (buffer, range))| { ( buffer, range, - (PLACEHOLDER.len() * ix)..(PLACEHOLDER.len() * (ix + 1) - 1), + (EDITOR_PLACEHOLDER.len() * ix) + ..(EDITOR_PLACEHOLDER.len() * (ix + 1) - 1), ) }) .collect::>(); - let new_text: String = PLACEHOLDER.repeat(selections.len()); + let mut new_text: String = EDITOR_PLACEHOLDER.repeat(selections.len()); + + // Add terminal placeholders for each terminal selection + let terminal_ranges: Vec<(String, std::ops::Range)> = terminal_selections + .into_iter() + .map(|text| { + let start = new_text.len(); + new_text.push_str(TERMINAL_PLACEHOLDER); + (text, start..(new_text.len() - 1)) + }) + .collect(); let callback = Arc::new({ let source_range = source_range.clone(); - move |_, window: &mut Window, cx: &mut App| { + move |_: CompletionIntent, window: &mut Window, cx: &mut App| { let editor = editor.clone(); let selections = selections.clone(); let mention_set = mention_set.clone(); let source_range = source_range.clone(); + let terminal_ranges = terminal_ranges.clone(); window.defer(cx, move |window, cx| { if let Some(editor) = editor.upgrade() { - mention_set - .update(cx, |store, cx| { - store.confirm_mention_for_selection( - source_range, - selections, - editor, - window, - cx, - ) - }) - .ok(); + // Insert editor selections + if !selections.is_empty() { + mention_set + .update(cx, |store, cx| { + store.confirm_mention_for_selection( + source_range.clone(), + selections, + editor.clone(), + window, + cx, + ) + }) + .ok(); + } + + // Insert terminal selections + for (terminal_text, terminal_range) in terminal_ranges { + let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); + let Some(start) = + snapshot.as_singleton_anchor(source_range.start) + else { + return; + }; + let offset = start.to_offset(&snapshot); + + let mention_uri = MentionUri::TerminalSelection; + let range = snapshot.anchor_after(offset + terminal_range.start) + ..snapshot.anchor_after(offset + terminal_range.end); + + let crease = crate::mention_set::crease_for_mention( + mention_uri.name().into(), + mention_uri.icon_path(cx), + range, + editor.downgrade(), + ); + + let crease_id = editor.update(cx, |editor, cx| { + let crease_ids = + editor.insert_creases(vec![crease.clone()], cx); + editor.fold_creases(vec![crease], false, window, cx); + crease_ids.first().copied().unwrap() + }); + + mention_set + .update(cx, |mention_set, _| { + mention_set.insert_mention( + crease_id, + mention_uri.clone(), + gpui::Task::ready(Ok( + crate::mention_set::Mention::Text { + content: terminal_text, + tracked_buffers: vec![], + }, + )) + .shared(), + ); + }) + .ok(); + } } }); false } }); - (new_text, callback) + ( + new_text, + callback + as Arc< + dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync, + >, + ) } }; @@ -1087,7 +1174,7 @@ impl PromptCompletionProvider { entries.push(PromptContextEntry::Mode(PromptContextType::Thread)); } - let has_selection = workspace + let has_editor_selection = workspace .read(cx) .active_item(cx) .and_then(|item| item.downcast::()) @@ -1096,7 +1183,10 @@ impl PromptCompletionProvider { editor.has_non_empty_selection(&editor.display_snapshot(cx)) }) }); - if has_selection { + + let has_terminal_selection = !terminal_selections_if_panel_open(workspace, cx).is_empty(); + + if has_editor_selection || has_terminal_selection { entries.push(PromptContextEntry::Action( PromptContextAction::AddSelections, )); @@ -2106,6 +2196,30 @@ fn build_code_label_for_path( label.build() } +/// Returns terminal selections from all terminal views if the terminal panel is open. +fn terminal_selections_if_panel_open(workspace: &Entity, cx: &App) -> Vec { + let Some(panel) = workspace.read(cx).panel::(cx) else { + return Vec::new(); + }; + + // Check if the dock containing this panel is open + 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 + .read(cx) + .dock_at_position(position) + .read(cx) + .is_open(); + if !dock_is_open { + return Vec::new(); + } + + panel.read(cx).terminal_selections(cx) +} + fn selection_ranges( workspace: &Entity, cx: &mut App, diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index 0d4ddd6f7a498f0b6624fd75a15b2577e06bbadf..2bd8e6d502dafc879b468a1c47735c0a57ab7aa6 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -249,6 +249,10 @@ impl MentionSet { debug_panic!("unexpected selection URI"); Task::ready(Err(anyhow!("unexpected selection URI"))) } + MentionUri::TerminalSelection => { + debug_panic!("unexpected terminal URI"); + Task::ready(Err(anyhow!("unexpected terminal URI"))) + } }; let task = cx .spawn(async move |_, _| task.await.map_err(|e| e.to_string())) diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index a16fcc716d1de13c6ba343ca3d77245d333aaf38..4be047c47d491f7121e3f2322d6edcdbcc7baebe 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -64,8 +64,11 @@ use workspace::{ CollaboratorId, searchable::{Direction, SearchableItemHandle}, }; + +use terminal_view::{TerminalView, terminal_panel::TerminalPanel}; use workspace::{ Save, Toast, Workspace, + dock::Panel, item::{self, FollowableItem, Item}, notifications::NotificationId, pane, @@ -159,6 +162,14 @@ pub trait AgentPanelDelegate { window: &mut Window, cx: &mut Context, ); + + fn quote_terminal_text( + &self, + workspace: &mut Workspace, + text: String, + window: &mut Window, + cx: &mut Context, + ); } impl dyn AgentPanelDelegate { @@ -1487,7 +1498,40 @@ impl TextThreadEditor { return; }; - let Some((selections, buffer)) = maybe!({ + // Try terminal selection first (requires focus, so more specific) + if let Some(terminal_text) = maybe!({ + let terminal_panel = workspace.panel::(cx)?; + + if !terminal_panel + .read(cx) + .focus_handle(cx) + .contains_focused(window, cx) + { + return None; + } + + let terminal_view = terminal_panel.read(cx).pane().and_then(|pane| { + pane.read(cx) + .active_item() + .and_then(|t| t.downcast::()) + })?; + + terminal_view + .read(cx) + .terminal() + .read(cx) + .last_content + .selection_text + .clone() + }) { + if !terminal_text.is_empty() { + agent_panel_delegate.quote_terminal_text(workspace, terminal_text, window, cx); + return; + } + } + + // Try editor selection + if let Some((selections, buffer)) = maybe!({ let editor = workspace .active_item(cx) .and_then(|item| item.act_as::(cx))?; @@ -1506,15 +1550,11 @@ impl TextThreadEditor { .collect::>() }); Some((selections, buffer)) - }) else { - return; - }; - - if selections.is_empty() { - return; + }) { + if !selections.is_empty() { + agent_panel_delegate.quote_selection(workspace, selections, buffer, window, cx); + } } - - agent_panel_delegate.quote_selection(workspace, selections, buffer, window, cx); } /// Handles the SendReviewToAgent action from the ProjectDiff toolbar. @@ -1714,6 +1754,54 @@ impl TextThreadEditor { }) } + pub fn quote_terminal_text( + &mut self, + text: String, + window: &mut Window, + cx: &mut Context, + ) { + let crease_title = "terminal".to_string(); + let formatted_text = format!("```console\n{}\n```\n", text); + + self.editor.update(cx, |editor, cx| { + // Insert newline first if not at the start of a line + let point = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); + if point.column > 0 { + editor.insert("\n", window, cx); + } + + let point = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); + let start_row = MultiBufferRow(point.row); + + editor.insert(&formatted_text, window, cx); + + let snapshot = editor.buffer().read(cx).snapshot(cx); + let anchor_before = snapshot.anchor_after(point); + let anchor_after = editor + .selections + .newest_anchor() + .head() + .bias_left(&snapshot); + + let fold_placeholder = + quote_selection_fold_placeholder(crease_title, cx.entity().downgrade()); + let crease = Crease::inline( + anchor_before..anchor_after, + fold_placeholder, + render_quote_selection_output_toggle, + |_, _, _, _| Empty.into_any(), + ); + editor.insert_creases(vec![crease], cx); + editor.fold_at(start_row, window, cx); + }) + } + fn copy(&mut self, _: &editor::actions::Copy, _window: &mut Window, cx: &mut Context) { if self.editor.read(cx).selections.count() == 1 { let (copied_text, metadata, _) = self.get_clipboard_contents(cx); @@ -3548,4 +3636,26 @@ mod tests { theme::init(theme::LoadThemes::JustBase, cx); } + + #[gpui::test] + async fn test_quote_terminal_text(cx: &mut TestAppContext) { + let (_context, text_thread_editor, mut cx) = + setup_text_thread_editor_text(vec![(Role::User, "")], cx).await; + + let terminal_output = "$ ls -la\ntotal 0\ndrwxr-xr-x 2 user user 40 Jan 1 00:00 ."; + + text_thread_editor.update_in(&mut cx, |text_thread_editor, window, cx| { + text_thread_editor.quote_terminal_text(terminal_output.to_string(), window, cx); + + text_thread_editor.editor.update(cx, |editor, cx| { + let text = editor.text(cx); + // The text should contain the terminal output wrapped in a code block + assert!( + text.contains(&format!("```console\n{}\n```", terminal_output)), + "Terminal text should be wrapped in code block. Got: {}", + text + ); + }); + }); + } } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index de0a4f712517406a834c2cdebffe452bfbc46f30..d9cfabb1da9b7405e5e316322156f7b0a0062f55 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -1084,6 +1084,32 @@ impl TerminalPanel { self.assistant_enabled } + /// Returns all panes in the terminal panel. + pub fn panes(&self) -> Vec<&Entity> { + self.center.panes() + } + + /// Returns all non-empty terminal selections from all terminal views in all panes. + pub fn terminal_selections(&self, cx: &App) -> Vec { + self.center + .panes() + .iter() + .flat_map(|pane| { + pane.read(cx).items().filter_map(|item| { + let terminal_view = item.downcast::()?; + terminal_view + .read(cx) + .terminal() + .read(cx) + .last_content + .selection_text + .clone() + .filter(|text| !text.is_empty()) + }) + }) + .collect() + } + fn is_enabled(&self, cx: &App) -> bool { self.workspace .upgrade() diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index c9d7c0a3c4f48f696c1cb2bc7834465ab39cf477..42dc102d1ccc01b1df6f3df316503447a38fd79d 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -19,6 +19,14 @@ use project::{Project, search::SearchQuery}; use schemars::JsonSchema; use serde::Deserialize; use settings::{Settings, SettingsStore, TerminalBlink, WorkingDirectory}; +use std::{ + cmp, + ops::{Range, RangeInclusive}, + path::{Path, PathBuf}, + rc::Rc, + sync::Arc, + time::Duration, +}; use task::TaskId; use terminal::{ Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Paste, ScrollLineDown, ScrollLineUp, @@ -50,16 +58,7 @@ use workspace::{ register_serializable_item, searchable::{Direction, SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle}, }; -use zed_actions::assistant::InlineAssist; - -use std::{ - cmp, - ops::{Range, RangeInclusive}, - path::{Path, PathBuf}, - rc::Rc, - sync::Arc, - time::Duration, -}; +use zed_actions::{agent::AddSelectionToThread, assistant::InlineAssist}; struct ImeState { marked_text: String, @@ -496,6 +495,13 @@ impl TerminalView { .upgrade() .and_then(|workspace| workspace.read(cx).panel::(cx)) .is_some_and(|terminal_panel| terminal_panel.read(cx).assistant_enabled()); + let has_selection = self + .terminal + .read(cx) + .last_content + .selection_text + .as_ref() + .is_some_and(|text| !text.is_empty()); let context_menu = ContextMenu::build(window, cx, |menu, _, _| { menu.context(self.focus_handle.clone()) .action("New Terminal", Box::new(NewTerminal::default())) @@ -507,6 +513,9 @@ impl TerminalView { .when(assistant_enabled, |menu| { menu.separator() .action("Inline Assist", Box::new(InlineAssist::default())) + .when(has_selection, |menu| { + menu.action("Add to Agent Thread", Box::new(AddSelectionToThread)) + }) }) .separator() .action(