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
1132 "ctrl-alt-r": "terminal::RerunTask",1132 "ctrl-alt-r": "terminal::RerunTask",
1133 "alt-t": "terminal::RerunTask",1133 "alt-t": "terminal::RerunTask",
1134 "ctrl-shift-5": "pane::SplitRight",1134 "ctrl-shift-5": "pane::SplitRight",
1135 "ctrl->": "agent::AddSelectionToThread",
1135 },1136 },
1136 },1137 },
1137 {1138 {
assets/keymaps/default-macos.json+1−0
@@ -1211,6 +1211,7 @@ · 1210 unmodified lines
1211 "ctrl-alt-right": "pane::SplitRight",1211 "ctrl-alt-right": "pane::SplitRight",
1212 "cmd-d": "pane::SplitRight",1212 "cmd-d": "pane::SplitRight",
1213 "cmd-alt-r": "terminal::RerunTask",1213 "cmd-alt-r": "terminal::RerunTask",
1214 "cmd->": "agent::AddSelectionToThread",
1214 },1215 },
1215 },1216 },
1216 {1217 {
assets/keymaps/default-windows.json+1−0
@@ -1150,6 +1150,7 @@ · 1149 unmodified lines
1150 "ctrl-alt-r": "terminal::RerunTask",1150 "ctrl-alt-r": "terminal::RerunTask",
1151 "alt-t": "terminal::RerunTask",1151 "alt-t": "terminal::RerunTask",
1152 "ctrl-shift-5": "pane::SplitRight",1152 "ctrl-shift-5": "pane::SplitRight",
1153 "ctrl-shift-.": "agent::AddSelectionToThread",
1153 },1154 },
1154 },1155 },
1155 {1156 {
crates/acp_thread/src/mention.rs+18−0
@@ -54,6 +54,7 @@ pub enum MentionUri { · 53 unmodified lines
54 Fetch {54 Fetch {
55 url: Url,55 url: Url,
56 },56 },
57 TerminalSelection,
57}58}
58 59
59impl MentionUri {60impl MentionUri {
@@ -199,6 +200,8 @@ impl MentionUri { · 139 unmodified lines
199 abs_path: Some(path.into()),200 abs_path: Some(path.into()),
200 line_range,201 line_range,
201 })202 })
203 } else if path.starts_with("/agent/terminal-selection") {
204 Ok(Self::TerminalSelection)
202 } else {205 } else {
203 bail!("invalid zed url: {:?}", input);206 bail!("invalid zed url: {:?}", input);
204 }207 }
@@ -221,6 +224,7 @@ impl MentionUri { · 16 unmodified lines
221 MentionUri::TextThread { name, .. } => name.clone(),224 MentionUri::TextThread { name, .. } => name.clone(),
222 MentionUri::Rule { name, .. } => name.clone(),225 MentionUri::Rule { name, .. } => name.clone(),
223 MentionUri::Diagnostics { .. } => "Diagnostics".to_string(),226 MentionUri::Diagnostics { .. } => "Diagnostics".to_string(),
227 MentionUri::TerminalSelection => "Terminal".to_string(),
224 MentionUri::Selection {228 MentionUri::Selection {
225 abs_path: path,229 abs_path: path,
226 line_range,230 line_range,
@@ -243,6 +247,7 @@ impl MentionUri { · 16 unmodified lines
243 MentionUri::TextThread { .. } => IconName::Thread.path().into(),247 MentionUri::TextThread { .. } => IconName::Thread.path().into(),
244 MentionUri::Rule { .. } => IconName::Reader.path().into(),248 MentionUri::Rule { .. } => IconName::Reader.path().into(),
245 MentionUri::Diagnostics { .. } => IconName::Warning.path().into(),249 MentionUri::Diagnostics { .. } => IconName::Warning.path().into(),
250 MentionUri::TerminalSelection => IconName::Terminal.path().into(),
246 MentionUri::Selection { .. } => IconName::Reader.path().into(),251 MentionUri::Selection { .. } => IconName::Reader.path().into(),
247 MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(),252 MentionUri::Fetch { .. } => IconName::ToolWeb.path().into(),
248 }253 }
@@ -337,6 +342,7 @@ impl MentionUri { · 88 unmodified lines
337 url342 url
338 }343 }
339 MentionUri::Fetch { url } => url.clone(),344 MentionUri::Fetch { url } => url.clone(),
345 MentionUri::TerminalSelection => Url::parse("zed:///agent/terminal-selection").unwrap(),
340 }346 }
341 }347 }
342}348}
@@ -641,4 +647,16 @@ mod tests { · 298 unmodified lines
641 _ => panic!("Expected Selection variant"),647 _ => panic!("Expected Selection variant"),
642 }648 }
643 }649 }
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 }
644}662}
crates/agent/src/thread.rs+11−0
@@ -317,6 +317,17 @@ impl UserMessage { · 316 unmodified lines
317 MentionUri::Diagnostics { .. } => {317 MentionUri::Diagnostics { .. } => {
318 write!(&mut diagnostics_context, "\n{}\n", content).ok();318 write!(&mut diagnostics_context, "\n{}\n", content).ok();
319 }319 }
320 MentionUri::TerminalSelection => {
321 write!(
322 &mut selection_context,
323 "\n{}",
324 MarkdownCodeBlock {
325 tag: "console",
326 text: content
327 }
328 )
329 .ok();
330 }
320 }331 }
321 332
322 language_model::MessageContent::Text(uri.as_link().to_string())333 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
1002 creases: Vec<(String, String)>,1002 creases: Vec<(String, String)>,
1003 window: &mut Window,1003 window: &mut Window,
1004 cx: &mut Context<Self>,1004 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>,
1005 ) {1074 ) {
1006 use editor::display_map::{Crease, FoldPlaceholder};1075 use editor::display_map::{Crease, FoldPlaceholder};
1007 use multi_buffer::MultiBufferRow;1076 use multi_buffer::MultiBufferRow;
1008 use rope::Point;1077 use rope::Point;
1009 1078
1010 self.editor.update(cx, |editor, cx| {1079 self.editor.update(cx, |editor, cx| {
1011 editor.insert("\n", window, cx);1080 let point = editor
1012 for (text, crease_title) in creases {1081 .selections
1013 let point = editor1082 .newest::<Point>(&editor.display_snapshot(cx))
1014 .selections1083 .head();
1015 .newest::<Point>(&editor.display_snapshot(cx))1084 let start_row = MultiBufferRow(point.row);
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);
1028 1085
1029 editor.insert("\n", window, cx);1086 editor.insert(&text, window, cx);
1030 1087
1031 let fold_placeholder = FoldPlaceholder {1088 let snapshot = editor.buffer().read(cx).snapshot(cx);
1032 render: Arc::new({1089 let anchor_before = snapshot.anchor_after(point);
1033 let title = crease_title.clone();1090 let anchor_after = editor
1034 move |_fold_id, _fold_range, _cx| {1091 .selections
1035 ButtonLike::new("code-crease")1092 .newest_anchor()
1036 .style(ButtonStyle::Filled)1093 .head()
1037 .layer(ElevationIndex::ElevatedSurface)1094 .bias_left(&snapshot);
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 };
1046 1095
1047 let crease = Crease::inline(1096 if add_trailing_newline {
1048 anchor_before..anchor_after,1097 editor.insert("\n", window, cx);
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);
1060 }1098 }
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);
1061 });1129 });
1062 }1130 }
1063 1131
crates/agent_ui/src/acp/thread_view.rs+13−0
@@ -6870,6 +6870,7 @@ impl AcpThreadView { · 6869 unmodified lines
6870 cx.open_url(url.as_str());6870 cx.open_url(url.as_str());
6871 }6871 }
6872 MentionUri::Diagnostics { .. } => {}6872 MentionUri::Diagnostics { .. } => {}
6873 MentionUri::TerminalSelection => {}
6873 })6874 })
6874 } else {6875 } else {
6875 cx.open_url(&url);6876 cx.open_url(&url);
@@ -7651,6 +7652,18 @@ impl AcpThreadView { · 775 unmodified lines
7651 });7652 });
7652 }7653 }
7653 7654
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
7654 /// Inserts code snippets as creases into the message editor.7667 /// Inserts code snippets as creases into the message editor.
7655 pub(crate) fn insert_code_crease(7668 pub(crate) fn insert_code_crease(
7656 &self,7669 &self,
crates/agent_ui/src/agent_panel.rs+32−0
@@ -2964,6 +2964,38 @@ impl AgentPanelDelegate for ConcreteAssistantPanelDelegate { · 2963 unmodified lines
2964 });2964 });
2965 });2965 });
2966 }2966 }
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 }
2967}2999}
2968 3000
2969struct OnboardingUpsell;3001struct OnboardingUpsell;
crates/agent_ui/src/completion_provider.rs+134−20
@@ -13,10 +13,12 @@ use editor::{ · 12 unmodified lines
13 CompletionProvider, Editor, ExcerptId, code_context_menus::COMPLETION_MENU_MAX_WIDTH,13 CompletionProvider, Editor, ExcerptId, code_context_menus::COMPLETION_MENU_MAX_WIDTH,
14};14};
15use feature_flags::{FeatureFlagAppExt as _, UserSlashCommandsFeatureFlag};15use feature_flags::{FeatureFlagAppExt as _, UserSlashCommandsFeatureFlag};
16use futures::FutureExt as _;
16use fuzzy::{PathMatch, StringMatch, StringMatchCandidate};17use fuzzy::{PathMatch, StringMatch, StringMatchCandidate};
17use gpui::{App, BackgroundExecutor, Entity, SharedString, Task, WeakEntity};18use gpui::{App, BackgroundExecutor, Entity, SharedString, Task, WeakEntity};
18use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId};19use language::{Buffer, CodeLabel, CodeLabelBuilder, HighlightId};
19use lsp::CompletionContext;20use lsp::CompletionContext;
21use multi_buffer::ToOffset as _;
20use ordered_float::OrderedFloat;22use ordered_float::OrderedFloat;
21use project::lsp_store::{CompletionDocumentation, SymbolLocation};23use project::lsp_store::{CompletionDocumentation, SymbolLocation};
22use project::{24use project::{
@@ -25,7 +27,10 @@ use project::{ · 2 unmodified lines
25};27};
26use prompt_store::{PromptStore, UserPromptId};28use prompt_store::{PromptStore, UserPromptId};
27use rope::Point;29use rope::Point;
28use text::{Anchor, ToPoint as _};30use settings::{Settings, TerminalDockPosition};
31use terminal::terminal_settings::TerminalSettings;
32use terminal_view::terminal_panel::TerminalPanel;
33use text::{Anchor, ToOffset as _, ToPoint as _};
29use ui::IconName;34use ui::IconName;
30use ui::prelude::*;35use ui::prelude::*;
31use util::ResultExt as _;36use util::ResultExt as _;
@@ -33,6 +38,7 @@ use util::paths::PathStyle; · 1 unmodified line
33use util::rel_path::RelPath;38use util::rel_path::RelPath;
34use util::truncate_and_remove_front;39use util::truncate_and_remove_front;
35use workspace::Workspace;40use workspace::Workspace;
41use workspace::dock::DockPosition;
36 42
37use crate::AgentPanel;43use crate::AgentPanel;
38use crate::mention_set::MentionSet;44use crate::mention_set::MentionSet;
@@ -554,48 +560,129 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> { · 515 unmodified lines
554 ) -> Option<Completion> {560 ) -> Option<Completion> {
555 let (new_text, on_action) = match action {561 let (new_text, on_action) = match action {
556 PromptContextAction::AddSelections => {562 PromptContextAction::AddSelections => {
557 const PLACEHOLDER: &str = "selection ";563 // Collect non-empty editor selections
558 let selections = selection_ranges(workspace, cx)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
559 .into_iter()580 .into_iter()
560 .enumerate()581 .enumerate()
561 .map(|(ix, (buffer, range))| {582 .map(|(ix, (buffer, range))| {
562 (583 (
563 buffer,584 buffer,
564 range,585 range,
565 (PLACEHOLDER.len() * ix)..(PLACEHOLDER.len() * (ix + 1) - 1),586 (EDITOR_PLACEHOLDER.len() * ix)
587 ..(EDITOR_PLACEHOLDER.len() * (ix + 1) - 1),
566 )588 )
567 })589 })
568 .collect::<Vec<_>>();590 .collect::<Vec<_>>();
569 591
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();
571 603
572 let callback = Arc::new({604 let callback = Arc::new({
573 let source_range = source_range.clone();605 let source_range = source_range.clone();
574 move |_, window: &mut Window, cx: &mut App| {606 move |_: CompletionIntent, window: &mut Window, cx: &mut App| {
575 let editor = editor.clone();607 let editor = editor.clone();
576 let selections = selections.clone();608 let selections = selections.clone();
577 let mention_set = mention_set.clone();609 let mention_set = mention_set.clone();
578 let source_range = source_range.clone();610 let source_range = source_range.clone();
611 let terminal_ranges = terminal_ranges.clone();
579 window.defer(cx, move |window, cx| {612 window.defer(cx, move |window, cx| {
580 if let Some(editor) = editor.upgrade() {613 if let Some(editor) = editor.upgrade() {
581 mention_set614 // Insert editor selections
582 .update(cx, |store, cx| {615 if !selections.is_empty() {
583 store.confirm_mention_for_selection(616 mention_set
584 source_range,617 .update(cx, |store, cx| {
585 selections,618 store.confirm_mention_for_selection(
586 editor,619 source_range.clone(),
587 window,620 selections,
588 cx,621 editor.clone(),
589 )622 window,
590 })623 cx,
591 .ok();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 }
592 }673 }
593 });674 });
594 false675 false
595 }676 }
596 });677 });
597 678
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 )
599 }686 }
600 };687 };
601 688
@@ -1087,7 +1174,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> { · 485 unmodified lines
1087 entries.push(PromptContextEntry::Mode(PromptContextType::Thread));1174 entries.push(PromptContextEntry::Mode(PromptContextType::Thread));
1088 }1175 }
1089 1176
1090 let has_selection = workspace1177 let has_editor_selection = workspace
1091 .read(cx)1178 .read(cx)
1092 .active_item(cx)1179 .active_item(cx)
1093 .and_then(|item| item.downcast::<Editor>())1180 .and_then(|item| item.downcast::<Editor>())
@@ -1096,7 +1183,10 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> { · 2 unmodified lines
1096 editor.has_non_empty_selection(&editor.display_snapshot(cx))1183 editor.has_non_empty_selection(&editor.display_snapshot(cx))
1097 })1184 })
1098 });1185 });
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 {
1100 entries.push(PromptContextEntry::Action(1190 entries.push(PromptContextEntry::Action(
1101 PromptContextAction::AddSelections,1191 PromptContextAction::AddSelections,
1102 ));1192 ));
@@ -2106,6 +2196,30 @@ fn build_code_label_for_path( · 1003 unmodified lines
2106 label.build()2196 label.build()
2107}2197}
2108 2198
2199/// Returns terminal selections from all terminal views if the terminal panel is open.
2200fn 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
2109fn selection_ranges(2223fn selection_ranges(
2110 workspace: &Entity<Workspace>,2224 workspace: &Entity<Workspace>,
2111 cx: &mut App,2225 cx: &mut App,
crates/agent_ui/src/mention_set.rs+4−0
@@ -249,6 +249,10 @@ impl MentionSet { · 248 unmodified lines
249 debug_panic!("unexpected selection URI");249 debug_panic!("unexpected selection URI");
250 Task::ready(Err(anyhow!("unexpected selection URI")))250 Task::ready(Err(anyhow!("unexpected selection URI")))
251 }251 }
252 MentionUri::TerminalSelection => {
253 debug_panic!("unexpected terminal URI");
254 Task::ready(Err(anyhow!("unexpected terminal URI")))
255 }
252 };256 };
253 let task = cx257 let task = cx
254 .spawn(async move |_, _| task.await.map_err(|e| e.to_string()))258 .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
64 CollaboratorId,64 CollaboratorId,
65 searchable::{Direction, SearchableItemHandle},65 searchable::{Direction, SearchableItemHandle},
66};66};
67
68use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
67use workspace::{69use workspace::{
68 Save, Toast, Workspace,70 Save, Toast, Workspace,
71 dock::Panel,
69 item::{self, FollowableItem, Item},72 item::{self, FollowableItem, Item},
70 notifications::NotificationId,73 notifications::NotificationId,
71 pane,74 pane,
@@ -159,6 +162,14 @@ pub trait AgentPanelDelegate { · 87 unmodified lines
159 window: &mut Window,162 window: &mut Window,
160 cx: &mut Context<Workspace>,163 cx: &mut Context<Workspace>,
161 );164 );
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 );
162}173}
163 174
164impl dyn AgentPanelDelegate {175impl dyn AgentPanelDelegate {
@@ -1487,7 +1498,40 @@ impl TextThreadEditor { · 1322 unmodified lines
1487 return;1498 return;
1488 };1499 };
1489 1500
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!({
1491 let editor = workspace1535 let editor = workspace
1492 .active_item(cx)1536 .active_item(cx)
1493 .and_then(|item| item.act_as::<Editor>(cx))?;1537 .and_then(|item| item.act_as::<Editor>(cx))?;
@@ -1506,15 +1550,11 @@ impl TextThreadEditor { · 12 unmodified lines
1506 .collect::<Vec<_>>()1550 .collect::<Vec<_>>()
1507 });1551 });
1508 Some((selections, buffer))1552 Some((selections, buffer))
1509 }) else {1553 }) {
1510 return;1554 if !selections.is_empty() {
1511 };1555 agent_panel_delegate.quote_selection(workspace, selections, buffer, window, cx);
1512 1556 }
1513 if selections.is_empty() {
1514 return;
1515 }1557 }
1516
1517 agent_panel_delegate.quote_selection(workspace, selections, buffer, window, cx);
1518 }1558 }
1519 1559
1520 /// Handles the SendReviewToAgent action from the ProjectDiff toolbar.1560 /// Handles the SendReviewToAgent action from the ProjectDiff toolbar.
@@ -1714,6 +1754,54 @@ impl TextThreadEditor { · 193 unmodified lines
1714 })1754 })
1715 }1755 }
1716 1756
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
1717 fn copy(&mut self, _: &editor::actions::Copy, _window: &mut Window, cx: &mut Context<Self>) {1805 fn copy(&mut self, _: &editor::actions::Copy, _window: &mut Window, cx: &mut Context<Self>) {
1718 if self.editor.read(cx).selections.count() == 1 {1806 if self.editor.read(cx).selections.count() == 1 {
1719 let (copied_text, metadata, _) = self.get_clipboard_contents(cx);1807 let (copied_text, metadata, _) = self.get_clipboard_contents(cx);
@@ -3548,4 +3636,26 @@ mod tests { · 1828 unmodified lines
3548 3636
3549 theme::init(theme::LoadThemes::JustBase, cx);3637 theme::init(theme::LoadThemes::JustBase, cx);
3550 }3638 }
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 }
3551}3661}
crates/terminal_view/src/terminal_panel.rs+26−0
@@ -1084,6 +1084,32 @@ impl TerminalPanel { · 1083 unmodified lines
1084 self.assistant_enabled1084 self.assistant_enabled
1085 }1085 }
1086 1086
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
1087 fn is_enabled(&self, cx: &App) -> bool {1113 fn is_enabled(&self, cx: &App) -> bool {
1088 self.workspace1114 self.workspace
1089 .upgrade()1115 .upgrade()
crates/terminal_view/src/terminal_view.rs+19−10
@@ -19,6 +19,14 @@ use project::{Project, search::SearchQuery}; · 18 unmodified lines
19use schemars::JsonSchema;19use schemars::JsonSchema;
20use serde::Deserialize;20use serde::Deserialize;
21use settings::{Settings, SettingsStore, TerminalBlink, WorkingDirectory};21use settings::{Settings, SettingsStore, TerminalBlink, WorkingDirectory};
22use std::{
23 cmp,
24 ops::{Range, RangeInclusive},
25 path::{Path, PathBuf},
26 rc::Rc,
27 sync::Arc,
28 time::Duration,
29};
22use task::TaskId;30use task::TaskId;
23use terminal::{31use terminal::{
24 Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Paste, ScrollLineDown, ScrollLineUp,32 Clear, Copy, Event, HoveredWord, MaybeNavigationTarget, Paste, ScrollLineDown, ScrollLineUp,
@@ -50,16 +58,7 @@ use workspace::{ · 25 unmodified lines
50 register_serializable_item,58 register_serializable_item,
51 searchable::{Direction, SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle},59 searchable::{Direction, SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle},
52};60};
53use zed_actions::assistant::InlineAssist;61use zed_actions::{agent::AddSelectionToThread, assistant::InlineAssist};
54
55use std::{
56 cmp,
57 ops::{Range, RangeInclusive},
58 path::{Path, PathBuf},
59 rc::Rc,
60 sync::Arc,
61 time::Duration,
62};
63 62
64struct ImeState {63struct ImeState {
65 marked_text: String,64 marked_text: String,
@@ -496,6 +495,13 @@ impl TerminalView { · 430 unmodified lines
496 .upgrade()495 .upgrade()
497 .and_then(|workspace| workspace.read(cx).panel::<TerminalPanel>(cx))496 .and_then(|workspace| workspace.read(cx).panel::<TerminalPanel>(cx))
498 .is_some_and(|terminal_panel| terminal_panel.read(cx).assistant_enabled());497 .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());
499 let context_menu = ContextMenu::build(window, cx, |menu, _, _| {505 let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
500 menu.context(self.focus_handle.clone())506 menu.context(self.focus_handle.clone())
501 .action("New Terminal", Box::new(NewTerminal::default()))507 .action("New Terminal", Box::new(NewTerminal::default()))
@@ -507,6 +513,9 @@ impl TerminalView { · 5 unmodified lines
507 .when(assistant_enabled, |menu| {513 .when(assistant_enabled, |menu| {
508 menu.separator()514 menu.separator()
509 .action("Inline Assist", Box::new(InlineAssist::default()))515 .action("Inline Assist", Box::new(InlineAssist::default()))
516 .when(has_selection, |menu| {
517 menu.action("Add to Agent Thread", Box::new(AddSelectionToThread))
518 })
510 })519 })
511 .separator()520 .separator()
512 .action(521 .action(