History

agent_ui: Add ability to add terminal output to agent context (#47637)

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 <img width="431" height="148" alt="image" src="https://github.com/user-attachments/assets/854732f9-2a7f-4f31-867e-d54af068c97c" /> Release Notes: - agent: Added the ability to send selections from terminals into the Agent thread ---------

Rutger Cappendijk committed 9 months ago

Commit 52182e41 parent d18d4b113 files · +493 −85Browse files

Unified · Split

13 files
  1. assets/keymaps/default-linux.json +1 −0
  2. assets/keymaps/default-macos.json +1 −0
  3. assets/keymaps/default-windows.json +1 −0
  4. crates/acp_thread/src/mention.rs +18 −0
  5. crates/agent/src/thread.rs +11 −0
  6. crates/agent_ui/src/acp/message_editor.rs +114 −46
  7. crates/agent_ui/src/acp/thread_view.rs +13 −0
  8. crates/agent_ui/src/agent_panel.rs +32 −0
  9. crates/agent_ui/src/completion_provider.rs +134 −20
  10. crates/agent_ui/src/mention_set.rs +4 −0
  11. crates/agent_ui/src/text_thread_editor.rs +119 −9
  12. crates/terminal_view/src/terminal_panel.rs +26 −0
  13. crates/terminal_view/src/terminal_view.rs +19 −10
assets/keymaps/default-linux.json+1−0
@@ -1132,6 +1132,7 @@ · 1131 unmodified lines
11321132 "ctrl-alt-r": "terminal::RerunTask",
11331133 "alt-t": "terminal::RerunTask",
11341134 "ctrl-shift-5": "pane::SplitRight",
1135+ "ctrl->": "agent::AddSelectionToThread",
11351136 },
11361137 },
11371138 {
assets/keymaps/default-macos.json+1−0
@@ -1211,6 +1211,7 @@ · 1210 unmodified lines
12111211 "ctrl-alt-right": "pane::SplitRight",
12121212 "cmd-d": "pane::SplitRight",
12131213 "cmd-alt-r": "terminal::RerunTask",
1214+ "cmd->": "agent::AddSelectionToThread",
12141215 },
12151216 },
12161217 {
assets/keymaps/default-windows.json+1−0
@@ -1150,6 +1150,7 @@ · 1149 unmodified lines
11501150 "ctrl-alt-r": "terminal::RerunTask",
11511151 "alt-t": "terminal::RerunTask",
11521152 "ctrl-shift-5": "pane::SplitRight",
1153+ "ctrl-shift-.": "agent::AddSelectionToThread",
11531154 },
11541155 },
11551156 {
crates/acp_thread/src/mention.rs+18−0
@@ -54,6 +54,7 @@ pub enum MentionUri { · 53 unmodified lines
5454 Fetch {
5555 url: Url,
5656 },
57+ TerminalSelection,
5758 }
5859
5960 impl MentionUri {
@@ -199,6 +200,8 @@ impl MentionUri { · 139 unmodified lines
199200 abs_path: Some(path.into()),
200201 line_range,
201202 })
203+ } else if path.starts_with("/agent/terminal-selection") {
204+ Ok(Self::TerminalSelection)
202205 } else {
203206 bail!("invalid zed url: {:?}", input);
204207 }
@@ -221,6 +224,7 @@ impl MentionUri { · 16 unmodified lines
221224 MentionUri::TextThread { name, .. } => name.clone(),
222225 MentionUri::Rule { name, .. } => name.clone(),
223226 MentionUri::Diagnostics { .. } => "Diagnostics".to_string(),
227+ MentionUri::TerminalSelection => "Terminal".to_string(),
224228 MentionUri::Selection {
225229 abs_path: path,
226230 line_range,
@@ -243,6 +247,7 @@ impl MentionUri { · 16 unmodified lines
243247 MentionUri::TextThread { .. } => IconName::Thread.path().into(),
244248 MentionUri::Rule { .. } => IconName::Reader.path().into(),
245249 MentionUri::Diagnostics { .. } => IconName::Warning.path().into(),
250+ MentionUri::TerminalSelection => IconName::Terminal.path().into(),
246251 MentionUri::Selection { .. } => IconName::Reader.path().into(),
247252 MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(),
248253 }
@@ -337,6 +342,7 @@ impl MentionUri { · 88 unmodified lines
337342 url
338343 }
339344 MentionUri::Fetch { url } => url.clone(),
345+ MentionUri::TerminalSelection => Url::parse("zed:///agent/terminal-selection").unwrap(),
340346 }
341347 }
342348 }
@@ -641,4 +647,16 @@ mod tests { · 298 unmodified lines
641647 _ => panic!("Expected Selection variant"),
642648 }
643649 }
650+
651+ #[test]
652+ fn test_parse_terminal_selection_uri() {
653+ let terminal_uri = "zed:///agent/terminal-selection";
654+ let parsed = MentionUri::parse(terminal_uri, PathStyle::local()).unwrap();
655+ match &parsed {
656+ MentionUri::TerminalSelection => {}
657+ _ => panic!("Expected Terminal variant"),
658+ }
659+ assert_eq!(parsed.to_uri().to_string(), terminal_uri);
660+ assert_eq!(parsed.name(), "Terminal");
661+ }
644662 }
crates/agent/src/thread.rs+11−0
@@ -317,6 +317,17 @@ impl UserMessage { · 316 unmodified lines
317317 MentionUri::Diagnostics { .. } => {
318318 write!(&mut diagnostics_context, "\n{}\n", content).ok();
319319 }
320+ MentionUri::TerminalSelection => {
321+ write!(
322+ &mut selection_context,
323+ "\n{}",
324+ MarkdownCodeBlock {
325+ tag: "console",
326+ text: content
327+ }
328+ )
329+ .ok();
330+ }
320331 }
321332
322333 language_model::MessageContent::Text(uri.as_link().to_string())
crates/agent_ui/src/acp/message_editor.rs+114−46
@@ -1002,62 +1002,130 @@ impl MessageEditor { · 1001 unmodified lines
10021002 creases: Vec<(String, String)>,
10031003 window: &mut Window,
10041004 cx: &mut Context<Self>,
1005+ ) {
1006+ self.editor.update(cx, |editor, cx| {
1007+ editor.insert("\n", window, cx);
1008+ });
1009+ for (text, crease_title) in creases {
1010+ self.insert_crease_impl(text, crease_title, IconName::TextSnippet, true, window, cx);
1011+ }
1012+ }
1013+
1014+ pub fn insert_terminal_crease(
1015+ &mut self,
1016+ text: String,
1017+ window: &mut Window,
1018+ cx: &mut Context<Self>,
1019+ ) {
1020+ let mention_uri = MentionUri::TerminalSelection;
1021+ let mention_text = mention_uri.as_link().to_string();
1022+
1023+ let (excerpt_id, text_anchor, content_len) = self.editor.update(cx, |editor, cx| {
1024+ let buffer = editor.buffer().read(cx);
1025+ let snapshot = buffer.snapshot(cx);
1026+ let (excerpt_id, _, buffer_snapshot) = snapshot.as_singleton().unwrap();
1027+ let text_anchor = editor
1028+ .selections
1029+ .newest_anchor()
1030+ .start
1031+ .text_anchor
1032+ .bias_left(&buffer_snapshot);
1033+
1034+ editor.insert(&mention_text, window, cx);
1035+ editor.insert(" ", window, cx);
1036+
1037+ (*excerpt_id, text_anchor, mention_text.len())
1038+ });
1039+
1040+ let Some((crease_id, tx)) = insert_crease_for_mention(
1041+ excerpt_id,
1042+ text_anchor,
1043+ content_len,
1044+ mention_uri.name().into(),
1045+ mention_uri.icon_path(cx),
1046+ None,
1047+ self.editor.clone(),
1048+ window,
1049+ cx,
1050+ ) else {
1051+ return;
1052+ };
1053+ drop(tx);
1054+
1055+ let mention_task = Task::ready(Ok(Mention::Text {
1056+ content: text,
1057+ tracked_buffers: vec![],
1058+ }))
1059+ .shared();
1060+
1061+ self.mention_set.update(cx, |mention_set, _| {
1062+ mention_set.insert_mention(crease_id, mention_uri, mention_task);
1063+ });
1064+ }
1065+
1066+ fn insert_crease_impl(
1067+ &mut self,
1068+ text: String,
1069+ title: String,
1070+ icon: IconName,
1071+ add_trailing_newline: bool,
1072+ window: &mut Window,
1073+ cx: &mut Context<Self>,
10051074 ) {
10061075 use editor::display_map::{Crease, FoldPlaceholder};
10071076 use multi_buffer::MultiBufferRow;
10081077 use rope::Point;
10091078
10101079 self.editor.update(cx, |editor, cx| {
1011− editor.insert("\n", window, cx);
1012− for (text, crease_title) in creases {
1013− let point = editor
1014− .selections
1015− .newest::<Point>(&editor.display_snapshot(cx))
1016− .head();
1017− let start_row = MultiBufferRow(point.row);
1018−
1019− editor.insert(&text, window, cx);
1020−
1021− let snapshot = editor.buffer().read(cx).snapshot(cx);
1022− let anchor_before = snapshot.anchor_after(point);
1023− let anchor_after = editor
1024− .selections
1025− .newest_anchor()
1026− .head()
1027− .bias_left(&snapshot);
1080+ let point = editor
1081+ .selections
1082+ .newest::<Point>(&editor.display_snapshot(cx))
1083+ .head();
1084+ let start_row = MultiBufferRow(point.row);
10281085
1029− editor.insert("\n", window, cx);
1086+ editor.insert(&text, window, cx);
10301087
1031− let fold_placeholder = FoldPlaceholder {
1032− render: Arc::new({
1033− let title = crease_title.clone();
1034− move |_fold_id, _fold_range, _cx| {
1035− ButtonLike::new("code-crease")
1036− .style(ButtonStyle::Filled)
1037− .layer(ElevationIndex::ElevatedSurface)
1038− .child(Icon::new(IconName::TextSnippet))
1039− .child(Label::new(title.clone()).single_line())
1040− .into_any_element()
1041− }
1042− }),
1043− merge_adjacent: false,
1044− ..Default::default()
1045− };
1088+ let snapshot = editor.buffer().read(cx).snapshot(cx);
1089+ let anchor_before = snapshot.anchor_after(point);
1090+ let anchor_after = editor
1091+ .selections
1092+ .newest_anchor()
1093+ .head()
1094+ .bias_left(&snapshot);
10461095
1047− let crease = Crease::inline(
1048− anchor_before..anchor_after,
1049− fold_placeholder,
1050− |row, is_folded, fold, _window, _cx| {
1051− Disclosure::new(("code-crease-toggle", row.0 as u64), !is_folded)
1052− .toggle_state(is_folded)
1053− .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
1054− .into_any_element()
1055− },
1056− |_, _, _, _| gpui::Empty.into_any(),
1057− );
1058− editor.insert_creases(vec![crease], cx);
1059− editor.fold_at(start_row, window, cx);
1096+ if add_trailing_newline {
1097+ editor.insert("\n", window, cx);
10601098 }
1099+
1100+ let fold_placeholder = FoldPlaceholder {
1101+ render: Arc::new({
1102+ let title = title.clone();
1103+ move |_fold_id, _fold_range, _cx| {
1104+ ButtonLike::new("crease")
1105+ .style(ButtonStyle::Filled)
1106+ .layer(ElevationIndex::ElevatedSurface)
1107+ .child(Icon::new(icon))
1108+ .child(Label::new(title.clone()).single_line())
1109+ .into_any_element()
1110+ }
1111+ }),
1112+ merge_adjacent: false,
1113+ ..Default::default()
1114+ };
1115+
1116+ let crease = Crease::inline(
1117+ anchor_before..anchor_after,
1118+ fold_placeholder,
1119+ |row, is_folded, fold, _window, _cx| {
1120+ Disclosure::new(("crease-toggle", row.0 as u64), !is_folded)
1121+ .toggle_state(is_folded)
1122+ .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
1123+ .into_any_element()
1124+ },
1125+ |_, _, _, _| gpui::Empty.into_any(),
1126+ );
1127+ editor.insert_creases(vec![crease], cx);
1128+ editor.fold_at(start_row, window, cx);
10611129 });
10621130 }
10631131
crates/agent_ui/src/acp/thread_view.rs+13−0
@@ -6870,6 +6870,7 @@ impl AcpThreadView { · 6869 unmodified lines
68706870 cx.open_url(url.as_str());
68716871 }
68726872 MentionUri::Diagnostics { .. } => {}
6873+ MentionUri::TerminalSelection => {}
68736874 })
68746875 } else {
68756876 cx.open_url(&url);
@@ -7651,6 +7652,18 @@ impl AcpThreadView { · 775 unmodified lines
76517652 });
76527653 }
76537654
7655+ /// Inserts terminal text as a crease into the message editor.
7656+ pub(crate) fn insert_terminal_text(
7657+ &self,
7658+ text: String,
7659+ window: &mut Window,
7660+ cx: &mut Context<Self>,
7661+ ) {
7662+ self.message_editor.update(cx, |message_editor, cx| {
7663+ message_editor.insert_terminal_crease(text, window, cx);
7664+ });
7665+ }
7666+
76547667 /// Inserts code snippets as creases into the message editor.
76557668 pub(crate) fn insert_code_crease(
76567669 &self,
crates/agent_ui/src/agent_panel.rs+32−0
@@ -2964,6 +2964,38 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate { · 2963 unmodified lines
29642964 });
29652965 });
29662966 }
2967+
2968+ fn quote_terminal_text(
2969+ &self,
2970+ workspace: &mut Workspace,
2971+ text: String,
2972+ window: &mut Window,
2973+ cx: &mut Context<Workspace>,
2974+ ) {
2975+ let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
2976+ return;
2977+ };
2978+
2979+ if !panel.focus_handle(cx).contains_focused(window, cx) {
2980+ workspace.toggle_panel_focus::<AgentPanel>(window, cx);
2981+ }
2982+
2983+ panel.update(cx, |_, cx| {
2984+ // Wait to create a new context until the workspace is no longer
2985+ // being updated.
2986+ cx.defer_in(window, move |panel, window, cx| {
2987+ if let Some(thread_view) = panel.active_thread_view() {
2988+ thread_view.update(cx, |thread_view, cx| {
2989+ thread_view.insert_terminal_text(text, window, cx);
2990+ });
2991+ } else if let Some(text_thread_editor) = panel.active_text_thread_editor() {
2992+ text_thread_editor.update(cx, |text_thread_editor, cx| {
2993+ text_thread_editor.quote_terminal_text(text, window, cx)
2994+ });
2995+ }
2996+ });
2997+ });
2998+ }
29672999 }
29683000
29693001 struct OnboardingUpsell;
crates/agent_ui/src/completion_provider.rs+134−20
@@ -13,10 +13,12 @@ use editor::{ · 12 unmodified lines
1313 CompletionProvider, Editor, ExcerptId, code_context_menus::COMPLETION_MENU_MAX_WIDTH,
1414 };
1515 use feature_flags::{FeatureFlagAppExt as _, UserSlashCommandsFeatureFlag};
16+use futures::FutureExt as _;
1617 use fuzzy::{PathMatch, StringMatch, StringMatchCandidate};
1718 use gpui::{App, BackgroundExecutor, Entity, SharedString, Task, WeakEntity};
1819 use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId};
1920 use lsp::CompletionContext;
21+use multi_buffer::ToOffset as _;
2022 use ordered_float::OrderedFloat;
2123 use project::lsp_store::{CompletionDocumentation, SymbolLocation};
2224 use project::{
@@ -25,7 +27,10 @@ use project::{ · 2 unmodified lines
2527 };
2628 use prompt_store::{PromptStore, UserPromptId};
2729 use rope::Point;
28−use text::{Anchor, ToPoint as _};
30+use settings::{Settings, TerminalDockPosition};
31+use terminal::terminal_settings::TerminalSettings;
32+use terminal_view::terminal_panel::TerminalPanel;
33+use text::{Anchor, ToOffset as _, ToPoint as _};
2934 use ui::IconName;
3035 use ui::prelude::*;
3136 use util::ResultExt as _;
@@ -33,6 +38,7 @@ use util::paths::PathStyle; · 1 unmodified line
3338 use util::rel_path::RelPath;
3439 use util::truncate_and_remove_front;
3540 use workspace::Workspace;
41+use workspace::dock::DockPosition;
3642
3743 use crate::AgentPanel;
3844 use crate::mention_set::MentionSet;
@@ -554,48 +560,129 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> { · 515 unmodified lines
554560 ) -> Option<Completion> {
555561 let (new_text, on_action) = match action {
556562 PromptContextAction::AddSelections => {
557− const PLACEHOLDER: &str = "selection ";
558− let selections = selection_ranges(workspace, cx)
563+ // Collect non-empty editor selections
564+ let editor_selections: Vec<_> = selection_ranges(workspace, cx)
565+ .into_iter()
566+ .filter(|(buffer, range)| {
567+ let snapshot = buffer.read(cx).snapshot();
568+ range.start.to_offset(&snapshot) != range.end.to_offset(&snapshot)
569+ })
570+ .collect();
571+
572+ // Collect terminal selections from all terminal views if the terminal panel is visible
573+ let terminal_selections: Vec<String> =
574+ terminal_selections_if_panel_open(workspace, cx);
575+
576+ const EDITOR_PLACEHOLDER: &str = "selection ";
577+ const TERMINAL_PLACEHOLDER: &str = "terminal ";
578+
579+ let selections = editor_selections
559580 .into_iter()
560581 .enumerate()
561582 .map(|(ix, (buffer, range))| {
562583 (
563584 buffer,
564585 range,
565− (PLACEHOLDER.len() * ix)..(PLACEHOLDER.len() * (ix + 1) - 1),
586+ (EDITOR_PLACEHOLDER.len() * ix)
587+ ..(EDITOR_PLACEHOLDER.len() * (ix + 1) - 1),
566588 )
567589 })
568590 .collect::<Vec<_>>();
569591
570− let new_text: String = PLACEHOLDER.repeat(selections.len());
592+ let mut new_text: String = EDITOR_PLACEHOLDER.repeat(selections.len());
593+
594+ // Add terminal placeholders for each terminal selection
595+ let terminal_ranges: Vec<(String, std::ops::Range<usize>)> = terminal_selections
596+ .into_iter()
597+ .map(|text| {
598+ let start = new_text.len();
599+ new_text.push_str(TERMINAL_PLACEHOLDER);
600+ (text, start..(new_text.len() - 1))
601+ })
602+ .collect();
571603
572604 let callback = Arc::new({
573605 let source_range = source_range.clone();
574− move |_, window: &mut Window, cx: &mut App| {
606+ move |_: CompletionIntent, window: &mut Window, cx: &mut App| {
575607 let editor = editor.clone();
576608 let selections = selections.clone();
577609 let mention_set = mention_set.clone();
578610 let source_range = source_range.clone();
611+ let terminal_ranges = terminal_ranges.clone();
579612 window.defer(cx, move |window, cx| {
580613 if let Some(editor) = editor.upgrade() {
581− mention_set
582− .update(cx, |store, cx| {
583− store.confirm_mention_for_selection(
584− source_range,
585− selections,
586− editor,
587− window,
588− cx,
589− )
590− })
591− .ok();
614+ // Insert editor selections
615+ if !selections.is_empty() {
616+ mention_set
617+ .update(cx, |store, cx| {
618+ store.confirm_mention_for_selection(
619+ source_range.clone(),
620+ selections,
621+ editor.clone(),
622+ window,
623+ cx,
624+ )
625+ })
626+ .ok();
627+ }
628+
629+ // Insert terminal selections
630+ for (terminal_text, terminal_range) in terminal_ranges {
631+ let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx);
632+ let Some(start) =
633+ snapshot.as_singleton_anchor(source_range.start)
634+ else {
635+ return;
636+ };
637+ let offset = start.to_offset(&snapshot);
638+
639+ let mention_uri = MentionUri::TerminalSelection;
640+ let range = snapshot.anchor_after(offset + terminal_range.start)
641+ ..snapshot.anchor_after(offset + terminal_range.end);
642+
643+ let crease = crate::mention_set::crease_for_mention(
644+ mention_uri.name().into(),
645+ mention_uri.icon_path(cx),
646+ range,
647+ editor.downgrade(),
648+ );
649+
650+ let crease_id = editor.update(cx, |editor, cx| {
651+ let crease_ids =
652+ editor.insert_creases(vec![crease.clone()], cx);
653+ editor.fold_creases(vec![crease], false, window, cx);
654+ crease_ids.first().copied().unwrap()
655+ });
656+
657+ mention_set
658+ .update(cx, |mention_set, _| {
659+ mention_set.insert_mention(
660+ crease_id,
661+ mention_uri.clone(),
662+ gpui::Task::ready(Ok(
663+ crate::mention_set::Mention::Text {
664+ content: terminal_text,
665+ tracked_buffers: vec![],
666+ },
667+ ))
668+ .shared(),
669+ );
670+ })
671+ .ok();
672+ }
592673 }
593674 });
594675 false
595676 }
596677 });
597678
598− (new_text, callback)
679+ (
680+ new_text,
681+ callback
682+ as Arc<
683+ dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync,
684+ >,
685+ )
599686 }
600687 };
601688
@@ -1087,7 +1174,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> { · 485 unmodified lines
10871174 entries.push(PromptContextEntry::Mode(PromptContextType::Thread));
10881175 }
10891176
1090− let has_selection = workspace
1177+ let has_editor_selection = workspace
10911178 .read(cx)
10921179 .active_item(cx)
10931180 .and_then(|item| item.downcast::<Editor>())
@@ -1096,7 +1183,10 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> { · 2 unmodified lines
10961183 editor.has_non_empty_selection(&editor.display_snapshot(cx))
10971184 })
10981185 });
1099− if has_selection {
1186+
1187+ let has_terminal_selection = !terminal_selections_if_panel_open(workspace, cx).is_empty();
1188+
1189+ if has_editor_selection || has_terminal_selection {
11001190 entries.push(PromptContextEntry::Action(
11011191 PromptContextAction::AddSelections,
11021192 ));
@@ -2106,6 +2196,30 @@ fn build_code_label_for_path( · 1003 unmodified lines
21062196 label.build()
21072197 }
21082198
2199+/// Returns terminal selections from all terminal views if the terminal panel is open.
2200+fn terminal_selections_if_panel_open(workspace: &Entity<Workspace>, cx: &App) -> Vec<String> {
2201+ let Some(panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
2202+ return Vec::new();
2203+ };
2204+
2205+ // Check if the dock containing this panel is open
2206+ let position = match TerminalSettings::get_global(cx).dock {
2207+ TerminalDockPosition::Left => DockPosition::Left,
2208+ TerminalDockPosition::Bottom => DockPosition::Bottom,
2209+ TerminalDockPosition::Right => DockPosition::Right,
2210+ };
2211+ let dock_is_open = workspace
2212+ .read(cx)
2213+ .dock_at_position(position)
2214+ .read(cx)
2215+ .is_open();
2216+ if !dock_is_open {
2217+ return Vec::new();
2218+ }
2219+
2220+ panel.read(cx).terminal_selections(cx)
2221+}
2222+
21092223 fn selection_ranges(
21102224 workspace: &Entity<Workspace>,
21112225 cx: &mut App,
crates/agent_ui/src/mention_set.rs+4−0
@@ -249,6 +249,10 @@ impl MentionSet { · 248 unmodified lines
249249 debug_panic!("unexpected selection URI");
250250 Task::ready(Err(anyhow!("unexpected selection URI")))
251251 }
252+ MentionUri::TerminalSelection => {
253+ debug_panic!("unexpected terminal URI");
254+ Task::ready(Err(anyhow!("unexpected terminal URI")))
255+ }
252256 };
253257 let task = cx
254258 .spawn(async move |_, _| task.await.map_err(|e| e.to_string()))
crates/agent_ui/src/text_thread_editor.rs+119−9
@@ -64,8 +64,11 @@ use workspace::{ · 63 unmodified lines
6464 CollaboratorId,
6565 searchable::{Direction, SearchableItemHandle},
6666 };
67+
68+use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
6769 use workspace::{
6870 Save, Toast, Workspace,
71+ dock::Panel,
6972 item::{self, FollowableItem, Item},
7073 notifications::NotificationId,
7174 pane,
@@ -159,6 +162,14 @@ pub trait AgentPanelDelegate { · 87 unmodified lines
159162 window: &mut Window,
160163 cx: &mut Context<Workspace>,
161164 );
165+
166+ fn quote_terminal_text(
167+ &self,
168+ workspace: &mut Workspace,
169+ text: String,
170+ window: &mut Window,
171+ cx: &mut Context<Workspace>,
172+ );
162173 }
163174
164175 impl dyn AgentPanelDelegate {
@@ -1487,7 +1498,40 @@ impl TextThreadEditor { · 1322 unmodified lines
14871498 return;
14881499 };
14891500
1490− let Some((selections, buffer)) = maybe!({
1501+ // Try terminal selection first (requires focus, so more specific)
1502+ if let Some(terminal_text) = maybe!({
1503+ let terminal_panel = workspace.panel::<TerminalPanel>(cx)?;
1504+
1505+ if !terminal_panel
1506+ .read(cx)
1507+ .focus_handle(cx)
1508+ .contains_focused(window, cx)
1509+ {
1510+ return None;
1511+ }
1512+
1513+ let terminal_view = terminal_panel.read(cx).pane().and_then(|pane| {
1514+ pane.read(cx)
1515+ .active_item()
1516+ .and_then(|t| t.downcast::<TerminalView>())
1517+ })?;
1518+
1519+ terminal_view
1520+ .read(cx)
1521+ .terminal()
1522+ .read(cx)
1523+ .last_content
1524+ .selection_text
1525+ .clone()
1526+ }) {
1527+ if !terminal_text.is_empty() {
1528+ agent_panel_delegate.quote_terminal_text(workspace, terminal_text, window, cx);
1529+ return;
1530+ }
1531+ }
1532+
1533+ // Try editor selection
1534+ if let Some((selections, buffer)) = maybe!({
14911535 let editor = workspace
14921536 .active_item(cx)
14931537 .and_then(|item| item.act_as::<Editor>(cx))?;
@@ -1506,15 +1550,11 @@ impl TextThreadEditor { · 12 unmodified lines
15061550 .collect::<Vec<_>>()
15071551 });
15081552 Some((selections, buffer))
1509− }) else {
1510− return;
1511− };
1512−
1513− if selections.is_empty() {
1514− return;
1553+ }) {
1554+ if !selections.is_empty() {
1555+ agent_panel_delegate.quote_selection(workspace, selections, buffer, window, cx);
1556+ }
15151557 }
1516−
1517− agent_panel_delegate.quote_selection(workspace, selections, buffer, window, cx);
15181558 }
15191559
15201560 /// Handles the SendReviewToAgent action from the ProjectDiff toolbar.
@@ -1714,6 +1754,54 @@ impl TextThreadEditor { · 193 unmodified lines
17141754 })
17151755 }
17161756
1757+ pub fn quote_terminal_text(
1758+ &mut self,
1759+ text: String,
1760+ window: &mut Window,
1761+ cx: &mut Context<Self>,
1762+ ) {
1763+ let crease_title = "terminal".to_string();
1764+ let formatted_text = format!("```console\n{}\n```\n", text);
1765+
1766+ self.editor.update(cx, |editor, cx| {
1767+ // Insert newline first if not at the start of a line
1768+ let point = editor
1769+ .selections
1770+ .newest::<Point>(&editor.display_snapshot(cx))
1771+ .head();
1772+ if point.column > 0 {
1773+ editor.insert("\n", window, cx);
1774+ }
1775+
1776+ let point = editor
1777+ .selections
1778+ .newest::<Point>(&editor.display_snapshot(cx))
1779+ .head();
1780+ let start_row = MultiBufferRow(point.row);
1781+
1782+ editor.insert(&formatted_text, window, cx);
1783+
1784+ let snapshot = editor.buffer().read(cx).snapshot(cx);
1785+ let anchor_before = snapshot.anchor_after(point);
1786+ let anchor_after = editor
1787+ .selections
1788+ .newest_anchor()
1789+ .head()
1790+ .bias_left(&snapshot);
1791+
1792+ let fold_placeholder =
1793+ quote_selection_fold_placeholder(crease_title, cx.entity().downgrade());
1794+ let crease = Crease::inline(
1795+ anchor_before..anchor_after,
1796+ fold_placeholder,
1797+ render_quote_selection_output_toggle,
1798+ |_, _, _, _| Empty.into_any(),
1799+ );
1800+ editor.insert_creases(vec![crease], cx);
1801+ editor.fold_at(start_row, window, cx);
1802+ })
1803+ }
1804+
17171805 fn copy(&mut self, _: &editor::actions::Copy, _window: &mut Window, cx: &mut Context<Self>) {
17181806 if self.editor.read(cx).selections.count() == 1 {
17191807 let (copied_text, metadata, _) = self.get_clipboard_contents(cx);
@@ -3548,4 +3636,26 @@ mod tests { · 1828 unmodified lines
35483636
35493637 theme::init(theme::LoadThemes::JustBase, cx);
35503638 }
3639+
3640+ #[gpui::test]
3641+ async fn test_quote_terminal_text(cx: &mut TestAppContext) {
3642+ let (_context, text_thread_editor, mut cx) =
3643+ setup_text_thread_editor_text(vec![(Role::User, "")], cx).await;
3644+
3645+ let terminal_output = "$ ls -la\ntotal 0\ndrwxr-xr-x 2 user user 40 Jan 1 00:00 .";
3646+
3647+ text_thread_editor.update_in(&mut cx, |text_thread_editor, window, cx| {
3648+ text_thread_editor.quote_terminal_text(terminal_output.to_string(), window, cx);
3649+
3650+ text_thread_editor.editor.update(cx, |editor, cx| {
3651+ let text = editor.text(cx);
3652+ // The text should contain the terminal output wrapped in a code block
3653+ assert!(
3654+ text.contains(&format!("```console\n{}\n```", terminal_output)),
3655+ "Terminal text should be wrapped in code block. Got: {}",
3656+ text
3657+ );
3658+ });
3659+ });
3660+ }
35513661 }
crates/terminal_view/src/terminal_panel.rs+26−0
@@ -1084,6 +1084,32 @@ impl TerminalPanel { · 1083 unmodified lines
10841084 self.assistant_enabled
10851085 }
10861086
1087+ /// Returns all panes in the terminal panel.
1088+ pub fn panes(&self) -> Vec<&Entity<Pane>> {
1089+ self.center.panes()
1090+ }
1091+
1092+ /// Returns all non-empty terminal selections from all terminal views in all panes.
1093+ pub fn terminal_selections(&self, cx: &App) -> Vec<String> {
1094+ self.center
1095+ .panes()
1096+ .iter()
1097+ .flat_map(|pane| {
1098+ pane.read(cx).items().filter_map(|item| {
1099+ let terminal_view = item.downcast::<crate::TerminalView>()?;
1100+ terminal_view
1101+ .read(cx)
1102+ .terminal()
1103+ .read(cx)
1104+ .last_content
1105+ .selection_text
1106+ .clone()
1107+ .filter(|text| !text.is_empty())
1108+ })
1109+ })
1110+ .collect()
1111+ }
1112+
10871113 fn is_enabled(&self, cx: &App) -> bool {
10881114 self.workspace
10891115 .upgrade()
crates/terminal_view/src/terminal_view.rs+19−10
@@ -19,6 +19,14 @@ use project::{Project, search::SearchQuery}; · 18 unmodified lines
1919 use schemars::JsonSchema;
2020 use serde::Deserialize;
2121 use settings::{Settings, SettingsStore, TerminalBlink, WorkingDirectory};
22+use std::{
23+ cmp,
24+ ops::{Range, RangeInclusive},
25+ path::{Path, PathBuf},
26+ rc::Rc,
27+ sync::Arc,
28+ time::Duration,
29+};
2230 use task::TaskId;
2331 use terminal::{
2432 Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Paste, ScrollLineDown, ScrollLineUp,
@@ -50,16 +58,7 @@ use workspace::{ · 25 unmodified lines
5058 register_serializable_item,
5159 searchable::{Direction, SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle},
5260 };
53−use zed_actions::assistant::InlineAssist;
54−
55−use std::{
56− cmp,
57− ops::{Range, RangeInclusive},
58− path::{Path, PathBuf},
59− rc::Rc,
60− sync::Arc,
61− time::Duration,
62−};
61+use zed_actions::{agent::AddSelectionToThread, assistant::InlineAssist};
6362
6463 struct ImeState {
6564 marked_text: String,
@@ -496,6 +495,13 @@ impl TerminalView { · 430 unmodified lines
496495 .upgrade()
497496 .and_then(|workspace| workspace.read(cx).panel::<TerminalPanel>(cx))
498497 .is_some_and(|terminal_panel| terminal_panel.read(cx).assistant_enabled());
498+ let has_selection = self
499+ .terminal
500+ .read(cx)
501+ .last_content
502+ .selection_text
503+ .as_ref()
504+ .is_some_and(|text| !text.is_empty());
499505 let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
500506 menu.context(self.focus_handle.clone())
501507 .action("New Terminal", Box::new(NewTerminal::default()))
@@ -507,6 +513,9 @@ impl TerminalView { · 5 unmodified lines
507513 .when(assistant_enabled, |menu| {
508514 menu.separator()
509515 .action("Inline Assist", Box::new(InlineAssist::default()))
516+ .when(has_selection, |menu| {
517+ menu.action("Add to Agent Thread", Box::new(AddSelectionToThread))
518+ })
510519 })
511520 .separator()
512521 .action(