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:
parent
422e0a2eb7
commit
086ea3c619
13 changed files with 882 additions and 112 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
@ -27,6 +27,7 @@ dependencies = [
|
||||||
"settings",
|
"settings",
|
||||||
"smol",
|
"smol",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"terminal",
|
||||||
"ui",
|
"ui",
|
||||||
"util",
|
"util",
|
||||||
"workspace-hack",
|
"workspace-hack",
|
||||||
|
@ -195,6 +196,7 @@ dependencies = [
|
||||||
"cloud_llm_client",
|
"cloud_llm_client",
|
||||||
"collections",
|
"collections",
|
||||||
"ctor",
|
"ctor",
|
||||||
|
"editor",
|
||||||
"env_logger 0.11.8",
|
"env_logger 0.11.8",
|
||||||
"fs",
|
"fs",
|
||||||
"futures 0.3.31",
|
"futures 0.3.31",
|
||||||
|
@ -209,6 +211,7 @@ dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"lsp",
|
"lsp",
|
||||||
"paths",
|
"paths",
|
||||||
|
"portable-pty",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
"project",
|
"project",
|
||||||
"prompt_store",
|
"prompt_store",
|
||||||
|
@ -219,12 +222,17 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"settings",
|
"settings",
|
||||||
"smol",
|
"smol",
|
||||||
|
"task",
|
||||||
|
"terminal",
|
||||||
|
"theme",
|
||||||
"ui",
|
"ui",
|
||||||
"util",
|
"util",
|
||||||
"uuid",
|
"uuid",
|
||||||
"watch",
|
"watch",
|
||||||
|
"which 6.0.3",
|
||||||
"workspace-hack",
|
"workspace-hack",
|
||||||
"worktree",
|
"worktree",
|
||||||
|
"zlog",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
@ -32,6 +32,7 @@ serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
settings.workspace = true
|
settings.workspace = true
|
||||||
smol.workspace = true
|
smol.workspace = true
|
||||||
|
terminal.workspace = true
|
||||||
ui.workspace = true
|
ui.workspace = true
|
||||||
util.workspace = true
|
util.workspace = true
|
||||||
workspace-hack.workspace = true
|
workspace-hack.workspace = true
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
mod connection;
|
mod connection;
|
||||||
mod diff;
|
mod diff;
|
||||||
|
mod terminal;
|
||||||
|
|
||||||
pub use connection::*;
|
pub use connection::*;
|
||||||
pub use diff::*;
|
pub use diff::*;
|
||||||
|
pub use terminal::*;
|
||||||
|
|
||||||
use action_log::ActionLog;
|
use action_log::ActionLog;
|
||||||
use agent_client_protocol as acp;
|
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]> {
|
pub fn locations(&self) -> Option<&[acp::ToolCallLocation]> {
|
||||||
if let AgentThreadEntry::ToolCall(ToolCall { locations, .. }) = self {
|
if let AgentThreadEntry::ToolCall(ToolCall { locations, .. }) = self {
|
||||||
Some(locations)
|
Some(locations)
|
||||||
|
@ -250,8 +260,17 @@ impl ToolCall {
|
||||||
|
|
||||||
pub fn diffs(&self) -> impl Iterator<Item = &Entity<Diff>> {
|
pub fn diffs(&self) -> impl Iterator<Item = &Entity<Diff>> {
|
||||||
self.content.iter().filter_map(|content| match content {
|
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)]
|
#[derive(Debug)]
|
||||||
pub enum ToolCallContent {
|
pub enum ToolCallContent {
|
||||||
ContentBlock { content: ContentBlock },
|
ContentBlock(ContentBlock),
|
||||||
Diff { diff: Entity<Diff> },
|
Diff(Entity<Diff>),
|
||||||
|
Terminal(Entity<Terminal>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolCallContent {
|
impl ToolCallContent {
|
||||||
|
@ -398,19 +418,20 @@ impl ToolCallContent {
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
match content {
|
match content {
|
||||||
acp::ToolCallContent::Content { content } => Self::ContentBlock {
|
acp::ToolCallContent::Content { content } => {
|
||||||
content: ContentBlock::new(content, &language_registry, cx),
|
Self::ContentBlock(ContentBlock::new(content, &language_registry, cx))
|
||||||
},
|
}
|
||||||
acp::ToolCallContent::Diff { diff } => Self::Diff {
|
acp::ToolCallContent::Diff { diff } => {
|
||||||
diff: cx.new(|cx| Diff::from_acp(diff, language_registry, cx)),
|
Self::Diff(cx.new(|cx| Diff::from_acp(diff, language_registry, cx)))
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_markdown(&self, cx: &App) -> String {
|
pub fn to_markdown(&self, cx: &App) -> String {
|
||||||
match self {
|
match self {
|
||||||
Self::ContentBlock { content } => content.to_markdown(cx).to_string(),
|
Self::ContentBlock(content) => content.to_markdown(cx).to_string(),
|
||||||
Self::Diff { diff } => diff.read(cx).to_markdown(cx),
|
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 {
|
pub enum ToolCallUpdate {
|
||||||
UpdateFields(acp::ToolCallUpdate),
|
UpdateFields(acp::ToolCallUpdate),
|
||||||
UpdateDiff(ToolCallUpdateDiff),
|
UpdateDiff(ToolCallUpdateDiff),
|
||||||
|
UpdateTerminal(ToolCallUpdateTerminal),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolCallUpdate {
|
impl ToolCallUpdate {
|
||||||
|
@ -426,6 +448,7 @@ impl ToolCallUpdate {
|
||||||
match self {
|
match self {
|
||||||
Self::UpdateFields(update) => &update.id,
|
Self::UpdateFields(update) => &update.id,
|
||||||
Self::UpdateDiff(diff) => &diff.id,
|
Self::UpdateDiff(diff) => &diff.id,
|
||||||
|
Self::UpdateTerminal(terminal) => &terminal.id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -448,6 +471,18 @@ pub struct ToolCallUpdateDiff {
|
||||||
pub diff: Entity<Diff>,
|
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)]
|
#[derive(Debug, Default)]
|
||||||
pub struct Plan {
|
pub struct Plan {
|
||||||
pub entries: Vec<PlanEntry>,
|
pub entries: Vec<PlanEntry>,
|
||||||
|
@ -760,7 +795,13 @@ impl AcpThread {
|
||||||
current_call.content.clear();
|
current_call.content.clear();
|
||||||
current_call
|
current_call
|
||||||
.content
|
.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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
87
crates/acp_thread/src/terminal.rs
Normal file
87
crates/acp_thread/src/terminal.rs
Normal 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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -33,6 +33,7 @@ language_model.workspace = true
|
||||||
language_models.workspace = true
|
language_models.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
paths.workspace = true
|
paths.workspace = true
|
||||||
|
portable-pty.workspace = true
|
||||||
project.workspace = true
|
project.workspace = true
|
||||||
prompt_store.workspace = true
|
prompt_store.workspace = true
|
||||||
rust-embed.workspace = true
|
rust-embed.workspace = true
|
||||||
|
@ -41,16 +42,20 @@ serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
settings.workspace = true
|
settings.workspace = true
|
||||||
smol.workspace = true
|
smol.workspace = true
|
||||||
|
task.workspace = true
|
||||||
|
terminal.workspace = true
|
||||||
ui.workspace = true
|
ui.workspace = true
|
||||||
util.workspace = true
|
util.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
watch.workspace = true
|
watch.workspace = true
|
||||||
|
which.workspace = true
|
||||||
workspace-hack.workspace = true
|
workspace-hack.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
ctor.workspace = true
|
ctor.workspace = true
|
||||||
client = { workspace = true, "features" = ["test-support"] }
|
client = { workspace = true, "features" = ["test-support"] }
|
||||||
clock = { workspace = true, "features" = ["test-support"] }
|
clock = { workspace = true, "features" = ["test-support"] }
|
||||||
|
editor = { workspace = true, "features" = ["test-support"] }
|
||||||
env_logger.workspace = true
|
env_logger.workspace = true
|
||||||
fs = { workspace = true, "features" = ["test-support"] }
|
fs = { workspace = true, "features" = ["test-support"] }
|
||||||
gpui = { 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 = { workspace = true, "features" = ["test-support"] }
|
||||||
language_model = { workspace = true, "features" = ["test-support"] }
|
language_model = { workspace = true, "features" = ["test-support"] }
|
||||||
lsp = { workspace = true, "features" = ["test-support"] }
|
lsp = { workspace = true, "features" = ["test-support"] }
|
||||||
|
pretty_assertions.workspace = true
|
||||||
project = { workspace = true, "features" = ["test-support"] }
|
project = { workspace = true, "features" = ["test-support"] }
|
||||||
reqwest_client.workspace = true
|
reqwest_client.workspace = true
|
||||||
settings = { workspace = true, "features" = ["test-support"] }
|
settings = { workspace = true, "features" = ["test-support"] }
|
||||||
|
terminal = { workspace = true, "features" = ["test-support"] }
|
||||||
|
theme = { workspace = true, "features" = ["test-support"] }
|
||||||
worktree = { workspace = true, "features" = ["test-support"] }
|
worktree = { workspace = true, "features" = ["test-support"] }
|
||||||
pretty_assertions.workspace = true
|
zlog.workspace = true
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
use crate::{AgentResponseEvent, Thread, templates::Templates};
|
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 acp_thread::ModelSelector;
|
||||||
use agent_client_protocol as acp;
|
use agent_client_protocol as acp;
|
||||||
use anyhow::{Context as _, Result, anyhow};
|
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(FindPathTool::new(project.clone()));
|
||||||
thread.add_tool(ReadFileTool::new(project.clone(), action_log));
|
thread.add_tool(ReadFileTool::new(project.clone(), action_log));
|
||||||
thread.add_tool(EditFileTool::new(cx.entity()));
|
thread.add_tool(EditFileTool::new(cx.entity()));
|
||||||
|
thread.add_tool(TerminalTool::new(project.clone(), cx));
|
||||||
thread
|
thread
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
use crate::{SystemPromptTemplate, Template, Templates};
|
use crate::{SystemPromptTemplate, Template, Templates};
|
||||||
use acp_thread::Diff;
|
|
||||||
use action_log::ActionLog;
|
use action_log::ActionLog;
|
||||||
use agent_client_protocol as acp;
|
use agent_client_protocol as acp;
|
||||||
use anyhow::{Context as _, Result, anyhow};
|
use anyhow::{Context as _, Result, anyhow};
|
||||||
|
@ -802,47 +801,6 @@ impl AgentResponseEventStream {
|
||||||
.ok();
|
.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(
|
fn send_tool_call(
|
||||||
&self,
|
&self,
|
||||||
id: &LanguageModelToolUseId,
|
id: &LanguageModelToolUseId,
|
||||||
|
@ -894,18 +852,6 @@ impl AgentResponseEventStream {
|
||||||
.ok();
|
.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) {
|
fn send_stop(&self, reason: StopReason) {
|
||||||
match reason {
|
match reason {
|
||||||
StopReason::EndTurn => {
|
StopReason::EndTurn => {
|
||||||
|
@ -979,17 +925,71 @@ impl ToolCallEventStream {
|
||||||
.update_tool_call_fields(&self.tool_use_id, fields);
|
.update_tool_call_fields(&self.tool_use_id, fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_diff(&self, diff: Entity<Diff>) {
|
pub fn update_diff(&self, diff: Entity<acp_thread::Diff>) {
|
||||||
self.stream.update_tool_call_diff(&self.tool_use_id, 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<()>> {
|
pub fn authorize(&self, title: String) -> impl use<> + Future<Output = Result<()>> {
|
||||||
self.stream.authorize_tool_call(
|
let (response_tx, response_rx) = oneshot::channel();
|
||||||
&self.tool_use_id,
|
self.stream
|
||||||
title,
|
.0
|
||||||
self.kind.clone(),
|
.unbounded_send(Ok(AgentResponseEvent::ToolCallAuthorization(
|
||||||
self.input.clone(),
|
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)]
|
#[cfg(test)]
|
||||||
impl ToolCallEventStreamReceiver {
|
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;
|
let event = self.0.next().await;
|
||||||
if let Some(Ok(AgentResponseEvent::ToolCallAuthorization(auth))) = event {
|
if let Some(Ok(AgentResponseEvent::ToolCallAuthorization(auth))) = event {
|
||||||
auth
|
auth
|
||||||
|
@ -1008,6 +1008,18 @@ impl ToolCallEventStreamReceiver {
|
||||||
panic!("Expected ToolCallAuthorization but got: {:?}", event);
|
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)]
|
#[cfg(test)]
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
mod edit_file_tool;
|
mod edit_file_tool;
|
||||||
mod find_path_tool;
|
mod find_path_tool;
|
||||||
mod read_file_tool;
|
mod read_file_tool;
|
||||||
|
mod terminal_tool;
|
||||||
mod thinking_tool;
|
mod thinking_tool;
|
||||||
|
|
||||||
pub use edit_file_tool::*;
|
pub use edit_file_tool::*;
|
||||||
pub use find_path_tool::*;
|
pub use find_path_tool::*;
|
||||||
pub use read_file_tool::*;
|
pub use read_file_tool::*;
|
||||||
|
pub use terminal_tool::*;
|
||||||
pub use thinking_tool::*;
|
pub use thinking_tool::*;
|
||||||
|
|
|
@ -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)");
|
assert_eq!(event.tool_call.title, "test 1 (local settings)");
|
||||||
|
|
||||||
// Test 2: Path outside project should require confirmation
|
// 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");
|
assert_eq!(event.tool_call.title, "test 2");
|
||||||
|
|
||||||
// Test 3: Relative path without .zed should not require confirmation
|
// Test 3: Relative path without .zed should not require confirmation
|
||||||
|
@ -992,7 +992,7 @@ mod tests {
|
||||||
cx,
|
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)");
|
assert_eq!(event.tool_call.title, "test 4 (local settings)");
|
||||||
|
|
||||||
// Test 5: When always_allow_tool_actions is enabled, no confirmation needed
|
// Test 5: When always_allow_tool_actions is enabled, no confirmation needed
|
||||||
|
@ -1088,7 +1088,7 @@ mod tests {
|
||||||
});
|
});
|
||||||
|
|
||||||
if should_confirm {
|
if should_confirm {
|
||||||
stream_rx.expect_tool_authorization().await;
|
stream_rx.expect_authorization().await;
|
||||||
} else {
|
} else {
|
||||||
auth.await.unwrap();
|
auth.await.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
|
@ -1192,7 +1192,7 @@ mod tests {
|
||||||
});
|
});
|
||||||
|
|
||||||
if should_confirm {
|
if should_confirm {
|
||||||
stream_rx.expect_tool_authorization().await;
|
stream_rx.expect_authorization().await;
|
||||||
} else {
|
} else {
|
||||||
auth.await.unwrap();
|
auth.await.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
|
@ -1276,7 +1276,7 @@ mod tests {
|
||||||
});
|
});
|
||||||
|
|
||||||
if should_confirm {
|
if should_confirm {
|
||||||
stream_rx.expect_tool_authorization().await;
|
stream_rx.expect_authorization().await;
|
||||||
} else {
|
} else {
|
||||||
auth.await.unwrap();
|
auth.await.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
|
@ -1339,7 +1339,7 @@ mod tests {
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
stream_rx.expect_tool_authorization().await;
|
stream_rx.expect_authorization().await;
|
||||||
|
|
||||||
// Test outside path with different modes
|
// Test outside path with different modes
|
||||||
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
|
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
|
// Test normal path with different modes
|
||||||
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
|
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
|
||||||
|
|
489
crates/agent2/src/tools/terminal_tool.rs
Normal file
489
crates/agent2/src/tools/terminal_tool.rs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,17 +1,13 @@
|
||||||
|
use acp_thread::{
|
||||||
|
AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessage, AssistantMessageChunk,
|
||||||
|
LoadError, MentionPath, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus,
|
||||||
|
};
|
||||||
use acp_thread::{AgentConnection, Plan};
|
use acp_thread::{AgentConnection, Plan};
|
||||||
|
use action_log::ActionLog;
|
||||||
|
use agent_client_protocol as acp;
|
||||||
use agent_servers::AgentServer;
|
use agent_servers::AgentServer;
|
||||||
use agent_settings::{AgentSettings, NotifyWhenAgentWaiting};
|
use agent_settings::{AgentSettings, NotifyWhenAgentWaiting};
|
||||||
use audio::{Audio, Sound};
|
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 buffer_diff::BufferDiff;
|
||||||
use collections::{HashMap, HashSet};
|
use collections::{HashMap, HashSet};
|
||||||
use editor::{
|
use editor::{
|
||||||
|
@ -32,6 +28,11 @@ use markdown::{HeadingLevelStyles, Markdown, MarkdownElement, MarkdownStyle};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use project::{CompletionIntent, Project};
|
use project::{CompletionIntent, Project};
|
||||||
use settings::{Settings as _, SettingsStore};
|
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 text::{Anchor, BufferSnapshot};
|
||||||
use theme::ThemeSettings;
|
use theme::ThemeSettings;
|
||||||
use ui::{
|
use ui::{
|
||||||
|
@ -41,11 +42,6 @@ use util::ResultExt;
|
||||||
use workspace::{CollaboratorId, Workspace};
|
use workspace::{CollaboratorId, Workspace};
|
||||||
use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage};
|
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::completion_provider::{ContextPickerCompletionProvider, MentionSet};
|
||||||
use crate::acp::message_history::MessageHistory;
|
use crate::acp::message_history::MessageHistory;
|
||||||
use crate::agent_diff::AgentDiff;
|
use crate::agent_diff::AgentDiff;
|
||||||
|
@ -63,6 +59,7 @@ pub struct AcpThreadView {
|
||||||
project: Entity<Project>,
|
project: Entity<Project>,
|
||||||
thread_state: ThreadState,
|
thread_state: ThreadState,
|
||||||
diff_editors: HashMap<EntityId, Entity<Editor>>,
|
diff_editors: HashMap<EntityId, Entity<Editor>>,
|
||||||
|
terminal_views: HashMap<EntityId, Entity<TerminalView>>,
|
||||||
message_editor: Entity<Editor>,
|
message_editor: Entity<Editor>,
|
||||||
message_set_from_history: Option<BufferSnapshot>,
|
message_set_from_history: Option<BufferSnapshot>,
|
||||||
_message_editor_subscription: Subscription,
|
_message_editor_subscription: Subscription,
|
||||||
|
@ -193,6 +190,7 @@ impl AcpThreadView {
|
||||||
notifications: Vec::new(),
|
notifications: Vec::new(),
|
||||||
notification_subscriptions: HashMap::default(),
|
notification_subscriptions: HashMap::default(),
|
||||||
diff_editors: Default::default(),
|
diff_editors: Default::default(),
|
||||||
|
terminal_views: Default::default(),
|
||||||
list_state: list_state.clone(),
|
list_state: list_state.clone(),
|
||||||
scrollbar_state: ScrollbarState::new(list_state).parent_entity(&cx.entity()),
|
scrollbar_state: ScrollbarState::new(list_state).parent_entity(&cx.entity()),
|
||||||
last_error: None,
|
last_error: None,
|
||||||
|
@ -676,6 +674,16 @@ impl AcpThreadView {
|
||||||
entry_ix: usize,
|
entry_ix: usize,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
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 {
|
let Some(multibuffers) = self.entry_diff_multibuffers(entry_ix, cx) else {
|
||||||
return;
|
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(
|
fn authenticate(
|
||||||
&mut self,
|
&mut self,
|
||||||
method: acp::AuthMethodId,
|
method: acp::AuthMethodId,
|
||||||
|
@ -1106,7 +1158,7 @@ impl AcpThreadView {
|
||||||
_ => tool_call
|
_ => tool_call
|
||||||
.content
|
.content
|
||||||
.iter()
|
.iter()
|
||||||
.any(|content| matches!(content, ToolCallContent::Diff { .. })),
|
.any(|content| matches!(content, ToolCallContent::Diff(_))),
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
|
let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
|
||||||
|
@ -1303,7 +1355,7 @@ impl AcpThreadView {
|
||||||
cx: &Context<Self>,
|
cx: &Context<Self>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
match content {
|
match content {
|
||||||
ToolCallContent::ContentBlock { content } => {
|
ToolCallContent::ContentBlock(content) => {
|
||||||
if let Some(md) = content.markdown() {
|
if let Some(md) = content.markdown() {
|
||||||
div()
|
div()
|
||||||
.p_2()
|
.p_2()
|
||||||
|
@ -1318,9 +1370,8 @@ impl AcpThreadView {
|
||||||
Empty.into_any_element()
|
Empty.into_any_element()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolCallContent::Diff { diff, .. } => {
|
ToolCallContent::Diff(diff) => self.render_diff_editor(&diff.read(cx).multibuffer()),
|
||||||
self.render_diff_editor(&diff.read(cx).multibuffer())
|
ToolCallContent::Terminal(terminal) => self.render_terminal(terminal),
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1389,6 +1440,21 @@ impl AcpThreadView {
|
||||||
.into_any()
|
.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 {
|
fn render_agent_logo(&self) -> AnyElement {
|
||||||
Icon::new(self.agent.logo())
|
Icon::new(self.agent.logo())
|
||||||
.color(Color::Muted)
|
.color(Color::Muted)
|
||||||
|
|
|
@ -5,6 +5,13 @@ edition.workspace = true
|
||||||
publish.workspace = true
|
publish.workspace = true
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
test-support = [
|
||||||
|
"collections/test-support",
|
||||||
|
"gpui/test-support",
|
||||||
|
"settings/test-support",
|
||||||
|
]
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
|
@ -39,5 +46,6 @@ workspace-hack.workspace = true
|
||||||
windows.workspace = true
|
windows.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
gpui = { workspace = true, features = ["test-support"] }
|
||||||
rand.workspace = true
|
rand.workspace = true
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
|
|
|
@ -58,7 +58,7 @@ use std::{
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
process::ExitStatus,
|
process::ExitStatus,
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
time::{Duration, Instant},
|
time::Instant,
|
||||||
};
|
};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
@ -534,10 +534,15 @@ impl TerminalBuilder {
|
||||||
|
|
||||||
'outer: loop {
|
'outer: loop {
|
||||||
let mut events = Vec::new();
|
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
|
let mut timer = cx
|
||||||
.background_executor()
|
.background_executor()
|
||||||
.timer(Duration::from_millis(4))
|
.timer(std::time::Duration::from_millis(4))
|
||||||
.fuse();
|
.fuse();
|
||||||
|
|
||||||
let mut wakeup = false;
|
let mut wakeup = false;
|
||||||
loop {
|
loop {
|
||||||
futures::select_biased! {
|
futures::select_biased! {
|
||||||
|
@ -2104,16 +2109,56 @@ pub fn rgba_color(r: u8, g: u8, b: u8) -> Hsla {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
IndexedCell, TerminalBounds, TerminalBuilder, TerminalContent, content_index_for_mouse,
|
||||||
|
rgb_for_index,
|
||||||
|
};
|
||||||
use alacritty_terminal::{
|
use alacritty_terminal::{
|
||||||
index::{Column, Line, Point as AlacPoint},
|
index::{Column, Line, Point as AlacPoint},
|
||||||
term::cell::Cell,
|
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 rand::{Rng, distributions::Alphanumeric, rngs::ThreadRng, thread_rng};
|
||||||
|
|
||||||
use crate::{
|
#[cfg_attr(windows, ignore = "TODO: fix on windows")]
|
||||||
IndexedCell, TerminalBounds, TerminalContent, content_index_for_mouse, rgb_for_index,
|
#[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]
|
#[test]
|
||||||
fn test_rgb_for_index() {
|
fn test_rgb_for_index() {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue