Update tool calls via ACP

Agus Zubiaga created

Change summary

crates/acp/src/acp.rs         | 56 +++++++++++++++++++++++++++++++++---
crates/acp/src/server.rs      | 21 +++++++++++++
crates/acp/src/thread_view.rs | 43 +++++++++++++++++++++++++---
3 files changed, 109 insertions(+), 11 deletions(-)

Detailed changes

crates/acp/src/acp.rs 🔗

@@ -2,7 +2,7 @@ mod server;
 mod thread_view;
 
 use agentic_coding_protocol::{self as acp, Role};
-use anyhow::Result;
+use anyhow::{Context as _, Result};
 use chrono::{DateTime, Utc};
 use futures::channel::oneshot;
 use gpui::{AppContext, Context, Entity, EventEmitter, SharedString, Task};
@@ -134,7 +134,11 @@ pub enum ToolCallStatus {
         respond_tx: oneshot::Sender<bool>,
     },
     // todo! Running?
-    Allowed,
+    Allowed {
+        // todo! should this be variants in crate::ToolCallStatus instead?
+        status: acp::ToolCallStatus,
+        content: Option<Entity<Markdown>>,
+    },
     Rejected,
 }
 
@@ -310,7 +314,10 @@ impl AcpThread {
         };
 
         let new_status = if allowed {
-            ToolCallStatus::Allowed
+            ToolCallStatus::Allowed {
+                status: acp::ToolCallStatus::Running,
+                content: None,
+            }
         } else {
             ToolCallStatus::Rejected
         };
@@ -326,6 +333,43 @@ impl AcpThread {
         cx.emit(AcpThreadEvent::EntryUpdated(id.as_u64() as usize));
     }
 
