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 | 158 +++++++++++++++------ 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, 492 insertions(+), 84 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 1a0581d40b9..a327dfa55f3 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 0c02111c04d..b8b7399fb0c 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 f232123ce95..b53fa4e7200 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 2d34ffc98e2..edfbe3443fe 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 ea684f8625f..bb5c09c38e3 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 9d25620c247..f6d1eba70f8 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); + let point = editor + .selections + .newest::(&editor.display_snapshot(cx)) + .head(); + let start_row = MultiBufferRow(point.row); - editor.insert(&text, window, cx); + 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 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); + if add_trailing_newline { editor.insert("\n", 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 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); } + + 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 509d31e014b..5f1b52d701f 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 83ba1be28b2..772e7aeefb5 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 26b39e6c704..fe45be3e0b1 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 0d4ddd6f7a4..2bd8e6d502d 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 a16fcc716d1..4be047c47d4 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 de0a4f71251..d9cfabb1da9 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 c9d7c0a3c4f..42dc102d1cc 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(