diff --git a/assets/icons/list_todo.svg b/assets/icons/list_todo.svg new file mode 100644 index 0000000000..1f50219418 --- /dev/null +++ b/assets/icons/list_todo.svg @@ -0,0 +1 @@ + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 0b463266f5..9012c1b092 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -278,7 +278,9 @@ "enter": "agent::Chat", "ctrl-enter": "agent::ChatWithFollow", "ctrl-i": "agent::ToggleProfileSelector", - "shift-ctrl-r": "agent::OpenAgentDiff" + "shift-ctrl-r": "agent::OpenAgentDiff", + "ctrl-shift-y": "agent::KeepAll", + "ctrl-shift-n": "agent::RejectAll" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 75d35f3ed3..05aa67f8a7 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -315,7 +315,9 @@ "enter": "agent::Chat", "cmd-enter": "agent::ChatWithFollow", "cmd-i": "agent::ToggleProfileSelector", - "shift-ctrl-r": "agent::OpenAgentDiff" + "shift-ctrl-r": "agent::OpenAgentDiff", + "cmd-shift-y": "agent::KeepAll", + "cmd-shift-n": "agent::RejectAll" } }, { diff --git a/crates/agent/src/context_server_tool.rs b/crates/agent/src/context_server_tool.rs index 68ffefb126..e4461f94de 100644 --- a/crates/agent/src/context_server_tool.rs +++ b/crates/agent/src/context_server_tool.rs @@ -51,6 +51,10 @@ impl Tool for ContextServerTool { true } + fn may_perform_edits(&self) -> bool { + true + } + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { let mut schema = self.tool.input_schema.clone(); assistant_tool::adapt_schema_to_format(&mut schema, format)?; diff --git a/crates/agent/src/message_editor.rs b/crates/agent/src/message_editor.rs index 9e3467cca6..484e91abfd 100644 --- a/crates/agent/src/message_editor.rs +++ b/crates/agent/src/message_editor.rs @@ -6,7 +6,7 @@ use crate::agent_model_selector::{AgentModelSelector, ModelType}; use crate::context::{AgentContextKey, ContextCreasesAddon, ContextLoadResult, load_context}; use crate::tool_compatibility::{IncompatibleToolsState, IncompatibleToolsTooltip}; use crate::ui::{ - AnimatedLabel, MaxModeTooltip, + MaxModeTooltip, preview::{AgentPreview, UsageCallout}, }; use agent_settings::{AgentSettings, CompletionMode}; @@ -27,7 +27,7 @@ use gpui::{ Animation, AnimationExt, App, ClipboardEntry, Entity, EventEmitter, Focusable, Subscription, Task, TextStyle, WeakEntity, linear_color_stop, linear_gradient, point, pulsating_between, }; -use language::{Buffer, Language}; +use language::{Buffer, Language, Point}; use language_model::{ ConfiguredModel, LanguageModelRequestMessage, MessageContent, RequestUsage, ZED_CLOUD_PROVIDER_ID, @@ -51,9 +51,9 @@ use crate::profile_selector::ProfileSelector; use crate::thread::{MessageCrease, Thread, TokenUsageRatio}; use crate::thread_store::{TextThreadStore, ThreadStore}; use crate::{ - ActiveThread, AgentDiffPane, Chat, ChatWithFollow, ExpandMessageEditor, Follow, NewThread, - OpenAgentDiff, RemoveAllContext, ToggleBurnMode, ToggleContextPicker, ToggleProfileSelector, - register_agent_preview, + ActiveThread, AgentDiffPane, Chat, ChatWithFollow, ExpandMessageEditor, Follow, KeepAll, + NewThread, OpenAgentDiff, RejectAll, RemoveAllContext, ToggleBurnMode, ToggleContextPicker, + ToggleProfileSelector, register_agent_preview, }; #[derive(RegisterComponent)] @@ -459,11 +459,20 @@ impl MessageEditor { } fn handle_review_click(&mut self, window: &mut Window, cx: &mut Context) { + if self.thread.read(cx).has_pending_edit_tool_uses() { + return; + } + self.edits_expanded = true; AgentDiffPane::deploy(self.thread.clone(), self.workspace.clone(), window, cx).log_err(); cx.notify(); } + fn handle_edit_bar_expand(&mut self, cx: &mut Context) { + self.edits_expanded = !self.edits_expanded; + cx.notify(); + } + fn handle_file_click( &self, buffer: Entity, @@ -494,6 +503,40 @@ impl MessageEditor { }); } + fn handle_accept_all(&mut self, _window: &mut Window, cx: &mut Context) { + if self.thread.read(cx).has_pending_edit_tool_uses() { + return; + } + + self.thread.update(cx, |thread, cx| { + thread.keep_all_edits(cx); + }); + cx.notify(); + } + + fn handle_reject_all(&mut self, _window: &mut Window, cx: &mut Context) { + if self.thread.read(cx).has_pending_edit_tool_uses() { + return; + } + + // Since there's no reject_all_edits method in the thread API, + // we need to iterate through all buffers and reject their edits + let action_log = self.thread.read(cx).action_log().clone(); + let changed_buffers = action_log.read(cx).changed_buffers(cx); + + for (buffer, _) in changed_buffers { + self.thread.update(cx, |thread, cx| { + let buffer_snapshot = buffer.read(cx); + let start = buffer_snapshot.anchor_before(Point::new(0, 0)); + let end = buffer_snapshot.anchor_after(buffer_snapshot.max_point()); + thread + .reject_edits_in_ranges(buffer, vec![start..end], cx) + .detach(); + }); + } + cx.notify(); + } + fn render_max_mode_toggle(&self, cx: &mut Context) -> Option { let thread = self.thread.read(cx); let model = thread.configured_model(); @@ -615,6 +658,12 @@ impl MessageEditor { .on_action(cx.listener(Self::move_up)) .on_action(cx.listener(Self::expand_message_editor)) .on_action(cx.listener(Self::toggle_burn_mode)) + .on_action( + cx.listener(|this, _: &KeepAll, window, cx| this.handle_accept_all(window, cx)), + ) + .on_action( + cx.listener(|this, _: &RejectAll, window, cx| this.handle_reject_all(window, cx)), + ) .capture_action(cx.listener(Self::paste)) .gap_2() .p_2() @@ -870,7 +919,10 @@ impl MessageEditor { let bg_edit_files_disclosure = editor_bg_color.blend(active_color.opacity(0.3)); let is_edit_changes_expanded = self.edits_expanded; - let is_generating = self.thread.read(cx).is_generating(); + let thread = self.thread.read(cx); + let pending_edits = thread.has_pending_edit_tool_uses(); + + const EDIT_NOT_READY_TOOLTIP_LABEL: &str = "Wait until file edits are complete."; v_flex() .mt_1() @@ -888,31 +940,28 @@ impl MessageEditor { }]) .child( h_flex() - .id("edits-container") - .cursor_pointer() - .p_1p5() + .p_1() .justify_between() .when(is_edit_changes_expanded, |this| { this.border_b_1().border_color(border_color) }) - .on_click( - cx.listener(|this, _, window, cx| this.handle_review_click(window, cx)), - ) .child( h_flex() + .id("edits-container") + .cursor_pointer() + .w_full() .gap_1() .child( Disclosure::new("edits-disclosure", is_edit_changes_expanded) - .on_click(cx.listener(|this, _ev, _window, cx| { - this.edits_expanded = !this.edits_expanded; - cx.notify(); + .on_click(cx.listener(|this, _, _, cx| { + this.handle_edit_bar_expand(cx) })), ) .map(|this| { - if is_generating { + if pending_edits { this.child( - AnimatedLabel::new(format!( - "Editing {} {}", + Label::new(format!( + "Editing {} {}…", changed_buffers.len(), if changed_buffers.len() == 1 { "file" @@ -920,7 +969,15 @@ impl MessageEditor { "files" } )) - .size(LabelSize::Small), + .color(Color::Muted) + .size(LabelSize::Small) + .with_animation( + "edit-label", + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.3, 0.7)), + |label, delta| label.alpha(delta), + ), ) } else { this.child( @@ -945,23 +1002,74 @@ impl MessageEditor { .color(Color::Muted), ) } - }), + }) + .on_click( + cx.listener(|this, _, _, cx| this.handle_edit_bar_expand(cx)), + ), ) .child( - Button::new("review", "Review Changes") - .label_size(LabelSize::Small) - .key_binding( - KeyBinding::for_action_in( - &OpenAgentDiff, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(12.))), + h_flex() + .gap_1() + .child( + IconButton::new("review-changes", IconName::ListTodo) + .icon_size(IconSize::Small) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |window, cx| { + Tooltip::for_action_in( + "Review Changes", + &OpenAgentDiff, + &focus_handle, + window, + cx, + ) + } + }) + .on_click(cx.listener(|this, _, window, cx| { + this.handle_review_click(window, cx) + })), ) - .on_click(cx.listener(|this, _, window, cx| { - this.handle_review_click(window, cx) - })), + .child(ui::Divider::vertical().color(ui::DividerColor::Border)) + .child( + Button::new("reject-all-changes", "Reject All") + .label_size(LabelSize::Small) + .disabled(pending_edits) + .when(pending_edits, |this| { + this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL)) + }) + .key_binding( + KeyBinding::for_action_in( + &RejectAll, + &focus_handle.clone(), + window, + cx, + ) + .map(|kb| kb.size(rems_from_px(10.))), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.handle_reject_all(window, cx) + })), + ) + .child( + Button::new("accept-all-changes", "Accept All") + .label_size(LabelSize::Small) + .disabled(pending_edits) + .when(pending_edits, |this| { + this.tooltip(Tooltip::text(EDIT_NOT_READY_TOOLTIP_LABEL)) + }) + .key_binding( + KeyBinding::for_action_in( + &KeepAll, + &focus_handle, + window, + cx, + ) + .map(|kb| kb.size(rems_from_px(10.))), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.handle_accept_all(window, cx) + })), + ), ), ) .when(is_edit_changes_expanded, |parent| { diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index f907766759..daa7d5726f 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -871,7 +871,16 @@ impl Thread { self.tool_use .pending_tool_uses() .iter() - .all(|tool_use| tool_use.status.is_error()) + .all(|pending_tool_use| pending_tool_use.status.is_error()) + } + + /// Returns whether any pending tool uses may perform edits + pub fn has_pending_edit_tool_uses(&self) -> bool { + self.tool_use + .pending_tool_uses() + .iter() + .filter(|pending_tool_use| !pending_tool_use.status.is_error()) + .any(|pending_tool_use| pending_tool_use.may_perform_edits) } pub fn tool_uses_for_message(&self, id: MessageId, cx: &App) -> Vec { diff --git a/crates/agent/src/tool_use.rs b/crates/agent/src/tool_use.rs index c26968949f..da6adc07f0 100644 --- a/crates/agent/src/tool_use.rs +++ b/crates/agent/src/tool_use.rs @@ -337,6 +337,12 @@ impl ToolUseState { ) .into(); + let may_perform_edits = self + .tools + .read(cx) + .tool(&tool_use.name, cx) + .is_some_and(|tool| tool.may_perform_edits()); + self.pending_tool_uses_by_id.insert( tool_use.id.clone(), PendingToolUse { @@ -345,6 +351,7 @@ impl ToolUseState { name: tool_use.name.clone(), ui_text: ui_text.clone(), input: tool_use.input, + may_perform_edits, status, }, ); @@ -518,6 +525,7 @@ pub struct PendingToolUse { pub ui_text: Arc, pub input: serde_json::Value, pub status: PendingToolUseStatus, + pub may_perform_edits: bool, } #[derive(Debug, Clone)] diff --git a/crates/assistant_tool/src/assistant_tool.rs b/crates/assistant_tool/src/assistant_tool.rs index ecda105f6d..6c08a61cf4 100644 --- a/crates/assistant_tool/src/assistant_tool.rs +++ b/crates/assistant_tool/src/assistant_tool.rs @@ -218,6 +218,9 @@ pub trait Tool: 'static + Send + Sync { /// before having permission to run. fn needs_confirmation(&self, input: &serde_json::Value, cx: &App) -> bool; + /// Returns true if the tool may perform edits. + fn may_perform_edits(&self) -> bool; + /// Returns the JSON schema that describes the tool's input. fn input_schema(&self, _: LanguageModelToolSchemaFormat) -> Result { Ok(serde_json::Value::Object(serde_json::Map::default())) diff --git a/crates/assistant_tools/src/copy_path_tool.rs b/crates/assistant_tools/src/copy_path_tool.rs index a27209b0d1..28d6bef9dd 100644 --- a/crates/assistant_tools/src/copy_path_tool.rs +++ b/crates/assistant_tools/src/copy_path_tool.rs @@ -48,6 +48,10 @@ impl Tool for CopyPathTool { false } + fn may_perform_edits(&self) -> bool { + true + } + fn description(&self) -> String { include_str!("./copy_path_tool/description.md").into() } diff --git a/crates/assistant_tools/src/create_directory_tool.rs b/crates/assistant_tools/src/create_directory_tool.rs index 5d4b36c2e8..b3e198c1b5 100644 --- a/crates/assistant_tools/src/create_directory_tool.rs +++ b/crates/assistant_tools/src/create_directory_tool.rs @@ -33,12 +33,16 @@ impl Tool for CreateDirectoryTool { "create_directory".into() } + fn description(&self) -> String { + include_str!("./create_directory_tool/description.md").into() + } + fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { false } - fn description(&self) -> String { - include_str!("./create_directory_tool/description.md").into() + fn may_perform_edits(&self) -> bool { + false } fn icon(&self) -> IconName { diff --git a/crates/assistant_tools/src/delete_path_tool.rs b/crates/assistant_tools/src/delete_path_tool.rs index 275161840b..e45c1976d1 100644 --- a/crates/assistant_tools/src/delete_path_tool.rs +++ b/crates/assistant_tools/src/delete_path_tool.rs @@ -37,6 +37,10 @@ impl Tool for DeletePathTool { false } + fn may_perform_edits(&self) -> bool { + true + } + fn description(&self) -> String { include_str!("./delete_path_tool/description.md").into() } diff --git a/crates/assistant_tools/src/diagnostics_tool.rs b/crates/assistant_tools/src/diagnostics_tool.rs index 2cac59c2d9..3b6d38fc06 100644 --- a/crates/assistant_tools/src/diagnostics_tool.rs +++ b/crates/assistant_tools/src/diagnostics_tool.rs @@ -50,6 +50,10 @@ impl Tool for DiagnosticsTool { false } + fn may_perform_edits(&self) -> bool { + false + } + fn description(&self) -> String { include_str!("./diagnostics_tool/description.md").into() } diff --git a/crates/assistant_tools/src/edit_file_tool.rs b/crates/assistant_tools/src/edit_file_tool.rs index c4768934db..bde904abb5 100644 --- a/crates/assistant_tools/src/edit_file_tool.rs +++ b/crates/assistant_tools/src/edit_file_tool.rs @@ -129,6 +129,10 @@ impl Tool for EditFileTool { false } + fn may_perform_edits(&self) -> bool { + true + } + fn description(&self) -> String { include_str!("edit_file_tool/description.md").to_string() } diff --git a/crates/assistant_tools/src/fetch_tool.rs b/crates/assistant_tools/src/fetch_tool.rs index 2c593407b6..82b15b7a86 100644 --- a/crates/assistant_tools/src/fetch_tool.rs +++ b/crates/assistant_tools/src/fetch_tool.rs @@ -118,7 +118,11 @@ impl Tool for FetchTool { } fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { - true + false + } + + fn may_perform_edits(&self) -> bool { + false } fn description(&self) -> String { diff --git a/crates/assistant_tools/src/find_path_tool.rs b/crates/assistant_tools/src/find_path_tool.rs index 1bf19d8d98..86e67a8f58 100644 --- a/crates/assistant_tools/src/find_path_tool.rs +++ b/crates/assistant_tools/src/find_path_tool.rs @@ -59,6 +59,10 @@ impl Tool for FindPathTool { false } + fn may_perform_edits(&self) -> bool { + false + } + fn description(&self) -> String { include_str!("./find_path_tool/description.md").into() } diff --git a/crates/assistant_tools/src/grep_tool.rs b/crates/assistant_tools/src/grep_tool.rs index 202e7620f2..1b0c69b744 100644 --- a/crates/assistant_tools/src/grep_tool.rs +++ b/crates/assistant_tools/src/grep_tool.rs @@ -60,6 +60,10 @@ impl Tool for GrepTool { false } + fn may_perform_edits(&self) -> bool { + false + } + fn description(&self) -> String { include_str!("./grep_tool/description.md").into() } diff --git a/crates/assistant_tools/src/list_directory_tool.rs b/crates/assistant_tools/src/list_directory_tool.rs index cfd0247514..2c8bf0f6cf 100644 --- a/crates/assistant_tools/src/list_directory_tool.rs +++ b/crates/assistant_tools/src/list_directory_tool.rs @@ -48,6 +48,10 @@ impl Tool for ListDirectoryTool { false } + fn may_perform_edits(&self) -> bool { + false + } + fn description(&self) -> String { include_str!("./list_directory_tool/description.md").into() } diff --git a/crates/assistant_tools/src/move_path_tool.rs b/crates/assistant_tools/src/move_path_tool.rs index ec079b6a56..27ae10151d 100644 --- a/crates/assistant_tools/src/move_path_tool.rs +++ b/crates/assistant_tools/src/move_path_tool.rs @@ -46,6 +46,10 @@ impl Tool for MovePathTool { false } + fn may_perform_edits(&self) -> bool { + true + } + fn description(&self) -> String { include_str!("./move_path_tool/description.md").into() } diff --git a/crates/assistant_tools/src/now_tool.rs b/crates/assistant_tools/src/now_tool.rs index 8587c9f7e6..b6b1cf90a4 100644 --- a/crates/assistant_tools/src/now_tool.rs +++ b/crates/assistant_tools/src/now_tool.rs @@ -37,6 +37,10 @@ impl Tool for NowTool { false } + fn may_perform_edits(&self) -> bool { + false + } + fn description(&self) -> String { "Returns the current datetime in RFC 3339 format. Only use this tool when the user specifically asks for it or the current task would benefit from knowing the current datetime.".into() } diff --git a/crates/assistant_tools/src/open_tool.rs b/crates/assistant_tools/src/open_tool.rs index 34d4a8bd07..97a4769e19 100644 --- a/crates/assistant_tools/src/open_tool.rs +++ b/crates/assistant_tools/src/open_tool.rs @@ -26,7 +26,9 @@ impl Tool for OpenTool { fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { true } - + fn may_perform_edits(&self) -> bool { + false + } fn description(&self) -> String { include_str!("./open_tool/description.md").to_string() } diff --git a/crates/assistant_tools/src/read_file_tool.rs b/crates/assistant_tools/src/read_file_tool.rs index 0be0b53d66..39cc3165d8 100644 --- a/crates/assistant_tools/src/read_file_tool.rs +++ b/crates/assistant_tools/src/read_file_tool.rs @@ -58,6 +58,10 @@ impl Tool for ReadFileTool { false } + fn may_perform_edits(&self) -> bool { + false + } + fn description(&self) -> String { include_str!("./read_file_tool/description.md").into() } diff --git a/crates/assistant_tools/src/terminal_tool.rs b/crates/assistant_tools/src/terminal_tool.rs index 91a2d994ed..4059eac2cf 100644 --- a/crates/assistant_tools/src/terminal_tool.rs +++ b/crates/assistant_tools/src/terminal_tool.rs @@ -80,6 +80,10 @@ impl Tool for TerminalTool { true } + fn may_perform_edits(&self) -> bool { + false + } + fn description(&self) -> String { include_str!("./terminal_tool/description.md").to_string() } diff --git a/crates/assistant_tools/src/thinking_tool.rs b/crates/assistant_tools/src/thinking_tool.rs index 1a8b6103ee..4641b7359e 100644 --- a/crates/assistant_tools/src/thinking_tool.rs +++ b/crates/assistant_tools/src/thinking_tool.rs @@ -28,6 +28,10 @@ impl Tool for ThinkingTool { false } + fn may_perform_edits(&self) -> bool { + false + } + fn description(&self) -> String { include_str!("./thinking_tool/description.md").to_string() } diff --git a/crates/assistant_tools/src/web_search_tool.rs b/crates/assistant_tools/src/web_search_tool.rs index 7478d2ba75..9430ac9d9e 100644 --- a/crates/assistant_tools/src/web_search_tool.rs +++ b/crates/assistant_tools/src/web_search_tool.rs @@ -36,6 +36,10 @@ impl Tool for WebSearchTool { false } + fn may_perform_edits(&self) -> bool { + false + } + fn description(&self) -> String { "Search the web for information using your query. Use this when you need real-time information, facts, or data that might not be in your training. Results will include snippets and links from relevant web pages.".into() } diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index adfbe1e52d..c7ea321dce 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -155,6 +155,7 @@ pub enum IconName { LineHeight, Link, ListCollapse, + ListTodo, ListTree, ListX, LoadCircle,