Port terminal tool to agent2 (#35918)

Release Notes:

- N/A

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
This commit is contained in:
Antonio Scandurra 2025-08-11 12:31:13 +02:00 committed by GitHub
parent 422e0a2eb7
commit 086ea3c619
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 882 additions and 112 deletions

8
Cargo.lock generated
View file

@ -27,6 +27,7 @@ dependencies = [
"settings",
"smol",
"tempfile",
"terminal",
"ui",
"util",
"workspace-hack",
@ -195,6 +196,7 @@ dependencies = [
"cloud_llm_client",
"collections",
"ctor",
"editor",
"env_logger 0.11.8",
"fs",
"futures 0.3.31",
@ -209,6 +211,7 @@ dependencies = [
"log",
"lsp",
"paths",
"portable-pty",
"pretty_assertions",
"project",
"prompt_store",
@ -219,12 +222,17 @@ dependencies = [
"serde_json",
"settings",
"smol",
"task",
"terminal",
"theme",
"ui",
"util",
"uuid",
"watch",
"which 6.0.3",
"workspace-hack",
"worktree",
"zlog",
]
[[package]]

View file

@ -32,6 +32,7 @@ serde.workspace = true
serde_json.workspace = true
settings.workspace = true
smol.workspace = true
terminal.workspace = true
ui.workspace = true
util.workspace = true
workspace-hack.workspace = true

View file

@ -1,8 +1,10 @@
mod connection;
mod diff;
mod terminal;
pub use connection::*;
pub use diff::*;
pub use terminal::*;
use action_log::ActionLog;
use agent_client_protocol as acp;
@ -147,6 +149,14 @@ impl AgentThreadEntry {
}
}
pub fn terminals(&self) -> impl Iterator<Item = &Entity<Terminal>> {
if let AgentThreadEntry::ToolCall(call) = self {
itertools::Either::Left(call.terminals())
} else {
itertools::Either::Right(std::iter::empty())
}
}
pub fn locations(&self) -> Option<&[acp::ToolCallLocation]> {
if let AgentThreadEntry::ToolCall(ToolCall { locations, .. }) = self {
Some(locations)
@ -250,8 +260,17 @@ impl ToolCall {
pub fn diffs(&self) -> impl Iterator<Item = &Entity<Diff>> {
self.content.iter().filter_map(|content| match content {
ToolCallContent::ContentBlock { .. } => None,
ToolCallContent::Diff { diff } => Some(diff),
ToolCallContent::Diff(diff) => Some(diff),
ToolCallContent::ContentBlock(_) => None,
ToolCallContent::Terminal(_) => None,
})
}
pub fn terminals(&self) -> impl Iterator<Item = &Entity<Terminal>> {
self.content.iter().filter_map(|content| match content {
ToolCallContent::Terminal(terminal) => Some(terminal),
ToolCallContent::ContentBlock(_) => None,
ToolCallContent::Diff(_) => None,
})
}
@ -387,8 +406,9 @@ impl ContentBlock {
#[derive(Debug)]
pub enum ToolCallContent {
ContentBlock { content: ContentBlock },
Diff { diff: Entity<Diff> },
ContentBlock(ContentBlock),
Diff(Entity<Diff>),
Terminal(Entity<Terminal>),
}
impl ToolCallContent {
@ -398,19 +418,20 @@ impl ToolCallContent {
cx: &mut App,
) -> Self {
match content {
acp::ToolCallContent::Content { content } => Self::ContentBlock {
content: ContentBlock::new(content, &language_registry, cx),
},
acp::ToolCallContent::Diff { diff } => Self::Diff {
diff: cx.new(|cx| Diff::from_acp(diff, language_registry, cx)),
},
acp::ToolCallContent::Content { content } => {
Self::ContentBlock(ContentBlock::new(content, &language_registry, cx))
}
acp::ToolCallContent::Diff { diff } => {
Self::Diff(cx.new(|cx| Diff::from_acp(diff, language_registry, cx)))
}
}
}
pub fn to_markdown(&self, cx: &App) -> String {
match self {
Self::ContentBlock { content } => content.to_markdown(cx).to_string(),
Self::Diff { diff } => diff.read(cx).to_markdown(cx),
Self::ContentBlock(content) => content.to_markdown(cx).to_string(),
Self::Diff(diff) => diff.read(cx).to_markdown(cx),
Self::Terminal(terminal) => terminal.read(cx).to_markdown(cx),
}
}
}
@ -419,6 +440,7 @@ impl ToolCallContent {
pub enum ToolCallUpdate {
UpdateFields(acp::ToolCallUpdate),
UpdateDiff(ToolCallUpdateDiff),
UpdateTerminal(ToolCallUpdateTerminal),
}
impl ToolCallUpdate {
@ -426,6 +448,7 @@ impl ToolCallUpdate {
match self {
Self::UpdateFields(update) => &update.id,
Self::UpdateDiff(diff) => &diff.id,
Self::UpdateTerminal(terminal) => &terminal.id,
}
}
}
@ -448,6 +471,18 @@ pub struct ToolCallUpdateDiff {
pub diff: Entity<Diff>,
}
impl From<ToolCallUpdateTerminal> for ToolCallUpdate {
fn from(terminal: ToolCallUpdateTerminal) -> Self {
Self::UpdateTerminal(terminal)
}
}
#[derive(Debug, PartialEq)]
pub struct ToolCallUpdateTerminal {
pub id: acp::ToolCallId,
pub terminal: Entity<Terminal>,
}
#[derive(Debug, Default)]
pub struct Plan {
pub entries: Vec<PlanEntry>,
@ -760,7 +795,13 @@ impl AcpThread {
current_call.content.clear();
current_call
.content
.push(ToolCallContent::Diff { diff: update.diff });
.push(ToolCallContent::Diff(update.diff));
}
ToolCallUpdate::UpdateTerminal(update) => {
current_call.content.clear();
current_call
.content
.push(ToolCallContent::Terminal(update.terminal));
}
}

View file

@ -0,0 +1,87 @@
use gpui::{App, AppContext, Context, Entity};
use language::LanguageRegistry;
use markdown::Markdown;
use std::{path::PathBuf, process::ExitStatus, sync::Arc, time::Instant};
pub struct Terminal {
command: Entity<Markdown>,
working_dir: Option<PathBuf>,
terminal: Entity<terminal::Terminal>,
started_at: Instant,
output: Option<TerminalOutput>,
}
pub struct TerminalOutput {
pub ended_at: Instant,
pub exit_status: Option<ExitStatus>,
pub was_content_truncated: bool,
pub original_content_len: usize,
pub content_line_count: usize,
pub finished_with_empty_output: bool,
}
impl Terminal {
pub fn new(
command: String,
working_dir: Option<PathBuf>,
terminal: Entity<terminal::Terminal>,
language_registry: Arc<LanguageRegistry>,
cx: &mut Context<Self>,
) -> Self {
Self {
command: cx
.new(|cx| Markdown::new(command.into(), Some(language_registry.clone()), None, cx)),
working_dir,
terminal,
started_at: Instant::now(),
output: None,
}
}
pub fn finish(
&mut self,
exit_status: Option<ExitStatus>,
original_content_len: usize,
truncated_content_len: usize,
content_line_count: usize,
finished_with_empty_output: bool,
cx: &mut Context<Self>,
) {
self.output = Some(TerminalOutput {
ended_at: Instant::now(),
exit_status,
was_content_truncated: truncated_content_len < original_content_len,
original_content_len,
content_line_count,
finished_with_empty_output,
});
cx.notify();
}
pub fn command(&self) -> &Entity<Markdown> {
&self.command
}
pub fn working_dir(&self) -> &Option<PathBuf> {
&self.working_dir
}
pub fn started_at(&self) -> Instant {
self.started_at
}
pub fn output(&self) -> Option<&TerminalOutput> {
self.output.as_ref()
}
pub fn inner(&self) -> &Entity<terminal::Terminal> {
&self.terminal
}
pub fn to_markdown(&self, cx: &App) -> String {
format!(
"Terminal:\n```\n{}\n```\n",
self.terminal.read(cx).get_content()
)
}
}

View file

@ -33,6 +33,7 @@ language_model.workspace = true
language_models.workspace = true
log.workspace = true
paths.workspace = true
portable-pty.workspace = true
project.workspace = true
prompt_store.workspace = true
rust-embed.workspace = true
@ -41,16 +42,20 @@ serde.workspace = true
serde_json.workspace = true
settings.workspace = true
smol.workspace = true
task.workspace = true
terminal.workspace = true
ui.workspace = true
util.workspace = true
uuid.workspace = true
watch.workspace = true
which.workspace = true
workspace-hack.workspace = true
[dev-dependencies]
ctor.workspace = true
client = { workspace = true, "features" = ["test-support"] }
clock = { workspace = true, "features" = ["test-support"] }
editor = { workspace = true, "features" = ["test-support"] }
env_logger.workspace = true
fs = { workspace = true, "features" = ["test-support"] }
gpui = { workspace = true, "features" = ["test-support"] }
@ -58,8 +63,11 @@ gpui_tokio.workspace = true
language = { workspace = true, "features" = ["test-support"] }
language_model = { workspace = true, "features" = ["test-support"] }
lsp = { workspace = true, "features" = ["test-support"] }
pretty_assertions.workspace = true
project = { workspace = true, "features" = ["test-support"] }
reqwest_client.workspace = true
settings = { workspace = true, "features" = ["test-support"] }
terminal = { workspace = true, "features" = ["test-support"] }
theme = { workspace = true, "features" = ["test-support"] }
worktree = { workspace = true, "features" = ["test-support"] }
pretty_assertions.workspace = true
zlog.workspace = true

View file

@ -1,5 +1,7 @@
use crate::{AgentResponseEvent, Thread, templates::Templates};
use crate::{EditFileTool, FindPathTool, ReadFileTool, ThinkingTool, ToolCallAuthorization};
use crate::{
EditFileTool, FindPathTool, ReadFileTool, TerminalTool, ThinkingTool, ToolCallAuthorization,
};
use acp_thread::ModelSelector;
use agent_client_protocol as acp;
use anyhow::{Context as _, Result, anyhow};
@ -418,6 +420,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
thread.add_tool(FindPathTool::new(project.clone()));
thread.add_tool(ReadFileTool::new(project.clone(), action_log));
thread.add_tool(EditFileTool::new(cx.entity()));
thread.add_tool(TerminalTool::new(project.clone(), cx));
thread
});

View file

@ -1,5 +1,4 @@
use crate::{SystemPromptTemplate, Template, Templates};
use acp_thread::Diff;
use action_log::ActionLog;
use agent_client_protocol as acp;
use anyhow::{Context as _, Result, anyhow};
@ -802,47 +801,6 @@ impl AgentResponseEventStream {
.ok();
}
fn authorize_tool_call(
&self,
id: &LanguageModelToolUseId,
title: String,
kind: acp::ToolKind,
input: serde_json::Value,
) -> impl use<> + Future<Output = Result<()>> {
let (response_tx, response_rx) = oneshot::channel();
self.0
.unbounded_send(Ok(AgentResponseEvent::ToolCallAuthorization(
ToolCallAuthorization {
tool_call: Self::initial_tool_call(id, title, kind, input),
options: vec![
acp::PermissionOption {
id: acp::PermissionOptionId("always_allow".into()),
name: "Always Allow".into(),
kind: acp::PermissionOptionKind::AllowAlways,
},
acp::PermissionOption {
id: acp::PermissionOptionId("allow".into()),
name: "Allow".into(),
kind: acp::PermissionOptionKind::AllowOnce,
},
acp::PermissionOption {
id: acp::PermissionOptionId("deny".into()),
name: "Deny".into(),
kind: acp::PermissionOptionKind::RejectOnce,
},
],
response: response_tx,
},
)))
.ok();
async move {
match response_rx.await?.0.as_ref() {
"allow" | "always_allow" => Ok(()),
_ => Err(anyhow!("Permission to run tool denied by user")),
}
}
}
fn send_tool_call(
&self,
id: &LanguageModelToolUseId,
@ -894,18 +852,6 @@ impl AgentResponseEventStream {
.ok();
}
fn update_tool_call_diff(&self, tool_use_id: &LanguageModelToolUseId, diff: Entity<Diff>) {
self.0
.unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate(
acp_thread::ToolCallUpdateDiff {
id: acp::ToolCallId(tool_use_id.to_string().into()),
diff,
}
.into(),
)))
.ok();
}
fn send_stop(&self, reason: StopReason) {
match reason {
StopReason::EndTurn => {
@ -979,17 +925,71 @@ impl ToolCallEventStream {
.update_tool_call_fields(&self.tool_use_id, fields);
}
pub fn update_diff(&self, diff: Entity<Diff>) {
self.stream.update_tool_call_diff(&self.tool_use_id, diff);
pub fn update_diff(&self, diff: Entity<acp_thread::Diff>) {
self.stream
.0
.unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate(
acp_thread::ToolCallUpdateDiff {
id: acp::ToolCallId(self.tool_use_id.to_string().into()),
diff,
}
.into(),
)))
.ok();
}
pub fn update_terminal(&self, terminal: Entity<acp_thread::Terminal>) {
self.stream
.0
.unbounded_send(Ok(AgentResponseEvent::ToolCallUpdate(
acp_thread::ToolCallUpdateTerminal {
id: acp::ToolCallId(self.tool_use_id.to_string().into()),
terminal,
}
.into(),
)))
.ok();
}
pub fn authorize(&self, title: String) -> impl use<> + Future<Output = Result<()>> {
self.stream.authorize_tool_call(
&self.tool_use_id,
title,
self.kind.clone(),
self.input.clone(),
)
let (response_tx, response_rx) = oneshot::channel();
self.stream
.0
.unbounded_send(Ok(AgentResponseEvent::ToolCallAuthorization(
ToolCallAuthorization {
tool_call: AgentResponseEventStream::initial_tool_call(
&self.tool_use_id,
title,
self.kind.clone(),
self.input.clone(),
),
options: vec![
acp::PermissionOption {
id: acp::PermissionOptionId("always_allow".into()),
name: "Always Allow".into(),
kind: acp::PermissionOptionKind::AllowAlways,
},
acp::PermissionOption {
id: acp::PermissionOptionId("allow".into()),
name: "Allow".into(),
kind: acp::PermissionOptionKind::AllowOnce,
},
acp::PermissionOption {
id: acp::PermissionOptionId("deny".into()),
name: "Deny".into(),
kind: acp::PermissionOptionKind::RejectOnce,
},
],
response: response_tx,
},
)))
.ok();
async move {
match response_rx.await?.0.as_ref() {
"allow" | "always_allow" => Ok(()),
_ => Err(anyhow!("Permission to run tool denied by user")),
}
}
}
}
@ -1000,7 +1000,7 @@ pub struct ToolCallEventStreamReceiver(
#[cfg(test)]
impl ToolCallEventStreamReceiver {
pub async fn expect_tool_authorization(&mut self) -> ToolCallAuthorization {
pub async fn expect_authorization(&mut self) -> ToolCallAuthorization {
let event = self.0.next().await;
if let Some(Ok(AgentResponseEvent::ToolCallAuthorization(auth))) = event {
auth
@ -1008,6 +1008,18 @@ impl ToolCallEventStreamReceiver {
panic!("Expected ToolCallAuthorization but got: {:?}", event);
}
}
pub async fn expect_terminal(&mut self) -> Entity<acp_thread::Terminal> {
let event = self.0.next().await;
if let Some(Ok(AgentResponseEvent::ToolCallUpdate(
acp_thread::ToolCallUpdate::UpdateTerminal(update),
))) = event
{
update.terminal
} else {
panic!("Expected terminal but got: {:?}", event);
}
}
}
#[cfg(test)]

View file

@ -1,9 +1,11 @@
mod edit_file_tool;
mod find_path_tool;
mod read_file_tool;
mod terminal_tool;
mod thinking_tool;
pub use edit_file_tool::*;
pub use find_path_tool::*;
pub use read_file_tool::*;
pub use terminal_tool::*;
pub use thinking_tool::*;

View file

@ -942,7 +942,7 @@ mod tests {
)
});
let event = stream_rx.expect_tool_authorization().await;
let event = stream_rx.expect_authorization().await;
assert_eq!(event.tool_call.title, "test 1 (local settings)");
// Test 2: Path outside project should require confirmation
@ -959,7 +959,7 @@ mod tests {
)
});
let event = stream_rx.expect_tool_authorization().await;
let event = stream_rx.expect_authorization().await;
assert_eq!(event.tool_call.title, "test 2");
// Test 3: Relative path without .zed should not require confirmation
@ -992,7 +992,7 @@ mod tests {
cx,
)
});
let event = stream_rx.expect_tool_authorization().await;
let event = stream_rx.expect_authorization().await;
assert_eq!(event.tool_call.title, "test 4 (local settings)");
// Test 5: When always_allow_tool_actions is enabled, no confirmation needed
@ -1088,7 +1088,7 @@ mod tests {
});
if should_confirm {
stream_rx.expect_tool_authorization().await;
stream_rx.expect_authorization().await;
} else {
auth.await.unwrap();
assert!(
@ -1192,7 +1192,7 @@ mod tests {
});
if should_confirm {
stream_rx.expect_tool_authorization().await;
stream_rx.expect_authorization().await;
} else {
auth.await.unwrap();
assert!(
@ -1276,7 +1276,7 @@ mod tests {
});
if should_confirm {
stream_rx.expect_tool_authorization().await;
stream_rx.expect_authorization().await;
} else {
auth.await.unwrap();
assert!(
@ -1339,7 +1339,7 @@ mod tests {
)
});
stream_rx.expect_tool_authorization().await;
stream_rx.expect_authorization().await;
// Test outside path with different modes
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
@ -1355,7 +1355,7 @@ mod tests {
)
});
stream_rx.expect_tool_authorization().await;
stream_rx.expect_authorization().await;
// Test normal path with different modes
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();

View file

@ -0,0 +1,489 @@
use agent_client_protocol as acp;
use anyhow::Result;
use futures::{FutureExt as _, future::Shared};
use gpui::{App, AppContext, Entity, SharedString, Task};
use project::{Project, terminals::TerminalKind};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings;
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use util::{ResultExt, get_system_shell, markdown::MarkdownInlineCode};
use crate::{AgentTool, ToolCallEventStream};
const COMMAND_OUTPUT_LIMIT: usize = 16 * 1024;
/// Executes a shell one-liner and returns the combined output.
///
/// This tool spawns a process using the user's shell, reads from stdout and stderr (preserving the order of writes), and returns a string with the combined output result.
///
/// The output results will be shown to the user already, only list it again if necessary, avoid being redundant.
///
/// Make sure you use the `cd` parameter to navigate to one of the root directories of the project. NEVER do it as part of the `command` itself, otherwise it will error.
///
/// Do not use this tool for commands that run indefinitely, such as servers (like `npm run start`, `npm run dev`, `python -m http.server`, etc) or file watchers that don't terminate on their own.
///
/// Remember that each invocation of this tool will spawn a new shell process, so you can't rely on any state from previous invocations.
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
pub struct TerminalToolInput {
/// The one-liner command to execute.
command: String,
/// Working directory for the command. This must be one of the root directories of the project.
cd: String,
}
pub struct TerminalTool {
project: Entity<Project>,
determine_shell: Shared<Task<String>>,
}
impl TerminalTool {
pub fn new(project: Entity<Project>, cx: &mut App) -> Self {
let determine_shell = cx.background_spawn(async move {
if cfg!(windows) {
return get_system_shell();
}
if which::which("bash").is_ok() {
log::info!("agent selected bash for terminal tool");
"bash".into()
} else {
let shell = get_system_shell();
log::info!("agent selected {shell} for terminal tool");
shell
}
});
Self {
project,
determine_shell: determine_shell.shared(),
}
}
fn authorize(
&self,
input: &TerminalToolInput,
event_stream: &ToolCallEventStream,
cx: &App,
) -> Task<Result<()>> {
if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
return Task::ready(Ok(()));
}
// TODO: do we want to have a special title here?
cx.foreground_executor()
.spawn(event_stream.authorize(self.initial_title(Ok(input.clone())).to_string()))
}
}
impl AgentTool for TerminalTool {
type Input = TerminalToolInput;
type Output = String;
fn name(&self) -> SharedString {
"terminal".into()
}
fn kind(&self) -> acp::ToolKind {
acp::ToolKind::Execute
}
fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
if let Ok(input) = input {
let mut lines = input.command.lines();
let first_line = lines.next().unwrap_or_default();
let remaining_line_count = lines.count();
match remaining_line_count {
0 => MarkdownInlineCode(&first_line).to_string().into(),
1 => MarkdownInlineCode(&format!(
"{} - {} more line",
first_line, remaining_line_count
))
.to_string()
.into(),
n => MarkdownInlineCode(&format!("{} - {} more lines", first_line, n))
.to_string()
.into(),
}
} else {
"Run terminal command".into()
}
}
fn run(
self: Arc<Self>,
input: Self::Input,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<Self::Output>> {
let language_registry = self.project.read(cx).languages().clone();
let working_dir = match working_dir(&input, &self.project, cx) {
Ok(dir) => dir,
Err(err) => return Task::ready(Err(err)),
};
let program = self.determine_shell.clone();
let command = if cfg!(windows) {
format!("$null | & {{{}}}", input.command.replace("\"", "'"))
} else if let Some(cwd) = working_dir
.as_ref()
.and_then(|cwd| cwd.as_os_str().to_str())
{
// Make sure once we're *inside* the shell, we cd into `cwd`
format!("(cd {cwd}; {}) </dev/null", input.command)
} else {
format!("({}) </dev/null", input.command)
};
let args = vec!["-c".into(), command];
let env = match &working_dir {
Some(dir) => self.project.update(cx, |project, cx| {
project.directory_environment(dir.as_path().into(), cx)
}),
None => Task::ready(None).shared(),
};
let env = cx.spawn(async move |_| {
let mut env = env.await.unwrap_or_default();
if cfg!(unix) {
env.insert("PAGER".into(), "cat".into());
}
env
});
let authorize = self.authorize(&input, &event_stream, cx);
cx.spawn({
async move |cx| {
authorize.await?;
let program = program.await;
let env = env.await;
let terminal = self
.project
.update(cx, |project, cx| {
project.create_terminal(
TerminalKind::Task(task::SpawnInTerminal {
command: Some(program),
args,
cwd: working_dir.clone(),
env,
..Default::default()
}),
cx,
)
})?
.await?;
let acp_terminal = cx.new(|cx| {
acp_thread::Terminal::new(
input.command.clone(),
working_dir.clone(),
terminal.clone(),
language_registry,
cx,
)
})?;
event_stream.update_terminal(acp_terminal.clone());
let exit_status = terminal
.update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
.await;
let (content, content_line_count) = terminal.read_with(cx, |terminal, _| {
(terminal.get_content(), terminal.total_lines())
})?;
let (processed_content, finished_with_empty_output) = process_content(
&content,
&input.command,
exit_status.map(portable_pty::ExitStatus::from),
);
acp_terminal
.update(cx, |terminal, cx| {
terminal.finish(
exit_status,
content.len(),
processed_content.len(),
content_line_count,
finished_with_empty_output,
cx,
);
})
.log_err();
Ok(processed_content)
}
})
}
}
fn process_content(
content: &str,
command: &str,
exit_status: Option<portable_pty::ExitStatus>,
) -> (String, bool) {
let should_truncate = content.len() > COMMAND_OUTPUT_LIMIT;
let content = if should_truncate {
let mut end_ix = COMMAND_OUTPUT_LIMIT.min(content.len());
while !content.is_char_boundary(end_ix) {
end_ix -= 1;
}
// Don't truncate mid-line, clear the remainder of the last line
end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix);
&content[..end_ix]
} else {
content
};
let content = content.trim();
let is_empty = content.is_empty();
let content = format!("```\n{content}\n```");
let content = if should_truncate {
format!(
"Command output too long. The first {} bytes:\n\n{content}",
content.len(),
)
} else {
content
};
let content = match exit_status {
Some(exit_status) if exit_status.success() => {
if is_empty {
"Command executed successfully.".to_string()
} else {
content.to_string()
}
}
Some(exit_status) => {
if is_empty {
format!(
"Command \"{command}\" failed with exit code {}.",
exit_status.exit_code()
)
} else {
format!(
"Command \"{command}\" failed with exit code {}.\n\n{content}",
exit_status.exit_code()
)
}
}
None => {
format!(
"Command failed or was interrupted.\nPartial output captured:\n\n{}",
content,
)
}
};
(content, is_empty)
}
fn working_dir(
input: &TerminalToolInput,
project: &Entity<Project>,
cx: &mut App,
) -> Result<Option<PathBuf>> {
let project = project.read(cx);
let cd = &input.cd;
if cd == "." || cd == "" {
// Accept "." or "" as meaning "the one worktree" if we only have one worktree.
let mut worktrees = project.worktrees(cx);
match worktrees.next() {
Some(worktree) => {
anyhow::ensure!(
worktrees.next().is_none(),
"'.' is ambiguous in multi-root workspaces. Please specify a root directory explicitly.",
);
Ok(Some(worktree.read(cx).abs_path().to_path_buf()))
}
None => Ok(None),
}
} else {
let input_path = Path::new(cd);
if input_path.is_absolute() {
// Absolute paths are allowed, but only if they're in one of the project's worktrees.
if project
.worktrees(cx)
.any(|worktree| input_path.starts_with(&worktree.read(cx).abs_path()))
{
return Ok(Some(input_path.into()));
}
} else {
if let Some(worktree) = project.worktree_for_root_name(cd, cx) {
return Ok(Some(worktree.read(cx).abs_path().to_path_buf()));
}
}
anyhow::bail!("`cd` directory {cd:?} was not in any of the project's worktrees.");
}
}
#[cfg(test)]
mod tests {
use agent_settings::AgentSettings;
use editor::EditorSettings;
use fs::RealFs;
use gpui::{BackgroundExecutor, TestAppContext};
use pretty_assertions::assert_eq;
use serde_json::json;
use settings::{Settings, SettingsStore};
use terminal::terminal_settings::TerminalSettings;
use theme::ThemeSettings;
use util::test::TempTree;
use crate::AgentResponseEvent;
use super::*;
fn init_test(executor: &BackgroundExecutor, cx: &mut TestAppContext) {
zlog::init_test();
executor.allow_parking();
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
language::init(cx);
Project::init_settings(cx);
ThemeSettings::register(cx);
TerminalSettings::register(cx);
EditorSettings::register(cx);
AgentSettings::register(cx);
});
}
#[gpui::test]
async fn test_interactive_command(executor: BackgroundExecutor, cx: &mut TestAppContext) {
if cfg!(windows) {
return;
}
init_test(&executor, cx);
let fs = Arc::new(RealFs::new(None, executor));
let tree = TempTree::new(json!({
"project": {},
}));
let project: Entity<Project> =
Project::test(fs, [tree.path().join("project").as_path()], cx).await;
let input = TerminalToolInput {
command: "cat".to_owned(),
cd: tree
.path()
.join("project")
.as_path()
.to_string_lossy()
.to_string(),
};
let (event_stream_tx, mut event_stream_rx) = ToolCallEventStream::test();
let result = cx
.update(|cx| Arc::new(TerminalTool::new(project, cx)).run(input, event_stream_tx, cx));
let auth = event_stream_rx.expect_authorization().await;
auth.response.send(auth.options[0].id.clone()).unwrap();
event_stream_rx.expect_terminal().await;
assert_eq!(result.await.unwrap(), "Command executed successfully.");
}
#[gpui::test]
async fn test_working_directory(executor: BackgroundExecutor, cx: &mut TestAppContext) {
if cfg!(windows) {
return;
}
init_test(&executor, cx);
let fs = Arc::new(RealFs::new(None, executor));
let tree = TempTree::new(json!({
"project": {},
"other-project": {},
}));
let project: Entity<Project> =
Project::test(fs, [tree.path().join("project").as_path()], cx).await;
let check = |input, expected, cx: &mut TestAppContext| {
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
let result = cx.update(|cx| {
Arc::new(TerminalTool::new(project.clone(), cx)).run(input, stream_tx, cx)
});
cx.run_until_parked();
let event = stream_rx.try_next();
if let Ok(Some(Ok(AgentResponseEvent::ToolCallAuthorization(auth)))) = event {
auth.response.send(auth.options[0].id.clone()).unwrap();
}
cx.spawn(async move |_| {
let output = result.await;
assert_eq!(output.ok(), expected);
})
};
check(
TerminalToolInput {
command: "pwd".into(),
cd: ".".into(),
},
Some(format!(
"```\n{}\n```",
tree.path().join("project").display()
)),
cx,
)
.await;
check(
TerminalToolInput {
command: "pwd".into(),
cd: "other-project".into(),
},
None, // other-project is a dir, but *not* a worktree (yet)
cx,
)
.await;
// Absolute path above the worktree root
check(
TerminalToolInput {
command: "pwd".into(),
cd: tree.path().to_string_lossy().into(),
},
None,
cx,
)
.await;
project
.update(cx, |project, cx| {
project.create_worktree(tree.path().join("other-project"), true, cx)
})
.await
.unwrap();
check(
TerminalToolInput {
command: "pwd".into(),
cd: "other-project".into(),
},
Some(format!(
"```\n{}\n```",
tree.path().join("other-project").display()
)),
cx,
)
.await;
check(
TerminalToolInput {
command: "pwd".into(),
cd: ".".into(),
},
None,
cx,
)
.await;
}
}

