diff --git a/crates/acp_thread/src/diff.rs b/crates/acp_thread/src/diff.rs index 8886b458d623237b74f715d3c1d0def33fbefa7d..08b1b9bdf24d1ff9980164c1af8b3e60bd2f3339 100644 --- a/crates/acp_thread/src/diff.rs +++ b/crates/acp_thread/src/diff.rs @@ -149,6 +149,16 @@ impl Diff { } } + pub fn file_path(&self, cx: &App) -> Option { + match self { + Self::Pending(PendingDiff { new_buffer, .. }) => new_buffer + .read(cx) + .file() + .map(|file| file.full_path(cx).to_string_lossy().into_owned()), + Self::Finalized(FinalizedDiff { path, .. }) => Some(path.clone()), + } + } + pub fn multibuffer(&self) -> &Entity { match self { Self::Pending(PendingDiff { multibuffer, .. }) => multibuffer, diff --git a/crates/agent_ui/src/connection_view/thread_view.rs b/crates/agent_ui/src/connection_view/thread_view.rs index 777a54312e8d4c35a100c6c1f7e5ac446613c4b9..ddeabf46c9ed85ea2a70f1d935f53a764ba66323 100644 --- a/crates/agent_ui/src/connection_view/thread_view.rs +++ b/crates/agent_ui/src/connection_view/thread_view.rs @@ -1,4 +1,5 @@ use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody}; +use editor::actions::OpenExcerpts; use gpui::{Corner, List}; use language_model::{LanguageModelEffortLevel, Speed}; use settings::update_settings_file; @@ -578,9 +579,70 @@ impl ThreadView { ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => { self.cancel_editing(&Default::default(), window, cx); } + ViewEvent::OpenDiffLocation { + path, + position, + split, + } => { + self.open_diff_location(path, *position, *split, window, cx); + } } } + fn open_diff_location( + &self, + path: &str, + position: Point, + split: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(project) = self.project.upgrade() else { + return; + }; + let Some(project_path) = project.read(cx).find_project_path(path, cx) else { + return; + }; + + let open_task = if split { + self.workspace + .update(cx, |workspace, cx| { + workspace.split_path(project_path, window, cx) + }) + .log_err() + } else { + self.workspace + .update(cx, |workspace, cx| { + workspace.open_path(project_path, None, true, window, cx) + }) + .log_err() + }; + + let Some(open_task) = open_task else { + return; + }; + + window + .spawn(cx, async move |cx| { + let item = open_task.await?; + let Some(editor) = item.downcast::() else { + return anyhow::Ok(()); + }; + editor.update_in(cx, |editor, window, cx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::center()), + window, + cx, + |selections| { + selections.select_ranges([position..position]); + }, + ); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + // turns pub fn start_turn(&mut self, cx: &mut Context) -> usize { @@ -4995,14 +5057,20 @@ impl ThreadView { matches!(tool_call.kind, acp::ToolKind::Edit) || tool_call.diffs().next().is_some(); let is_cancelled_edit = is_edit && matches!(tool_call.status, ToolCallStatus::Canceled); - let has_revealed_diff = tool_call.diffs().next().is_some_and(|diff| { - self.entry_view_state - .read(cx) - .entry(entry_ix) - .and_then(|entry| entry.editor_for_diff(diff)) - .is_some() - && diff.read(cx).has_revealed_range(cx) - }); + let (has_revealed_diff, tool_call_output_focus) = tool_call + .diffs() + .next() + .and_then(|diff| { + let editor = self + .entry_view_state + .read(cx) + .entry(entry_ix) + .and_then(|entry| entry.editor_for_diff(diff))?; + let has_revealed_diff = diff.read(cx).has_revealed_range(cx); + let has_focus = editor.read(cx).is_focused(window); + Some((has_revealed_diff, has_focus)) + }) + .unwrap_or((false, false)); let use_card_layout = needs_confirmation || is_edit || is_terminal_tool; @@ -5211,7 +5279,12 @@ impl ThreadView { .map(|this| { if is_terminal_tool { let label_source = tool_call.label.read(cx).source(); - this.child(self.render_collapsible_command(true, label_source, &tool_call.id, cx)) + this.child(self.render_collapsible_command( + true, + label_source, + &tool_call.id, + cx, + )) } else { this.child( h_flex() @@ -5235,97 +5308,148 @@ impl ThreadView { window, cx, )) - .when(is_collapsible || failed_or_canceled, |this| { - let diff_for_discard = - if has_revealed_diff && is_cancelled_edit && cx.has_flag::() { - tool_call.diffs().next().cloned() - } else { - None - }; - this.child( - h_flex() - .px_1() - .when_some(diff_for_discard.clone(), |this, _| this.pr_0p5()) - .gap_1() - .when(is_collapsible, |this| { - this.child( - Disclosure::new(("expand-output", entry_ix), is_open) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronDown) - .visible_on_hover(&card_header_id) - .on_click(cx.listener({ - let id = tool_call.id.clone(); - move |this: &mut Self, _, _, cx: &mut Context| { + .child( + h_flex() + .gap_0p5() + .when(is_collapsible || failed_or_canceled, |this| { + let diff_for_discard = if has_revealed_diff + && is_cancelled_edit + && cx.has_flag::() + { + tool_call.diffs().next().cloned() + } else { + None + }; + + this.child( + h_flex() + .px_1() + .when_some(diff_for_discard.clone(), |this, _| { + this.pr_0p5() + }) + .gap_1() + .when(is_collapsible, |this| { + this.child( + Disclosure::new( + ("expand-output", entry_ix), + is_open, + ) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .visible_on_hover(&card_header_id) + .on_click(cx.listener({ + let id = tool_call.id.clone(); + move |this: &mut Self, + _, + _, + cx: &mut Context| { if is_open { - this - .expanded_tool_calls.remove(&id); + this.expanded_tool_calls + .remove(&id); } else { - this.expanded_tool_calls.insert(id.clone()); + this.expanded_tool_calls + .insert(id.clone()); } - cx.notify(); + cx.notify(); + } + })), + ) + }) + .when(failed_or_canceled, |this| { + if is_cancelled_edit && !has_revealed_diff { + this.child( + div() + .id(entry_ix) + .tooltip(Tooltip::text( + "Interrupted Edit", + )) + .child( + Icon::new(IconName::XCircle) + .color(Color::Muted) + .size(IconSize::Small), + ), + ) + } else if is_cancelled_edit { + this + } else { + this.child( + Icon::new(IconName::Close) + .color(Color::Error) + .size(IconSize::Small), + ) } - })), + }) + .when_some(diff_for_discard, |this, diff| { + let tool_call_id = tool_call.id.clone(); + let is_discarded = self + .discarded_partial_edits + .contains(&tool_call_id); + + this.when(!is_discarded, |this| { + this.child( + IconButton::new( + ("discard-partial-edit", entry_ix), + IconName::Undo, + ) + .icon_size(IconSize::Small) + .tooltip(move |_, cx| { + Tooltip::with_meta( + "Discard Interrupted Edit", + None, + "You can discard this interrupted partial edit and restore the original file content.", + cx, + ) + }) + .on_click(cx.listener({ + let tool_call_id = + tool_call_id.clone(); + move |this, _, _window, cx| { + let diff_data = diff.read(cx); + let base_text = diff_data + .base_text() + .clone(); + let buffer = + diff_data.buffer().clone(); + buffer.update( + cx, + |buffer, cx| { + buffer.set_text( + base_text.as_ref(), + cx, + ); + }, + ); + this.discarded_partial_edits + .insert( + tool_call_id.clone(), + ); + cx.notify(); + } + })), + ) + }) + }), ) - }) - .when(failed_or_canceled, |this| { - if is_cancelled_edit && !has_revealed_diff { - this.child( - div() - .id(entry_ix) - .tooltip(Tooltip::text( - "Interrupted Edit", - )) - .child( - Icon::new(IconName::XCircle) - .color(Color::Muted) - .size(IconSize::Small), - ), - ) - } else if is_cancelled_edit { - this - } else { - this.child( - Icon::new(IconName::Close) - .color(Color::Error) - .size(IconSize::Small), + }) + .when(tool_call_output_focus, |this| { + this.child( + Button::new("open-file-button", "Open File") + .label_size(LabelSize::Small) + .style(ButtonStyle::OutlinedGhost) + .key_binding( + KeyBinding::for_action(&OpenExcerpts, cx) + .map(|s| s.size(rems_from_px(12.))), ) - } - }) - .when_some(diff_for_discard, |this, diff| { - let tool_call_id = tool_call.id.clone(); - let is_discarded = self.discarded_partial_edits.contains(&tool_call_id); - this.when(!is_discarded, |this| { - this.child( - IconButton::new( - ("discard-partial-edit", entry_ix), - IconName::Undo, + .on_click(|_, window, cx| { + window.dispatch_action( + Box::new(OpenExcerpts), + cx, ) - .icon_size(IconSize::Small) - .tooltip(move |_, cx| Tooltip::with_meta( - "Discard Interrupted Edit", - None, - "You can discard this interrupted partial edit and restore the original file content.", - cx - )) - .on_click(cx.listener({ - let tool_call_id = tool_call_id.clone(); - move |this, _, _window, cx| { - let diff_data = diff.read(cx); - let base_text = diff_data.base_text().clone(); - let buffer = diff_data.buffer().clone(); - buffer.update(cx, |buffer, cx| { - buffer.set_text(base_text.as_ref(), cx); - }); - this.discarded_partial_edits.insert(tool_call_id.clone()); - cx.notify(); - } - })), - ) - }) - }) + }), + ) + }), + ) - ) - }), ) } }) diff --git a/crates/agent_ui/src/entry_view_state.rs b/crates/agent_ui/src/entry_view_state.rs index ea794cd5234c60b0932f1a26813854fdb28dcc95..b06d67f63b997e67ca891ab6238e0bd2ce94a304 100644 --- a/crates/agent_ui/src/entry_view_state.rs +++ b/crates/agent_ui/src/entry_view_state.rs @@ -5,7 +5,7 @@ use acp_thread::{AcpThread, AgentThreadEntry}; use agent::ThreadStore; use agent_client_protocol::{self as acp, ToolCallId}; use collections::HashMap; -use editor::{Editor, EditorMode, MinimapVisibility, SizingBehavior}; +use editor::{Editor, EditorEvent, EditorMode, MinimapVisibility, SizingBehavior}; use gpui::{ AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, FocusHandle, Focusable, ScrollHandle, SharedString, TextStyleRefinement, WeakEntity, Window, @@ -13,6 +13,7 @@ use gpui::{ use language::language_settings::SoftWrap; use project::Project; use prompt_store::PromptStore; +use rope::Point; use settings::Settings as _; use terminal_view::TerminalView; use theme::ThemeSettings; @@ -168,12 +169,48 @@ impl EntryViewState { for diff in diffs { views.entry(diff.entity_id()).or_insert_with(|| { - let element = create_editor_diff(diff.clone(), window, cx).into_any(); + let editor = create_editor_diff(diff.clone(), window, cx); + cx.subscribe(&editor, { + let diff = diff.clone(); + let entry_index = index; + move |_this, _editor, event: &EditorEvent, cx| { + if let EditorEvent::OpenExcerptsRequested { + selections_by_buffer, + split, + } = event + { + let multibuffer = diff.read(cx).multibuffer(); + if let Some((buffer_id, (ranges, _))) = + selections_by_buffer.iter().next() + { + if let Some(buffer) = + multibuffer.read(cx).buffer(*buffer_id) + { + if let Some(range) = ranges.first() { + let point = + buffer.read(cx).offset_to_point(range.start.0); + if let Some(path) = diff.read(cx).file_path(cx) { + cx.emit(EntryViewEvent { + entry_index, + view_event: ViewEvent::OpenDiffLocation { + path, + position: point, + split: *split, + }, + }); + } + } + } + } + } + } + }) + .detach(); cx.emit(EntryViewEvent { entry_index: index, view_event: ViewEvent::NewDiff(id.clone()), }); - element + editor.into_any() }); } } @@ -242,6 +279,11 @@ pub enum ViewEvent { NewTerminal(ToolCallId), TerminalMovedToBackground(ToolCallId), MessageEditorEvent(Entity, MessageEditorEvent), + OpenDiffLocation { + path: String, + position: Point, + split: bool, + }, } #[derive(Default, Debug)] @@ -379,6 +421,7 @@ fn create_editor_diff( editor.scroll_manager.set_forbid_vertical_scroll(true); editor.set_show_indent_guides(false, cx); editor.set_read_only(true); + editor.set_delegate_open_excerpts(true); editor.set_show_breakpoints(false, cx); editor.set_show_code_actions(false, cx); editor.set_show_git_diff_gutter(false, cx);