Skip to content
12 changes: 12 additions & 0 deletions crates/acp_thread/src/mention.rs
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,18 @@ pub fn selection_name(path: Option<&Path>, line_range: &RangeInclusive<u32>) ->
)
}

/// Formats a 0-based, inclusive line range as a 1-based path suffix: `:5` for a
/// single line or `:5-9` for a span. Used for `path:line` mentions in text.
pub fn line_range_suffix(line_range: &RangeInclusive<u32>) -> String {
let start = *line_range.start() + 1;
let end = *line_range.end() + 1;
if start == end {
format!(":{start}")
} else {
format!(":{start}-{end}")
}
}

#[cfg(test)]
mod tests {
use util::{path, uri};
Expand Down
223 changes: 220 additions & 3 deletions crates/agent_ui/src/agent_panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use std::{
time::Duration,
};

use acp_thread::{AcpThread, AcpThreadEvent, MentionUri, ThreadStatus};
use acp_thread::{AcpThread, AcpThreadEvent, MentionUri, ThreadStatus, line_range_suffix};
use agent::{ContextServerRegistry, SharedThread, ThreadStore};
use agent_client_protocol::schema as acp;
use agent_servers::AgentServer;
Expand Down Expand Up @@ -38,7 +38,7 @@ use zed_actions::{
use crate::ExpandMessageEditor;
use crate::ManageProfiles;
use crate::agent_connection_store::AgentConnectionStore;
use crate::completion_provider::AgentContextSource;
use crate::completion_provider::{AgentContextSelection, AgentContextSource};
use crate::terminal_thread_metadata_store::{
TerminalThreadMetadata, TerminalThreadMetadataStore, compose_terminal_thread_title,
terminal_title_without_prefix,
Expand Down Expand Up @@ -81,12 +81,13 @@ use gpui::{
};
use language::LanguageRegistry;
use language_model::LanguageModelRegistry;
use project::{Project, ProjectPath, Worktree};
use project::{Project, ProjectItem, ProjectPath, Worktree};
use settings::TerminalDockPosition;
use settings::{NotifyWhenAgentWaiting, Settings, update_settings_file};
use skill_creator::{SkillCreatorOpenMode, is_supported_skill_url, open_skill_creator};
use terminal::{Event as TerminalEvent, terminal_settings::TerminalSettings};
use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
use text::OffsetRangeExt;
use theme_settings::ThemeSettings;
use ui::{
Button, ContextMenu, ContextMenuEntry, GradientFade, IconButton, KeyBinding, PopoverMenu,
Expand Down Expand Up @@ -708,6 +709,32 @@ pub fn init(cx: &mut App) {
conversation_view.update(cx, |conversation_view, cx| {
conversation_view.insert_selection(selection, window, cx);
});
} else if let Some(terminal_id) = panel.active_terminal_id()
&& let Some(agent_terminal) = panel.terminals.get(&terminal_id)
{
// Resolve mentions against the cwd: live cwd, else spawn dir.
let working_directory = agent_terminal
.view
.read(cx)
.terminal()
.read(cx)
.working_directory()
.or_else(|| agent_terminal.working_directory.clone());
let text = format_selection_for_terminal(
&selection,
&panel.project,
working_directory.as_deref(),
cx,
);
if !text.is_empty() {
let view = agent_terminal.view.clone();
view.update(cx, |view, cx| {
view.terminal().update(cx, |terminal, _| {
terminal.paste(&text);
});
window.focus(&view.focus_handle(cx), cx);
});
}
}
});
});
Expand All @@ -718,6 +745,63 @@ pub fn init(cx: &mut App) {
.detach();
}

fn format_selection_for_terminal(
selection: &AgentContextSelection,
project: &Entity<Project>,
working_directory: Option<&std::path::Path>,
cx: &App,
) -> String {
match selection {
AgentContextSelection::Editor(ranges) => {
let path_style = project.read(cx).path_style(cx);
let mut parts: Vec<String> = Vec::new();
for (buffer, range) in ranges {
let buffer = buffer.read(cx);
let Some(project_path) = buffer.project_path(cx) else {
continue;
};
let snapshot = buffer.snapshot();
let point_range = range.to_point(&snapshot);
let line_range = point_range.start.row..=point_range.end.row;
let path = mention_path_for_terminal(
project,
&project_path,
working_directory,
path_style,
cx,
);
parts.push(format!("{path}{}", line_range_suffix(&line_range)));
}
if parts.is_empty() {
String::new()
} else {
// Trailing space so the mention doesn't fuse with the next input.
format!("{} ", parts.join(" "))
}
}
AgentContextSelection::Terminal(texts) => texts.join("\n"),
}
}