View file

@ -1,17 +1,13 @@
use acp_thread::{
AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk,
LoadError, MentionPath, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus,
};
use acp_thread::{AgentConnection, Plan};
use action_log::ActionLog;
use agent_client_protocol as acp;
use agent_servers::AgentServer;
use agent_settings::{AgentSettings, NotifyWhenAgentWaiting};
use audio::{Audio, Sound};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::path::Path;
use std::process::ExitStatus;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use action_log::ActionLog;
use agent_client_protocol as acp;
use buffer_diff::BufferDiff;
use collections::{HashMap, HashSet};
use editor::{
@ -32,6 +28,11 @@ use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
use parking_lot::Mutex;
use project::{CompletionIntent, Project};
use settings::{Settings as _, SettingsStore};
use std::{
cell::RefCell, collections::BTreeMap, path::Path, process::ExitStatus, rc::Rc, sync::Arc,
time::Duration,
};
use terminal_view::TerminalView;
use text::{Anchor, BufferSnapshot};
use theme::ThemeSettings;
use ui::{
@ -41,11 +42,6 @@ use util::ResultExt;
use workspace::{CollaboratorId, Workspace};
use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage};
use ::acp_thread::{
AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk,
LoadError, MentionPath, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus,
};
use crate::acp::completion_provider::{ContextPickerCompletionProvider, MentionSet};
use crate::acp::message_history::MessageHistory;
use crate::agent_diff::AgentDiff;
@ -63,6 +59,7 @@ pub struct AcpThreadView {
project: Entity<Project>,
thread_state: ThreadState,
diff_editors: HashMap<EntityId, Entity<Editor>>,
terminal_views: HashMap<EntityId, Entity<TerminalView>>,
message_editor: Entity<Editor>,
message_set_from_history: Option<BufferSnapshot>,
_message_editor_subscription: Subscription,
@ -193,6 +190,7 @@ impl AcpThreadView {
notifications: Vec::new(),
notification_subscriptions: HashMap::default(),
diff_editors: Default::default(),
terminal_views: Default::default(),
list_state: list_state.clone(),
scrollbar_state: ScrollbarState::new(list_state).parent_entity(&cx.entity()),
last_error: None,
@ -676,6 +674,16 @@ impl AcpThreadView {
entry_ix: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.sync_diff_multibuffers(entry_ix, window, cx);
self.sync_terminals(entry_ix, window, cx);
}
fn sync_diff_multibuffers(
&mut self,
entry_ix: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(multibuffers) = self.entry_diff_multibuffers(entry_ix, cx) else {
return;
@ -739,6 +747,50 @@ impl AcpThreadView {
)
}
fn sync_terminals(&mut self, entry_ix: usize, window: &mut Window, cx: &mut Context<Self>) {
let Some(terminals) = self.entry_terminals(entry_ix, cx) else {
return;
};
let terminals = terminals.collect::<Vec<_>>();
for terminal in terminals {
if self.terminal_views.contains_key(&terminal.entity_id()) {
return;
}
let terminal_view = cx.new(|cx| {
let mut view = TerminalView::new(
terminal.read(cx).inner().clone(),
self.workspace.clone(),
None,
self.project.downgrade(),
window,
cx,
);
view.set_embedded_mode(None, cx);
view
});
let entity_id = terminal.entity_id();
cx.observe_release(&terminal, move |this, _, _| {
this.terminal_views.remove(&entity_id);
})
.detach();
self.terminal_views.insert(entity_id, terminal_view);
}
}
fn entry_terminals(
&self,
entry_ix: usize,
cx: &App,
) -> Option<impl Iterator<Item = Entity<acp_thread::Terminal>>> {
let entry = self.thread()?.read(cx).entries().get(entry_ix)?;
Some(entry.terminals().map(|terminal| terminal.clone()))
}
fn authenticate(
&mut self,
method: acp::AuthMethodId,
@ -1106,7 +1158,7 @@ impl AcpThreadView {
_ => tool_call
.content
.iter()
.any(|content| matches!(content, ToolCallContent::Diff { .. })),
.any(|content| matches!(content, ToolCallContent::Diff(_))),
};
let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
@ -1303,7 +1355,7 @@ impl AcpThreadView {
cx: &Context<Self>,
) -> AnyElement {
match content {
ToolCallContent::ContentBlock { content } => {
ToolCallContent::ContentBlock(content) => {
if let Some(md) = content.markdown() {
div()
.p_2()
@ -1318,9 +1370,8 @@ impl AcpThreadView {
Empty.into_any_element()
}
}
ToolCallContent::Diff { diff, .. } => {
self.render_diff_editor(&diff.read(cx).multibuffer())
}
ToolCallContent::Diff(diff) => self.render_diff_editor(&diff.read(cx).multibuffer()),
ToolCallContent::Terminal(terminal) => self.render_terminal(terminal),
}
}
@ -1389,6 +1440,21 @@ impl AcpThreadView {
.into_any()
}
fn render_terminal(&self, terminal: &Entity<acp_thread::Terminal>) -> AnyElement {
v_flex()
.h_72()
.child(
if let Some(terminal_view) = self.terminal_views.get(&terminal.entity_id()) {
// TODO: terminal has all the state we need to reproduce
// what we had in the terminal card.
terminal_view.clone().into_any_element()
} else {
Empty.into_any()
},
)
.into_any()
}
fn render_agent_logo(&self) -> AnyElement {
Icon::new(self.agent.logo())
.color(Color::Muted)

View file

@ -5,6 +5,13 @@ edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[features]
test-support = [
"collections/test-support",
"gpui/test-support",
"settings/test-support",
]
[lints]
workspace = true
@ -39,5 +46,6 @@ workspace-hack.workspace = true
windows.workspace = true
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
rand.workspace = true
url.workspace = true

View file

@ -58,7 +58,7 @@ use std::{
path::PathBuf,
process::ExitStatus,
sync::Arc,
time::{Duration, Instant},
time::Instant,
};
use thiserror::Error;
@ -534,10 +534,15 @@ impl TerminalBuilder {
'outer: loop {
let mut events = Vec::new();
#[cfg(any(test, feature = "test-support"))]
let mut timer = cx.background_executor().simulate_random_delay().fuse();
#[cfg(not(any(test, feature = "test-support")))]
let mut timer = cx
.background_executor()
.timer(Duration::from_millis(4))
.timer(std::time::Duration::from_millis(4))
.fuse();
let mut wakeup = false;
loop {
futures::select_biased! {
@ -2104,16 +2109,56 @@ pub fn rgba_color(r: u8, g: u8, b: u8) -> Hsla {
#[cfg(test)]
mod tests {
use super::*;
use crate::{
IndexedCell, TerminalBounds, TerminalBuilder, TerminalContent, content_index_for_mouse,
rgb_for_index,
};
use alacritty_terminal::{
index::{Column, Line, Point as AlacPoint},
term::cell::Cell,
};
use gpui::{Pixels, Point, bounds, point, size};
use collections::HashMap;
use gpui::{Pixels, Point, TestAppContext, bounds, point, size};
use rand::{Rng, distributions::Alphanumeric, rngs::ThreadRng, thread_rng};
use crate::{
IndexedCell, TerminalBounds, TerminalContent, content_index_for_mouse, rgb_for_index,
};
#[cfg_attr(windows, ignore = "TODO: fix on windows")]
#[gpui::test]
async fn test_basic_terminal(cx: &mut TestAppContext) {
cx.executor().allow_parking();
let (completion_tx, completion_rx) = smol::channel::unbounded();
let terminal = cx.new(|cx| {
TerminalBuilder::new(
None,
None,
None,
task::Shell::WithArguments {
program: "echo".into(),
args: vec!["hello".into()],
title_override: None,
},
HashMap::default(),
CursorShape::default(),
AlternateScroll::On,
None,
false,
0,
completion_tx,
cx,
)
.unwrap()
.subscribe(cx)
});
assert_eq!(
completion_rx.recv().await.unwrap(),
Some(ExitStatus::default())
);
assert_eq!(
terminal.update(cx, |term, _| term.get_content()).trim(),
"hello"
);
}
#[test]
fn test_rgb_for_index() {