From 52182e43cdee1b60d07f877aba86805b25176e5f Mon Sep 17 00:00:00 2001 From: Rutger Cappendijk Date: Thu, 29 Jan 2026 17:15:03 +0200 Subject: [PATCH] agent_ui: Add ability to add terminal output to agent context (#47637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary This PR adds the ability to quote terminal selections into Agent threads, similar to how editor selections can be quoted. Users can now select text in the terminal and add it as context to their agent conversation. ### Features - **Context menu**: Added "Add to Agent Thread" option to the terminal's right-click menu (only shows when text is selected) - **Keyboard shortcut**: The existing `cmd->` / `ctrl->` shortcut now works in the terminal - **Collapsed display**: Terminal output appears as a collapsed "Terminal" crease in the chat, just like code references - **Works with both thread types**: Supports both ACP-style threads (Claude Code) and text threads ### Implementation - Extended `quote_selection` handler to check for terminal panel focus after checking for editor selections - Terminal content is formatted as ` ```terminal` code blocks via new `insert_terminal_crease` method - Refactored crease insertion into `insert_crease_impl` helper to support both code snippets (with `TextSnippet` icon) and terminal output (with `Terminal` icon) - Added automatic detection and folding of ` ```terminal` blocks in `set_message` so terminal content stays collapsed in sent message history - Terminal creases use a trailing space instead of newline so cursor behavior matches user expectations ### Demo 1. Select text in terminal 2. Right-click → "Add to Agent Thread" (or press `cmd->`) 3. Terminal content appears as collapsed "Terminal" block in chat 4. Send message - AI receives full terminal output, but chat history shows it collapsed ### General note I'm new to open source and the codebase in general so let me know if anything should be different! I tried to stay in line with other patterns I saw. I didn't see a way to setup an end to end test with a real terminal so I just added a unit test for the adding of the text to the chat, if it exists and I missed it then I think the test coverage could be improved with a test that uses a real terminal + running the command image Release Notes: - agent: Added the ability to send selections from terminals into the Agent thread --------- Co-authored-by: Ben Brandt --- assets/keymaps/default-linux.json | 1 + assets/keymaps/default-macos.json | 1 + assets/keymaps/default-windows.json | 1 + crates/acp_thread/src/mention.rs | 18 +++ crates/agent/src/thread.rs | 11 ++ crates/agent_ui/src/acp/message_editor.rs | 160 +++++++++++++++------ crates/agent_ui/src/acp/thread_view.rs | 13 ++ crates/agent_ui/src/agent_panel.rs | 32 +++++ crates/agent_ui/src/completion_provider.rs | 154 +++++++++++++++++--- crates/agent_ui/src/mention_set.rs | 4 + crates/agent_ui/src/text_thread_editor.rs | 128 +++++++++++++++-- crates/terminal_view/src/terminal_panel.rs | 26 ++++ crates/terminal_view/src/terminal_view.rs | 29 ++-- 13 files changed, 493 insertions(+), 85 deletions(-) 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(