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,