diff --git a/Cargo.lock b/Cargo.lock
index 5ceed10b19..a323fb70af 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -279,9 +279,9 @@ dependencies = [
[[package]]
name = "agentic-coding-protocol"
-version = "0.0.9"
+version = "0.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e276b798eddd02562a339340a96919d90bbfcf78de118fdddc932524646fac7"
+checksum = "a3e6ae951b36fa2f8d9dd6e1af6da2fcaba13d7c866cf6a9e65deda9dc6c5fe4"
dependencies = [
"anyhow",
"chrono",
diff --git a/Cargo.toml b/Cargo.toml
index 0169d32eb6..c99ba3953d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -410,7 +410,7 @@ zlog_settings = { path = "crates/zlog_settings" }
# External crates
#
-agentic-coding-protocol = "0.0.9"
+agentic-coding-protocol = "0.0.10"
aho-corasick = "1.1"
alacritty_terminal = { git = "https://github.com/zed-industries/alacritty.git", branch = "add-hush-login-flag" }
any_vec = "0.14"
diff --git a/assets/icons/todo_complete.svg b/assets/icons/todo_complete.svg
new file mode 100644
index 0000000000..9fa2e818bb
--- /dev/null
+++ b/assets/icons/todo_complete.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/todo_pending.svg b/assets/icons/todo_pending.svg
new file mode 100644
index 0000000000..dfb013b52b
--- /dev/null
+++ b/assets/icons/todo_pending.svg
@@ -0,0 +1,10 @@
+
diff --git a/assets/icons/todo_progress.svg b/assets/icons/todo_progress.svg
new file mode 100644
index 0000000000..9b2ed7375d
--- /dev/null
+++ b/assets/icons/todo_progress.svg
@@ -0,0 +1,11 @@
+
diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs
index ae22725d5e..9af1eeb187 100644
--- a/crates/acp_thread/src/acp_thread.rs
+++ b/crates/acp_thread/src/acp_thread.rs
@@ -453,9 +453,69 @@ impl Diff {
}
}
+#[derive(Debug, Default)]
+pub struct Plan {
+ pub entries: Vec,
+}
+
+#[derive(Debug)]
+pub struct PlanStats<'a> {
+ pub in_progress_entry: Option<&'a PlanEntry>,
+ pub pending: u32,
+ pub completed: u32,
+}
+
+impl Plan {
+ pub fn is_empty(&self) -> bool {
+ self.entries.is_empty()
+ }
+
+ pub fn stats(&self) -> PlanStats<'_> {
+ let mut stats = PlanStats {
+ in_progress_entry: None,
+ pending: 0,
+ completed: 0,
+ };
+
+ for entry in &self.entries {
+ match &entry.status {
+ acp::PlanEntryStatus::Pending => {
+ stats.pending += 1;
+ }
+ acp::PlanEntryStatus::InProgress => {
+ stats.in_progress_entry = stats.in_progress_entry.or(Some(entry));
+ }
+ acp::PlanEntryStatus::Completed => {
+ stats.completed += 1;
+ }
+ }
+ }
+
+ stats
+ }
+}
+
+#[derive(Debug)]
+pub struct PlanEntry {
+ pub content: Entity,
+ pub priority: acp::PlanEntryPriority,
+ pub status: acp::PlanEntryStatus,
+}
+
+impl PlanEntry {
+ pub fn from_acp(entry: acp::PlanEntry, cx: &mut App) -> Self {
+ Self {
+ content: cx.new(|cx| Markdown::new_text(entry.content.into(), cx)),
+ priority: entry.priority,
+ status: entry.status,
+ }
+ }
+}
+
pub struct AcpThread {
- entries: Vec,
title: SharedString,
+ entries: Vec,
+ plan: Plan,
project: Entity,
action_log: Entity,
shared_buffers: HashMap, BufferSnapshot>,
@@ -515,6 +575,7 @@ impl AcpThread {
action_log,
shared_buffers: Default::default(),
entries: Default::default(),
+ plan: Default::default(),
title,
project,
send_task: None,
@@ -819,6 +880,29 @@ impl AcpThread {
}
}
+ pub fn plan(&self) -> &Plan {
+ &self.plan
+ }
+
+ pub fn update_plan(&mut self, request: acp::UpdatePlanParams, cx: &mut Context) {
+ self.plan = Plan {
+ entries: request
+ .entries
+ .into_iter()
+ .map(|entry| PlanEntry::from_acp(entry, cx))
+ .collect(),
+ };
+
+ cx.notify();
+ }
+
+ pub fn clear_completed_plan_entries(&mut self, cx: &mut Context) {
+ self.plan
+ .entries
+ .retain(|entry| !matches!(entry.status, acp::PlanEntryStatus::Completed));
+ cx.notify();
+ }
+
pub fn set_project_location(&self, location: ToolCallLocation, cx: &mut Context) {
self.project.update(cx, |project, cx| {
let Some(path) = project.project_path_for_absolute_path(&location.path, cx) else {
@@ -1136,6 +1220,17 @@ impl AcpClientDelegate {
Self { thread, cx }
}
+ pub async fn clear_completed_plan_entries(&self) -> Result<()> {
+ let cx = &mut self.cx.clone();
+ cx.update(|cx| {
+ self.thread
+ .update(cx, |thread, cx| thread.clear_completed_plan_entries(cx))
+ })?
+ .context("Failed to update thread")?;
+
+ Ok(())
+ }
+
pub async fn request_existing_tool_call_confirmation(
&self,
tool_call_id: ToolCallId,
@@ -1233,6 +1328,18 @@ impl acp::Client for AcpClientDelegate {
Ok(())
}
+ async fn update_plan(&self, request: acp::UpdatePlanParams) -> Result<(), acp::Error> {
+ let cx = &mut self.cx.clone();
+
+ cx.update(|cx| {
+ self.thread
+ .update(cx, |thread, cx| thread.update_plan(request, cx))
+ })?
+ .context("Failed to update thread")?;
+
+ Ok(())
+ }
+
async fn read_text_file(
&self,
request: acp::ReadTextFileParams,
diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs
index 52c6012267..8b3d93a122 100644
--- a/crates/agent_servers/src/claude.rs
+++ b/crates/agent_servers/src/claude.rs
@@ -153,6 +153,7 @@ impl AgentServer for ClaudeCode {
let handler_task = cx.foreground_executor().spawn({
let end_turn_tx = end_turn_tx.clone();
let tool_id_map = tool_id_map.clone();
+ let delegate = delegate.clone();
async move {
while let Some(message) = incoming_message_rx.next().await {
ClaudeAgentConnection::handle_message(
@@ -167,6 +168,7 @@ impl AgentServer for ClaudeCode {
});
let mut connection = ClaudeAgentConnection {
+ delegate,
outgoing_tx,
end_turn_tx,
_handler_task: handler_task,
@@ -186,6 +188,7 @@ impl AgentConnection for ClaudeAgentConnection {
&self,
params: AnyAgentRequest,
) -> LocalBoxFuture<'static, Result> {
+ let delegate = self.delegate.clone();
let end_turn_tx = self.end_turn_tx.clone();
let outgoing_tx = self.outgoing_tx.clone();
async move {
@@ -201,6 +204,8 @@ impl AgentConnection for ClaudeAgentConnection {
Err(anyhow!("Authentication not supported"))
}
AnyAgentRequest::SendUserMessageParams(message) => {
+ delegate.clear_completed_plan_entries().await?;
+
let (tx, rx) = oneshot::channel();
end_turn_tx.borrow_mut().replace(tx);
let mut content = String::new();
@@ -241,6 +246,7 @@ impl AgentConnection for ClaudeAgentConnection {
}
struct ClaudeAgentConnection {
+ delegate: AcpClientDelegate,
outgoing_tx: UnboundedSender,
end_turn_tx: Rc>>>>,
_mcp_server: Option,
@@ -267,8 +273,17 @@ impl ClaudeAgentConnection {
.log_err();
}
ContentChunk::ToolUse { id, name, input } => {
- if let Some(resp) = delegate
- .push_tool_call(ClaudeTool::infer(&name, input).as_acp())
+ let claude_tool = ClaudeTool::infer(&name, input);
+
+ if let ClaudeTool::TodoWrite(Some(params)) = claude_tool {
+ delegate
+ .update_plan(acp::UpdatePlanParams {
+ entries: params.todos.into_iter().map(Into::into).collect(),
+ })
+ .await
+ .log_err();
+ } else if let Some(resp) = delegate
+ .push_tool_call(claude_tool.as_acp())
.await
.log_err()
{
diff --git a/crates/agent_servers/src/claude/tools.rs b/crates/agent_servers/src/claude/tools.rs
index a2d6b487b2..9c82139a07 100644
--- a/crates/agent_servers/src/claude/tools.rs
+++ b/crates/agent_servers/src/claude/tools.rs
@@ -614,6 +614,16 @@ pub enum TodoPriority {
Low,
}
+impl Into for TodoPriority {
+ fn into(self) -> acp::PlanEntryPriority {
+ match self {
+ TodoPriority::High => acp::PlanEntryPriority::High,
+ TodoPriority::Medium => acp::PlanEntryPriority::Medium,
+ TodoPriority::Low => acp::PlanEntryPriority::Low,
+ }
+ }
+}
+
#[derive(Deserialize, Serialize, JsonSchema, Debug)]
#[serde(rename_all = "snake_case")]
pub enum TodoStatus {
@@ -622,6 +632,16 @@ pub enum TodoStatus {
Completed,
}
+impl Into for TodoStatus {
+ fn into(self) -> acp::PlanEntryStatus {
+ match self {
+ TodoStatus::Pending => acp::PlanEntryStatus::Pending,
+ TodoStatus::InProgress => acp::PlanEntryStatus::InProgress,
+ TodoStatus::Completed => acp::PlanEntryStatus::Completed,
+ }
+ }
+}
+
#[derive(Deserialize, Serialize, JsonSchema, Debug)]
pub struct Todo {
/// Unique identifier
@@ -634,6 +654,16 @@ pub struct Todo {
pub status: TodoStatus,
}
+impl Into for Todo {
+ fn into(self) -> acp::PlanEntry {
+ acp::PlanEntry {
+ content: self.content,
+ priority: self.priority.into(),
+ status: self.status.into(),
+ }
+ }
+}
+
#[derive(Deserialize, JsonSchema, Debug)]
pub struct TodoWriteToolParams {
pub todos: Vec,
diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs
index d2903ab6eb..95f4f81205 100644
--- a/crates/agent_ui/src/acp/thread_view.rs
+++ b/crates/agent_ui/src/acp/thread_view.rs
@@ -1,3 +1,4 @@
+use acp_thread::Plan;
use agent_servers::AgentServer;
use std::cell::RefCell;
use std::collections::BTreeMap;
@@ -66,7 +67,8 @@ pub struct AcpThreadView {
expanded_tool_calls: HashSet,
expanded_thinking_blocks: HashSet<(usize, usize)>,
edits_expanded: bool,
- editor_is_expanded: bool,
+ plan_expanded: bool,
+ editor_expanded: bool,
message_history: Rc>>,
}
@@ -186,7 +188,8 @@ impl AcpThreadView {
expanded_tool_calls: HashSet::default(),
expanded_thinking_blocks: HashSet::default(),
edits_expanded: false,
- editor_is_expanded: false,
+ plan_expanded: false,
+ editor_expanded: false,
message_history,
}
}
@@ -332,14 +335,14 @@ impl AcpThreadView {
_window: &mut Window,
cx: &mut Context,
) {
- self.set_editor_is_expanded(!self.editor_is_expanded, cx);
+ self.set_editor_is_expanded(!self.editor_expanded, cx);
cx.notify();
}
fn set_editor_is_expanded(&mut self, is_expanded: bool, cx: &mut Context) {
- self.editor_is_expanded = is_expanded;
+ self.editor_expanded = is_expanded;
self.message_editor.update(cx, |editor, _| {
- if self.editor_is_expanded {
+ if self.editor_expanded {
editor.set_mode(EditorMode::Full {
scale_ui_elements_with_buffer_font_size: false,
show_active_line_background: false,
@@ -1477,7 +1480,7 @@ impl AcpThreadView {
container.into_any()
}
- fn render_edits_bar(
+ fn render_activity_bar(
&self,
thread_entity: &Entity,
window: &mut Window,
@@ -1486,8 +1489,9 @@ impl AcpThreadView {
let thread = thread_entity.read(cx);
let action_log = thread.action_log();
let changed_buffers = action_log.read(cx).changed_buffers(cx);
+ let plan = thread.plan();
- if changed_buffers.is_empty() {
+ if changed_buffers.is_empty() && plan.is_empty() {
return None;
}
@@ -1496,7 +1500,6 @@ impl AcpThreadView {
let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3));
let pending_edits = thread.has_pending_edit_tool_calls();
- let expanded = self.edits_expanded;
v_flex()
.mt_1()
@@ -1512,27 +1515,165 @@ impl AcpThreadView {
blur_radius: px(3.),
spread_radius: px(0.),
}])
- .child(self.render_edits_bar_summary(
- action_log,
- &changed_buffers,
- expanded,
- pending_edits,
- window,
- cx,
- ))
- .when(expanded, |parent| {
- parent.child(self.render_edits_bar_files(
- action_log,
- &changed_buffers,
- pending_edits,
- cx,
- ))
+ .when(!plan.is_empty(), |this| {
+ this.child(self.render_plan_summary(plan, window, cx))
+ .when(self.plan_expanded, |parent| {
+ parent.child(self.render_plan_entries(plan, window, cx))
+ })
+ })
+ .when(!changed_buffers.is_empty(), |this| {
+ this.child(Divider::horizontal())
+ .child(self.render_edits_summary(
+ action_log,
+ &changed_buffers,
+ self.edits_expanded,
+ pending_edits,
+ window,
+ cx,
+ ))
+ .when(self.edits_expanded, |parent| {
+ parent.child(self.render_edited_files(
+ action_log,
+ &changed_buffers,
+ pending_edits,
+ cx,
+ ))
+ })
})
.into_any()
.into()
}
- fn render_edits_bar_summary(
+ fn render_plan_summary(&self, plan: &Plan, window: &mut Window, cx: &Context) -> Div {
+ let stats = plan.stats();
+
+ let title = if let Some(entry) = stats.in_progress_entry
+ && !self.plan_expanded
+ {
+ h_flex()
+ .w_full()
+ .gap_1()
+ .text_xs()
+ .text_color(cx.theme().colors().text_muted)
+ .justify_between()
+ .child(
+ h_flex()
+ .gap_1()
+ .child(
+ Label::new("Current:")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ .child(MarkdownElement::new(
+ entry.content.clone(),
+ plan_label_markdown_style(&entry.status, window, cx),
+ )),
+ )
+ .when(stats.pending > 0, |this| {
+ this.child(
+ Label::new(format!("{} left", stats.pending))
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .mr_1(),
+ )
+ })
+ } else {
+ let status_label = if stats.pending == 0 {
+ "All Done".to_string()
+ } else if stats.completed == 0 {
+ format!("{}", plan.entries.len())
+ } else {
+ format!("{}/{}", stats.completed, plan.entries.len())
+ };
+
+ h_flex()
+ .w_full()
+ .gap_1()
+ .justify_between()
+ .child(
+ Label::new("Plan")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ .child(
+ Label::new(status_label)
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .mr_1(),
+ )
+ };
+
+ h_flex()
+ .p_1()
+ .justify_between()
+ .when(self.plan_expanded, |this| {
+ this.border_b_1().border_color(cx.theme().colors().border)
+ })
+ .child(
+ h_flex()
+ .id("plan_summary")
+ .w_full()
+ .gap_1()
+ .child(Disclosure::new("plan_disclosure", self.plan_expanded))
+ .child(title)
+ .on_click(cx.listener(|this, _, _, cx| {
+ this.plan_expanded = !this.plan_expanded;
+ cx.notify();
+ })),
+ )
+ }
+
+ fn render_plan_entries(&self, plan: &Plan, window: &mut Window, cx: &Context) -> Div {
+ v_flex().children(plan.entries.iter().enumerate().flat_map(|(index, entry)| {
+ let element = h_flex()
+ .py_1()
+ .px_2()
+ .gap_2()
+ .justify_between()
+ .bg(cx.theme().colors().editor_background)
+ .when(index < plan.entries.len() - 1, |parent| {
+ parent.border_color(cx.theme().colors().border).border_b_1()
+ })
+ .child(
+ h_flex()
+ .id(("plan_entry", index))
+ .gap_1p5()
+ .max_w_full()
+ .overflow_x_scroll()
+ .text_xs()
+ .text_color(cx.theme().colors().text_muted)
+ .child(match entry.status {
+ acp::PlanEntryStatus::Pending => Icon::new(IconName::TodoPending)
+ .size(IconSize::Small)
+ .color(Color::Muted)
+ .into_any_element(),
+ acp::PlanEntryStatus::InProgress => Icon::new(IconName::TodoProgress)
+ .size(IconSize::Small)
+ .color(Color::Accent)
+ .with_animation(
+ "running",
+ Animation::new(Duration::from_secs(2)).repeat(),
+ |icon, delta| {
+ icon.transform(Transformation::rotate(percentage(delta)))
+ },
+ )
+ .into_any_element(),
+ acp::PlanEntryStatus::Completed => Icon::new(IconName::TodoComplete)
+ .size(IconSize::Small)
+ .color(Color::Success)
+ .into_any_element(),
+ })
+ .child(MarkdownElement::new(
+ entry.content.clone(),
+ plan_label_markdown_style(&entry.status, window, cx),
+ )),
+ );
+
+ Some(element)
+ }))
+ }
+
+ fn render_edits_summary(
&self,
action_log: &Entity,
changed_buffers: &BTreeMap, Entity>,
@@ -1678,7 +1819,7 @@ impl AcpThreadView {
)
}
- fn render_edits_bar_files(
+ fn render_edited_files(
&self,
action_log: &Entity,
changed_buffers: &BTreeMap, Entity>,
@@ -1831,7 +1972,7 @@ impl AcpThreadView {
fn render_message_editor(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement {
let focus_handle = self.message_editor.focus_handle(cx);
let editor_bg_color = cx.theme().colors().editor_background;
- let (expand_icon, expand_tooltip) = if self.editor_is_expanded {
+ let (expand_icon, expand_tooltip) = if self.editor_expanded {
(IconName::Minimize, "Minimize Message Editor")
} else {
(IconName::Maximize, "Expand Message Editor")
@@ -1844,7 +1985,7 @@ impl AcpThreadView {
.border_t_1()
.border_color(cx.theme().colors().border)
.bg(editor_bg_color)
- .when(self.editor_is_expanded, |this| {
+ .when(self.editor_expanded, |this| {
this.h(vh(0.8, window)).size_full().justify_between()
})
.child(
@@ -2243,7 +2384,7 @@ impl Render for AcpThreadView {
.child(LoadingLabel::new("").size(LabelSize::Small))
.into(),
})
- .children(self.render_edits_bar(&thread, window, cx))
+ .children(self.render_activity_bar(&thread, window, cx))
} else {
this.child(self.render_empty_state(cx))
}
@@ -2409,3 +2550,27 @@ fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> Markd
..Default::default()
}
}
+
+fn plan_label_markdown_style(
+ status: &acp::PlanEntryStatus,
+ window: &Window,
+ cx: &App,
+) -> MarkdownStyle {
+ let default_md_style = default_markdown_style(false, window, cx);
+
+ MarkdownStyle {
+ base_text_style: TextStyle {
+ color: cx.theme().colors().text_muted,
+ strikethrough: if matches!(status, acp::PlanEntryStatus::Completed) {
+ Some(gpui::StrikethroughStyle {
+ thickness: px(1.),
+ color: Some(cx.theme().colors().text_muted.opacity(0.8)),
+ })
+ } else {
+ None
+ },
+ ..default_md_style.base_text_style
+ },
+ ..default_md_style
+ }
+}
diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs
index 6834d56215..631ccc1af3 100644
--- a/crates/icons/src/icons.rs
+++ b/crates/icons/src/icons.rs
@@ -256,6 +256,9 @@ pub enum IconName {
TextSnippet,
ThumbsDown,
ThumbsUp,
+ TodoComplete,
+ TodoPending,
+ TodoProgress,
ToolBulb,
ToolCopy,
ToolDeleteFile,