use std::{cell::RefCell, ops::Range, rc::Rc}; use super::thread_history::ThreadHistory; use acp_thread::{AcpThread, AgentThreadEntry}; use agent::ThreadStore; use agent_client_protocol::{self as acp, ToolCallId}; use collections::HashMap; use editor::{Editor, EditorEvent, EditorMode, MinimapVisibility, SizingBehavior}; use gpui::{ AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, FocusHandle, Focusable, ScrollHandle, TextStyleRefinement, WeakEntity, Window, }; use language::language_settings::SoftWrap; use project::{AgentId, Project}; use prompt_store::PromptStore; use rope::Point; use settings::Settings as _; use terminal_view::TerminalView; use theme::ThemeSettings; use ui::{Context, TextSize}; use workspace::Workspace; use crate::message_editor::{MessageEditor, MessageEditorEvent}; pub struct EntryViewState { workspace: WeakEntity, project: WeakEntity, thread_store: Option>, history: WeakEntity, prompt_store: Option>, entries: Vec, prompt_capabilities: Rc>, available_commands: Rc>>, agent_id: AgentId, } impl EntryViewState { pub fn new( workspace: WeakEntity, project: WeakEntity, thread_store: Option>, history: WeakEntity, prompt_store: Option>, prompt_capabilities: Rc>, available_commands: Rc>>, agent_id: AgentId, ) -> Self { Self { workspace, project, thread_store, history, prompt_store, entries: Vec::new(), prompt_capabilities, available_commands, agent_id, } } pub fn entry(&self, index: usize) -> Option<&Entry> { self.entries.get(index) } pub fn sync_entry( &mut self, index: usize, thread: &Entity, window: &mut Window, cx: &mut Context, ) { let Some(thread_entry) = thread.read(cx).entries().get(index) else { return; }; match thread_entry { AgentThreadEntry::UserMessage(message) => { let has_id = message.id.is_some(); let is_subagent = thread.read(cx).parent_session_id().is_some(); let chunks = message.chunks.clone(); if let Some(Entry::UserMessage(editor)) = self.entries.get_mut(index) { if !editor.focus_handle(cx).is_focused(window) { // Only update if we are not editing. // If we are, cancelling the edit will set the message to the newest content. editor.update(cx, |editor, cx| { editor.set_message(chunks, window, cx); }); } } else { let message_editor = cx.new(|cx| { let mut editor = MessageEditor::new( self.workspace.clone(), self.project.clone(), self.thread_store.clone(), self.history.clone(), self.prompt_store.clone(), self.prompt_capabilities.clone(), self.available_commands.clone(), self.agent_id.clone(), "Edit message - @ to include context", editor::EditorMode::AutoHeight { min_lines: 1, max_lines: None, }, window, cx, ); if !has_id || is_subagent { editor.set_read_only(true, cx); } editor.set_message(chunks, window, cx); editor }); cx.subscribe(&message_editor, move |_, editor, event, cx| { cx.emit(EntryViewEvent { entry_index: index, view_event: ViewEvent::MessageEditorEvent(editor, event.clone()), }) }) .detach(); self.set_entry(index, Entry::UserMessage(message_editor)); } } AgentThreadEntry::ToolCall(tool_call) => { let id = tool_call.id.clone(); let terminals = tool_call.terminals().cloned().collect::>(); let diffs = tool_call.diffs().cloned().collect::>(); let views = if let Some(Entry::ToolCall(tool_call)) = self.entries.get_mut(index) { &mut tool_call.content } else { self.set_entry( index, Entry::ToolCall(ToolCallEntry { content: HashMap::default(), }), ); let Some(Entry::ToolCall(tool_call)) = self.entries.get_mut(index) else { unreachable!() }; &mut tool_call.content }; let is_tool_call_completed = matches!(tool_call.status, acp_thread::ToolCallStatus::Completed); for terminal in terminals { match views.entry(terminal.entity_id()) { collections::hash_map::Entry::Vacant(entry) => { let element = create_terminal( self.workspace.clone(), self.project.clone(), terminal.clone(), window, cx, ) .into_any(); cx.emit(EntryViewEvent { entry_index: index, view_event: ViewEvent::NewTerminal(id.clone()), }); entry.insert(element); } collections::hash_map::Entry::Occupied(_entry) => { if is_tool_call_completed && terminal.read(cx).output().is_none() { cx.emit(EntryViewEvent { entry_index: index, view_event: ViewEvent::TerminalMovedToBackground(id.clone()), }); } } } } for diff in diffs { views.entry(diff.entity_id()).or_insert_with(|| { 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()), }); editor.into_any() }); } } AgentThreadEntry::AssistantMessage(message) => { let entry = if let Some(Entry::AssistantMessage(entry)) = self.entries.get_mut(index) { entry } else { self.set_entry( index, Entry::AssistantMessage(AssistantMessageEntry::default()), ); let Some(Entry::AssistantMessage(entry)) = self.entries.get_mut(index) else { unreachable!() }; entry }; entry.sync(message); } }; } fn set_entry(&mut self, index: usize, entry: Entry) { if index == self.entries.len() { self.entries.push(entry); } else { self.entries[index] = entry; } } pub fn remove(&mut self, range: Range) { self.entries.drain(range); } pub fn agent_ui_font_size_changed(&mut self, cx: &mut App) { for entry in self.entries.iter() { match entry { Entry::UserMessage { .. } | Entry::AssistantMessage { .. } => {} Entry::ToolCall(ToolCallEntry { content }) => { for view in content.values() { if let Ok(diff_editor) = view.clone().downcast::() { diff_editor.update(cx, |diff_editor, cx| { diff_editor.set_text_style_refinement( diff_editor_text_style_refinement(cx), ); cx.notify(); }) } } } } } } } impl EventEmitter for EntryViewState {} pub struct EntryViewEvent { pub entry_index: usize, pub view_event: ViewEvent, } pub enum ViewEvent { NewDiff(ToolCallId), NewTerminal(ToolCallId), TerminalMovedToBackground(ToolCallId), MessageEditorEvent(Entity, MessageEditorEvent), OpenDiffLocation { path: String, position: Point, split: bool, }, } #[derive(Default, Debug)] pub struct AssistantMessageEntry { scroll_handles_by_chunk_index: HashMap, } impl AssistantMessageEntry { pub fn scroll_handle_for_chunk(&self, ix: usize) -> Option { self.scroll_handles_by_chunk_index.get(&ix).cloned() } pub fn sync(&mut self, message: &acp_thread::AssistantMessage) { if let Some(acp_thread::AssistantMessageChunk::Thought { .. }) = message.chunks.last() { let ix = message.chunks.len() - 1; let handle = self.scroll_handles_by_chunk_index.entry(ix).or_default(); handle.scroll_to_bottom(); } } } #[derive(Debug)] pub struct ToolCallEntry { content: HashMap, } #[derive(Debug)] pub enum Entry { UserMessage(Entity), AssistantMessage(AssistantMessageEntry), ToolCall(ToolCallEntry), } impl Entry { pub fn focus_handle(&self, cx: &App) -> Option { match self { Self::UserMessage(editor) => Some(editor.read(cx).focus_handle(cx)), Self::AssistantMessage(_) | Self::ToolCall(_) => None, } } pub fn message_editor(&self) -> Option<&Entity> { match self { Self::UserMessage(editor) => Some(editor), Self::AssistantMessage(_) | Self::ToolCall(_) => None, } } pub fn editor_for_diff(&self, diff: &Entity) -> Option> { self.content_map()? .get(&diff.entity_id()) .cloned() .map(|entity| entity.downcast::().unwrap()) } pub fn terminal( &self, terminal: &Entity, ) -> Option> { self.content_map()? .get(&terminal.entity_id()) .cloned() .map(|entity| entity.downcast::().unwrap()) } pub fn scroll_handle_for_assistant_message_chunk( &self, chunk_ix: usize, ) -> Option { match self { Self::AssistantMessage(message) => message.scroll_handle_for_chunk(chunk_ix), Self::UserMessage(_) | Self::ToolCall(_) => None, } } fn content_map(&self) -> Option<&HashMap> { match self { Self::ToolCall(ToolCallEntry { content }) => Some(content), _ => None, } } #[cfg(test)] pub fn has_content(&self) -> bool { match self { Self::ToolCall(ToolCallEntry { content }) => !content.is_empty(), Self::UserMessage(_) | Self::AssistantMessage(_) => false, } } } fn create_terminal( workspace: WeakEntity, project: WeakEntity, terminal: Entity, window: &mut Window, cx: &mut App, ) -> Entity { cx.new(|cx| { let mut view = TerminalView::new( terminal.read(cx).inner().clone(), workspace, None, project, window, cx, ); view.set_embedded_mode(Some(1000), cx); view }) } fn create_editor_diff( diff: Entity, window: &mut Window, cx: &mut App, ) -> Entity { cx.new(|cx| { let mut editor = Editor::new( EditorMode::Full { scale_ui_elements_with_buffer_font_size: false, show_active_line_background: false, sizing_behavior: SizingBehavior::SizeByContent, }, diff.read(cx).multibuffer().clone(), None, window, cx, ); editor.set_show_gutter(false, cx); editor.disable_inline_diagnostics(); editor.disable_expand_excerpt_buttons(cx); editor.set_show_vertical_scrollbar(false, cx); editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx); editor.set_soft_wrap_mode(SoftWrap::None, cx); 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); editor.set_expand_all_diff_hunks(cx); editor.set_text_style_refinement(diff_editor_text_style_refinement(cx)); editor }) } fn diff_editor_text_style_refinement(cx: &mut App) -> TextStyleRefinement { TextStyleRefinement { font_size: Some( TextSize::Small .rems(cx) .to_pixels(ThemeSettings::get_global(cx).agent_ui_font_size(cx)) .into(), ), ..Default::default() } } #[cfg(test)] mod tests { use std::path::Path; use std::rc::Rc; use acp_thread::{AgentConnection, StubAgentConnection}; use agent_client_protocol as acp; use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; use editor::RowInfo; use fs::FakeFs; use gpui::{AppContext as _, TestAppContext}; use crate::entry_view_state::EntryViewState; use multi_buffer::MultiBufferRow; use pretty_assertions::assert_matches; use project::Project; use serde_json::json; use settings::SettingsStore; use util::path; use workspace::{MultiWorkspace, PathList}; #[gpui::test] async fn test_diff_sync(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( "/project", json!({ "hello.txt": "hi world" }), ) .await; let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; let (multi_workspace, cx) = cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let tool_call = acp::ToolCall::new("tool", "Tool call") .status(acp::ToolCallStatus::InProgress) .content(vec![acp::ToolCallContent::Diff( acp::Diff::new("/project/hello.txt", "hello world").old_text("hi world"), )]); let connection = Rc::new(StubAgentConnection::new()); let thread = cx .update(|_, cx| { connection.clone().new_session( project.clone(), PathList::new(&[Path::new(path!("/project"))]), cx, ) }) .await .unwrap(); let session_id = thread.update(cx, |thread, _| thread.session_id().clone()); cx.update(|_, cx| { connection.send_update(session_id, acp::SessionUpdate::ToolCall(tool_call), cx) }); let thread_store = None; let history = cx.update(|_window, cx| cx.new(|cx| crate::ThreadHistory::new(None, cx))); let view_state = cx.new(|_cx| { EntryViewState::new( workspace.downgrade(), project.downgrade(), thread_store, history.downgrade(), None, Default::default(), Default::default(), "Test Agent".into(), ) }); view_state.update_in(cx, |view_state, window, cx| { view_state.sync_entry(0, &thread, window, cx) }); let diff = thread.read_with(cx, |thread, _| { thread .entries() .get(0) .unwrap() .diffs() .next() .unwrap() .clone() }); cx.run_until_parked(); let diff_editor = view_state.read_with(cx, |view_state, _cx| { view_state.entry(0).unwrap().editor_for_diff(&diff).unwrap() }); assert_eq!( diff_editor.read_with(cx, |editor, cx| editor.text(cx)), "hi world\nhello world" ); let row_infos = diff_editor.read_with(cx, |editor, cx| { let multibuffer = editor.buffer().read(cx); multibuffer .snapshot(cx) .row_infos(MultiBufferRow(0)) .collect::>() }); assert_matches!( row_infos.as_slice(), [ RowInfo { multibuffer_row: Some(MultiBufferRow(0)), diff_status: Some(DiffHunkStatus { kind: DiffHunkStatusKind::Deleted, .. }), .. }, RowInfo { multibuffer_row: Some(MultiBufferRow(1)), diff_status: Some(DiffHunkStatus { kind: DiffHunkStatusKind::Added, .. }), .. } ] ); } fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); theme::init(theme::LoadThemes::JustBase, cx); release_channel::init(semver::Version::new(0, 0, 0), cx); }); } }