/// Path for a terminal mention: relative to the terminal cwd if possible, else absolute.
fn mention_path_for_terminal(
project: &Entity<Project>,
project_path: &ProjectPath,
working_directory: Option<&std::path::Path>,
path_style: util::paths::PathStyle,
cx: &App,
) -> String {
let abs_path = project.read(cx).absolute_path(project_path, cx);
match (abs_path, working_directory) {
(Some(abs_path), Some(working_directory)) => path_style
.strip_prefix(&abs_path, working_directory)
.map(|relative| relative.display(path_style).into_owned())
.unwrap_or_else(|| abs_path.to_string_lossy().into_owned()),
(Some(abs_path), None) => abs_path.to_string_lossy().into_owned(),
(None, _) => project_path.path.display(path_style).into_owned(),
}
}

fn conflict_resource_block(conflict: &ConflictContent) -> acp::ContentBlock {
let mention_uri = MentionUri::MergeConflict {
file_path: conflict.file_path.clone(),
Expand Down Expand Up @@ -8625,6 +8709,139 @@ mod tests {
});
}

#[gpui::test]
async fn test_add_selection_to_terminal_thread_pastes_mention(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});

let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/project",
json!({ "file.rs": "line one\nline two\nline three\n" }),
)
.await;
let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;

let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace
.read_with(cx, |mw, _cx| mw.workspace().clone())
.unwrap();
let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);

let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});

// Make a terminal thread the active conversation. A display-only terminal
// avoids spawning a real shell; its working directory is supplied directly
// so the mention resolves relative to it. No agent is started inside it.
let terminal_id = TerminalId::new();
panel
.update_in(&mut cx, |panel, window, cx| {
panel.insert_display_only_terminal(
terminal_id,
Some(PathBuf::from("/project")),
Some("Terminal".into()),
None,
None,
true,
true,
AgentThreadSource::AgentPanel,
window,
cx,
)
})
.expect("display-only terminal should be inserted");
cx.run_until_parked();

panel.read_with(&cx, |panel, _cx| {
assert_eq!(panel.active_terminal_id(), Some(terminal_id));
assert!(panel.active_conversation_view().is_none());
});

// Open the file in the center pane so the selection comes from a
// worktree-backed editor (with a project path).
workspace
.update_in(&mut cx, |workspace, window, cx| {
workspace.open_paths(
vec![PathBuf::from("/project/file.rs")],
workspace::OpenOptions::default(),
None,
window,
cx,
)
})
.await;
cx.run_until_parked();

let editor = workspace.update(&mut cx, |workspace, cx| {
workspace
.active_item(cx)
.and_then(|item| item.act_as::<Editor>(cx))
.expect("opened file should be an editor")
});

cx.focus(&editor);
cx.run_until_parked();

let terminal = panel.read_with(&cx, |panel, cx| {
panel
.terminals
.get(&terminal_id)
.expect("terminal should exist")
.view
.read(cx)
.terminal()
.clone()
});
// Drop any input the terminal may have received during setup.
terminal.update(&mut cx, |terminal, _| {
terminal.take_input_log();
});

// With only a cursor and nothing highlighted, the action is a no-op and
// must not paste anything into the terminal.
workspace.update_in(&mut cx, |_, window, cx| {
window.dispatch_action(AddSelectionToThread.boxed_clone(), cx);
});
cx.run_until_parked();
let pasted_without_selection =
terminal.update(&mut cx, |terminal, _| terminal.take_input_log());
assert!(
pasted_without_selection.is_empty(),
"no selection should paste nothing, got {pasted_without_selection:?}"
);

// Now highlight a portion of the file: from the start of line 2 into line 3.
editor.update_in(&mut cx, |editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |selections| {
selections.select_ranges([text::Point::new(1, 0)..text::Point::new(2, 4)]);
});
});
cx.run_until_parked();

workspace.update_in(&mut cx, |_, window, cx| {
window.dispatch_action(AddSelectionToThread.boxed_clone(), cx);
});
cx.run_until_parked();

let pasted: String = terminal
.update(&mut cx, |terminal, _| terminal.take_input_log())
.into_iter()
.map(|bytes| String::from_utf8(bytes).expect("pasted bytes should be valid UTF-8"))
.collect();

// Lines are 1-based and inclusive; the path is presented as
// `<rel-path>:<start>-<end>`, with a trailing space.
assert_eq!(pasted, "file.rs:2-3 ");
}

async fn setup_panel(cx: &mut TestAppContext) -> (Entity<AgentPanel>, VisualTestContext) {
init_test(cx);
cx.update(|cx| {
Expand Down
Loading