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

Rutger Cappendijk and Ben Brandt created

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

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>

Change summary

assets/keymaps/default-linux.json          |   1 
assets/keymaps/default-macos.json          |   1 
assets/keymaps/default-windows.json        |   1 
crates/acp_thread/src/mention.rs           |  18 ++
crates/agent/src/thread.rs                 |  11 +
crates/agent_ui/src/acp/message_editor.rs  | 160 +++++++++++++++++------
crates/agent_ui/src/acp/thread_view.rs     |  13 +
crates/agent_ui/src/agent_panel.rs         |  32 ++++
crates/agent_ui/src/completion_provider.rs | 154 ++++++++++++++++++++---
crates/agent_ui/src/mention_set.rs         |   4 
crates/agent_ui/src/text_thread_editor.rs  | 128 +++++++++++++++++-
crates/terminal_view/src/terminal_panel.rs |  26 +++
crates/terminal_view/src/terminal_view.rs  |  29 ++-
13 files changed, 493 insertions(+), 85 deletions(-)

Detailed changes

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",
     },
   },
   {

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",
     },
   },
   {

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",
     },
   },
   {

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");
+    }
 }

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())

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>,
+    ) {
+        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<Self>,
+    ) {
+        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<Self>,
     ) {
         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::<Point>(&editor.display_snapshot(cx))
-                    .head();
-                let start_row = MultiBufferRow(point.row);
-
-                editor.insert(&text, window, cx);
-
-                let snapshot = editor.buffer().read(cx).snapshot(cx);
-                let anchor_before = snapshot.anchor_after(point);
-                let anchor_after = editor
-                    .selections
-                    .newest_anchor()
-                    .head()
-                    .bias_left(&snapshot);
+            let point = editor
+                .selections
+                .newest::<Point>(&editor.display_snapshot(cx))
+                .head();
+            let start_row = MultiBufferRow(point.row);
 
-                editor.insert("\n", window, cx);
+            editor.insert(&text, window, cx);
 
-                let fold_placeholder = FoldPlaceholder {
-                    render: Arc::new({
-                        let title = crease_title.clone();
-                        move |_fold_id, _fold_range, _cx| {
-                            ButtonLike::new("code-crease")
-                                .style(ButtonStyle::Filled)
-                                .layer(ElevationIndex::ElevatedSurface)
-                                .child(Icon::new(IconName::TextSnippet))
-                                .child(Label::new(title.clone()).single_line())
-                                .into_any_element()
-                        }
-                    }),
-                    merge_adjacent: false,
-                    ..Default::default()
-                };
+            let snapshot = editor.buffer().read(cx).snapshot(cx);
+            let anchor_before = snapshot.anchor_after(point);
+            let anchor_after = editor
+                .selections
+                .newest_anchor()
+                .head()
+                .bias_left(&snapshot);
 
-                let crease = Crease::inline(
-                    anchor_before..anchor_after,
-                    fold_placeholder,
-                    |row, is_folded, fold, _window, _cx| {
-                        Disclosure::new(("code-crease-toggle", row.0 as u64), !is_folded)
-                            .toggle_state(is_folded)
-                            .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
-                            .into_any_element()
-                    },
-                    |_, _, _, _| gpui::Empty.into_any(),
-                );
-                editor.insert_creases(vec![crease], cx);
-                editor.fold_at(start_row, window, cx);
+            if add_trailing_newline {
+                editor.insert("\n", window, cx);
             }
+
+            let fold_placeholder = FoldPlaceholder {
+                render: Arc::new({
+                    let title = title.clone();
+                    move |_fold_id, _fold_range, _cx| {
+                        ButtonLike::new("crease")
+                            .style(ButtonStyle::Filled)
+                            .layer(ElevationIndex::ElevatedSurface)
+                            .child(Icon::new(icon))
+                            .child(Label::new(title.clone()).single_line())
+                            .into_any_element()
+                    }
+                }),
+                merge_adjacent: false,
+                ..Default::default()
+            };
+
+            let crease = Crease::inline(
+                anchor_before..anchor_after,
+                fold_placeholder,
+                |row, is_folded, fold, _window, _cx| {
+                    Disclosure::new(("crease-toggle", row.0 as u64), !is_folded)
+                        .toggle_state(is_folded)
+                        .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
+                        .into_any_element()
+                },
+                |_, _, _, _| gpui::Empty.into_any(),
+            );
+            editor.insert_creases(vec![crease], cx);
+            editor.fold_at(start_row, window, cx);
         });
     }
 

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>,
+    ) {
+        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,

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<Workspace>,
+    ) {
+        let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
+            return;
+        };
+
+        if !panel.focus_handle(cx).contains_focused(window, cx) {
+            workspace.toggle_panel_focus::<AgentPanel>(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;

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<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
     ) -> Option<Completion> {
         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<String> =
+                    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::<Vec<_>>();
 
-                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<usize>)> = 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<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
             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::<Editor>())
@@ -1096,7 +1183,10 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
                     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<Workspace>, cx: &App) -> Vec<String> {
+    let Some(panel) = workspace.read(cx).panel::<TerminalPanel>(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<Workspace>,
     cx: &mut App,

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()))

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<Workspace>,
     );
+
+    fn quote_terminal_text(
+        &self,
+        workspace: &mut Workspace,
+        text: String,
+        window: &mut Window,
+        cx: &mut Context<Workspace>,
+    );
 }
 
 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::<TerminalPanel>(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::<TerminalView>())
+            })?;
+
+            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::<Editor>(cx))?;
@@ -1506,15 +1550,11 @@ impl TextThreadEditor {
                     .collect::<Vec<_>>()
             });
             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<Self>,
+    ) {
+        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::<Point>(&editor.display_snapshot(cx))
+                .head();
+            if point.column > 0 {
+                editor.insert("\n", window, cx);
+            }
+
+            let point = editor
+                .selections
+                .newest::<Point>(&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<Self>) {
         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
+                );
+            });
+        });
+    }
 }

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<Pane>> {
+        self.center.panes()
+    }
+
+    /// Returns all non-empty terminal selections from all terminal views in all panes.
+    pub fn terminal_selections(&self, cx: &App) -> Vec<String> {
+        self.center
+            .panes()
+            .iter()
+            .flat_map(|pane| {
+                pane.read(cx).items().filter_map(|item| {
+                    let terminal_view = item.downcast::<crate::TerminalView>()?;
+                    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()

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::<TerminalPanel>(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(