+    pub fn update_tool_call(
+        &mut self,
+        id: ToolCallId,
+        new_status: acp::ToolCallStatus,
+        new_content: Option<acp::ToolCallContent>,
+        cx: &mut Context<Self>,
+    ) -> Result<()> {
+        let language_registry = self.project.read(cx).languages().clone();
+        let entry = self.entry_mut(id.0).context("Entry not found")?;
+
+        match &mut entry.content {
+            AgentThreadEntryContent::ToolCall(call) => match &mut call.status {
+                ToolCallStatus::Allowed { content, status } => {
+                    *content = new_content.map(|new_content| {
+                        let acp::ToolCallContent::Markdown { markdown } = new_content;
+
+                        cx.new(|cx| {
+                            Markdown::new(markdown.into(), Some(language_registry), None, cx)
+                        })
+                    });
+
+                    *status = new_status;
+                }
+                ToolCallStatus::WaitingForConfirmation { .. } => {
+                    anyhow::bail!("Tool call hasn't been authorized yet")
+                }
+                ToolCallStatus::Rejected => {
+                    anyhow::bail!("Tool call was rejected and therefore can't be updated")
+                }
+            },
+            _ => anyhow::bail!("Entry is not a tool call"),
+        }
+
+        cx.emit(AcpThreadEvent::EntryUpdated(id.as_u64() as usize));
+        Ok(())
+    }
+
     fn entry_mut(&mut self, id: ThreadEntryId) -> Option<&mut ThreadEntry> {
         let entry = self.entries.get_mut(id.0 as usize);
         debug_assert!(
@@ -341,7 +385,7 @@ impl AcpThread {
             match &entry.content {
                 AgentThreadEntryContent::ToolCall(call) => match call.status {
                     ToolCallStatus::WaitingForConfirmation { .. } => return true,
-                    ToolCallStatus::Allowed | ToolCallStatus::Rejected => continue,
+                    ToolCallStatus::Allowed { .. } | ToolCallStatus::Rejected => continue,
                 },
                 AgentThreadEntryContent::Message(_) => {
                     // Reached the beginning of the turn
@@ -478,7 +522,7 @@ mod tests {
             assert!(matches!(
                 thread.entries().last().unwrap().content,
                 AgentThreadEntryContent::ToolCall(ToolCall {
-                    status: ToolCallStatus::Allowed,
+                    status: ToolCallStatus::Allowed { .. },
                     ..
                 })
             ));
@@ -498,7 +542,7 @@ mod tests {
             assert!(matches!(
                 thread.entries[1].content,
                 AgentThreadEntryContent::ToolCall(ToolCall {
-                    status: ToolCallStatus::Allowed,
+                    status: ToolCallStatus::Allowed { .. },
                     ..
                 })
             ));

crates/acp/src/server.rs 🔗

@@ -202,6 +202,27 @@ impl acp::Client for AcpClientDelegate {
             Ok(acp::RequestToolCallResponse::Rejected)
         }
     }
+
+    async fn update_tool_call(
+        &self,
+        request: acp::UpdateToolCallParams,
+    ) -> Result<acp::UpdateToolCallResponse> {
+        let cx = &mut self.cx.clone();
+
+        cx.update(|cx| {
+            self.update_thread(&request.thread_id.into(), cx, |thread, cx| {
+                thread.update_tool_call(
+                    request.tool_call_id.into(),
+                    request.status,
+                    request.content,
+                    cx,
+                )
+            })
+        })?
+        .context("Failed to update thread")??;
+
+        Ok(acp::UpdateToolCallResponse)
+    }
 }
 
 impl AcpServer {

crates/acp/src/thread_view.rs 🔗

@@ -1,11 +1,14 @@
 use std::path::Path;
 use std::rc::Rc;
+use std::time::Duration;
 
+use agentic_coding_protocol::{self as acp};
 use anyhow::Result;
 use editor::{Editor, MultiBuffer};
 use gpui::{
-    App, EdgesRefinement, Empty, Entity, Focusable, ListState, SharedString, StyleRefinement,
-    Subscription, TextStyleRefinement, UnderlineStyle, Window, div, list, prelude::*,
+    Animation, AnimationExt, App, EdgesRefinement, Empty, Entity, Focusable, ListState,
+    SharedString, StyleRefinement, Subscription, TextStyleRefinement, Transformation,
+    UnderlineStyle, Window, div, list, percentage, prelude::*,
 };
 use gpui::{FocusHandle, Task};
 use language::Buffer;
@@ -256,11 +259,30 @@ impl AcpThreadView {
     fn render_tool_call(&self, tool_call: &ToolCall, window: &Window, cx: &Context<Self>) -> Div {
         let status_icon = match &tool_call.status {
             ToolCallStatus::WaitingForConfirmation { .. } => Empty.into_element().into_any(),
-            ToolCallStatus::Allowed => Icon::new(IconName::Check)
+            ToolCallStatus::Allowed {
+                status: acp::ToolCallStatus::Running,
+                ..
+            } => Icon::new(IconName::ArrowCircle)
+                .color(Color::Success)
+                .size(IconSize::Small)
+                .with_animation(
+                    "running",
+                    Animation::new(Duration::from_secs(2)).repeat(),
+                    |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
+                )
+                .into_any_element(),
+            ToolCallStatus::Allowed {
+                status: acp::ToolCallStatus::Finished,
+                ..
+            } => Icon::new(IconName::Check)
                 .color(Color::Success)
                 .size(IconSize::Small)
                 .into_any_element(),
-            ToolCallStatus::Rejected => Icon::new(IconName::X)
+            ToolCallStatus::Rejected
+            | ToolCallStatus::Allowed {
+                status: acp::ToolCallStatus::Error,
+                ..
+            } => Icon::new(IconName::X)
                 .color(Color::Error)
                 .size(IconSize::Small)
                 .into_any_element(),
@@ -309,7 +331,18 @@ impl AcpThreadView {
                 )
                 .into_any()
                 .into(),
-            ToolCallStatus::Allowed => None,
+            ToolCallStatus::Allowed { content, .. } => content.clone().map(|content| {
+                div()
+                    .border_color(cx.theme().colors().border)
+                    .border_t_1()
+                    .px_2()
+                    .py_1p5()
+                    .child(MarkdownElement::new(
+                        content,
+                        default_markdown_style(window, cx),
+                    ))
+                    .into_any_element()
+            }),
             ToolCallStatus::Rejected => None,
         };