From cc9cc12f7b200d0702340bd4a01a28ef3cb4a20f Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Fri, 4 Apr 2025 13:37:14 -0300 Subject: [PATCH] agent: Remove `edit_files` tool (#28041) Release Notes: - agent: Remove `edit_files` tool in favor of `find_replace` --- Cargo.lock | 5 - crates/assistant_tools/Cargo.toml | 6 - crates/assistant_tools/src/assistant_tools.rs | 4 - crates/assistant_tools/src/edit_files_tool.rs | 559 ---------- .../src/edit_files_tool/description.md | 11 - .../src/edit_files_tool/edit_action.rs | 967 ------------------ .../src/edit_files_tool/edit_prompt.md | 134 --- .../src/edit_files_tool/log.rs | 417 -------- 8 files changed, 2103 deletions(-) delete mode 100644 crates/assistant_tools/src/edit_files_tool.rs delete mode 100644 crates/assistant_tools/src/edit_files_tool/description.md delete mode 100644 crates/assistant_tools/src/edit_files_tool/edit_action.rs delete mode 100644 crates/assistant_tools/src/edit_files_tool/edit_prompt.md delete mode 100644 crates/assistant_tools/src/edit_files_tool/log.rs diff --git a/Cargo.lock b/Cargo.lock index 601810f1e2..3a1fbe5e17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -738,7 +738,6 @@ dependencies = [ "assistant_tool", "chrono", "collections", - "feature_flags", "futures 0.3.31", "gpui", "html_to_markdown", @@ -746,18 +745,14 @@ dependencies = [ "itertools 0.14.0", "language", "language_model", - "log", "lsp", "open", "project", "rand 0.8.5", "regex", - "release_channel", "schemars", "serde", "serde_json", - "settings", - "theme", "ui", "unindent", "util", diff --git a/crates/assistant_tools/Cargo.toml b/crates/assistant_tools/Cargo.toml index 32bf96e37f..fda696f217 100644 --- a/crates/assistant_tools/Cargo.toml +++ b/crates/assistant_tools/Cargo.toml @@ -16,7 +16,6 @@ anyhow.workspace = true assistant_tool.workspace = true chrono.workspace = true collections.workspace = true -feature_flags.workspace = true futures.workspace = true gpui.workspace = true html_to_markdown.workspace = true @@ -24,19 +23,14 @@ http_client.workspace = true itertools.workspace = true language.workspace = true language_model.workspace = true -log.workspace = true lsp.workspace = true project.workspace = true regex.workspace = true -release_channel.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true -settings.workspace = true -theme.workspace = true ui.workspace = true util.workspace = true -workspace.workspace = true worktree.workspace = true open = { workspace = true } workspace-hack.workspace = true diff --git a/crates/assistant_tools/src/assistant_tools.rs b/crates/assistant_tools/src/assistant_tools.rs index d0bd17461d..4b46d5be44 100644 --- a/crates/assistant_tools/src/assistant_tools.rs +++ b/crates/assistant_tools/src/assistant_tools.rs @@ -7,7 +7,6 @@ mod create_directory_tool; mod create_file_tool; mod delete_path_tool; mod diagnostics_tool; -mod edit_files_tool; mod fetch_tool; mod find_replace_file_tool; mod list_directory_tool; @@ -37,7 +36,6 @@ use crate::create_directory_tool::CreateDirectoryTool; use crate::create_file_tool::CreateFileTool; use crate::delete_path_tool::DeletePathTool; use crate::diagnostics_tool::DiagnosticsTool; -use crate::edit_files_tool::EditFilesTool; use crate::fetch_tool::FetchTool; use crate::find_replace_file_tool::FindReplaceFileTool; use crate::list_directory_tool::ListDirectoryTool; @@ -51,7 +49,6 @@ use crate::thinking_tool::ThinkingTool; pub fn init(http_client: Arc, cx: &mut App) { assistant_tool::init(cx); - crate::edit_files_tool::log::init(cx); let registry = ToolRegistry::global(cx); registry.register_tool(BashTool); @@ -64,7 +61,6 @@ pub fn init(http_client: Arc, cx: &mut App) { registry.register_tool(SymbolInfoTool); registry.register_tool(MovePathTool); registry.register_tool(DiagnosticsTool); - registry.register_tool(EditFilesTool); registry.register_tool(ListDirectoryTool); registry.register_tool(NowTool); registry.register_tool(OpenTool); diff --git a/crates/assistant_tools/src/edit_files_tool.rs b/crates/assistant_tools/src/edit_files_tool.rs deleted file mode 100644 index d5933bfa02..0000000000 --- a/crates/assistant_tools/src/edit_files_tool.rs +++ /dev/null @@ -1,559 +0,0 @@ -mod edit_action; -pub mod log; - -use crate::replace::{replace_exact, replace_with_flexible_indent}; -use crate::schema::json_schema_for; -use anyhow::{Context, Result, anyhow}; -use assistant_tool::{ActionLog, Tool}; -use collections::HashSet; -use edit_action::{EditAction, EditActionParser, edit_model_prompt}; -use futures::{SinkExt, StreamExt, channel::mpsc}; -use gpui::{App, AppContext, AsyncApp, Entity, Task}; -use language_model::{ConfiguredModel, LanguageModelToolSchemaFormat}; -use language_model::{ - LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, MessageContent, Role, -}; -use log::{EditToolLog, EditToolRequestId}; -use project::Project; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::fmt::Write; -use std::sync::Arc; -use ui::IconName; -use util::ResultExt; - -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct EditFilesToolInput { - /// High-level edit instructions. These will be interpreted by a smaller - /// model, so explain the changes you want that model to make and which - /// file paths need changing. The description should be concise and clear. - /// - /// WARNING: When specifying which file paths need changing, you MUST - /// start each path with one of the project's root directories. - /// - /// WARNING: NEVER include code blocks or snippets in edit instructions. - /// Only provide natural language descriptions of the changes needed! The tool will - /// reject any instructions that contain code blocks or snippets. - /// - /// The following examples assume we have two root directories in the project: - /// - root-1 - /// - root-2 - /// - /// - /// If you want to introduce a new quit function to kill the process, your - /// instructions should be: "Add a new `quit` function to - /// `root-1/src/main.rs` to kill the process". - /// - /// Notice how the file path starts with root-1. Without that, the path - /// would be ambiguous and the call would fail! - /// - /// - /// - /// If you want to change documentation to always start with a capital - /// letter, your instructions should be: "In `root-2/db.js`, - /// `root-2/inMemory.js` and `root-2/sql.js`, change all the documentation - /// to start with a capital letter". - /// - /// Notice how we never specify code snippets in the instructions! - /// - pub edit_instructions: String, - - /// A user-friendly description of what changes are being made. - /// This will be shown to the user in the UI to describe the edit operation. The screen real estate for this UI will be extremely - /// constrained, so make the description extremely terse. - /// - /// - /// For fixing a broken authentication system: - /// "Fix auth bug in login flow" - /// - /// - /// - /// For adding unit tests to a module: - /// "Add tests for user profile logic" - /// - pub display_description: String, -} - -pub struct EditFilesTool; - -impl Tool for EditFilesTool { - fn name(&self) -> String { - "edit_files".into() - } - - fn needs_confirmation(&self) -> bool { - false - } - - fn description(&self) -> String { - include_str!("./edit_files_tool/description.md").into() - } - - fn icon(&self) -> IconName { - IconName::Pencil - } - - fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> serde_json::Value { - json_schema_for::(format) - } - - fn ui_text(&self, input: &serde_json::Value) -> String { - match serde_json::from_value::(input.clone()) { - Ok(input) => input.display_description, - Err(_) => "Edit files".to_string(), - } - } - - fn run( - self: Arc, - input: serde_json::Value, - messages: &[LanguageModelRequestMessage], - project: Entity, - action_log: Entity, - cx: &mut App, - ) -> Task> { - let input = match serde_json::from_value::(input) { - Ok(input) => input, - Err(err) => return Task::ready(Err(anyhow!(err))), - }; - - match EditToolLog::try_global(cx) { - Some(log) => { - let req_id = log.update(cx, |log, cx| { - log.new_request(input.edit_instructions.clone(), cx) - }); - - let task = EditToolRequest::new( - input, - messages, - project, - action_log, - Some((log.clone(), req_id)), - cx, - ); - - cx.spawn(async move |cx| { - let result = task.await; - - let str_result = match &result { - Ok(out) => Ok(out.clone()), - Err(err) => Err(err.to_string()), - }; - - log.update(cx, |log, cx| log.set_tool_output(req_id, str_result, cx)) - .log_err(); - - result - }) - } - - None => EditToolRequest::new(input, messages, project, action_log, None, cx), - } - } -} - -struct EditToolRequest { - parser: EditActionParser, - editor_response: EditorResponse, - project: Entity, - action_log: Entity, - tool_log: Option<(Entity, EditToolRequestId)>, -} - -enum EditorResponse { - /// The editor model hasn't produced any actions yet. - /// If we don't have any by the end, we'll return its message to the architect model. - Message(String), - /// The editor model produced at least one action. - Actions { - applied: Vec, - search_errors: Vec, - }, -} - -struct AppliedAction { - source: String, - buffer: Entity, -} - -#[derive(Debug)] -enum DiffResult { - Diff(language::Diff), - SearchError(SearchError), -} - -#[derive(Debug)] -enum SearchError { - NoMatch { - file_path: String, - search: String, - }, - EmptyBuffer { - file_path: String, - search: String, - exists: bool, - }, -} - -impl EditToolRequest { - fn new( - input: EditFilesToolInput, - messages: &[LanguageModelRequestMessage], - project: Entity, - action_log: Entity, - tool_log: Option<(Entity, EditToolRequestId)>, - cx: &mut App, - ) -> Task> { - let model_registry = LanguageModelRegistry::read_global(cx); - let Some(ConfiguredModel { model, .. }) = model_registry.default_model() else { - return Task::ready(Err(anyhow!("No model configured"))); - }; - - let mut messages = messages.to_vec(); - // Remove the last tool use (this run) to prevent an invalid request - 'outer: for message in messages.iter_mut().rev() { - for (index, content) in message.content.iter().enumerate().rev() { - match content { - MessageContent::ToolUse(_) => { - message.content.remove(index); - break 'outer; - } - MessageContent::ToolResult(_) => { - // If we find any tool results before a tool use, the request is already valid - break 'outer; - } - MessageContent::Text(_) | MessageContent::Image(_) => {} - } - } - } - - messages.push(LanguageModelRequestMessage { - role: Role::User, - content: vec![edit_model_prompt().into(), input.edit_instructions.into()], - cache: false, - }); - - cx.spawn(async move |cx| { - let llm_request = LanguageModelRequest { - messages, - tools: vec![], - stop: vec![], - temperature: Some(0.0), - }; - - let (mut tx, mut rx) = mpsc::channel::(32); - let stream = model.stream_completion_text(llm_request, &cx); - let reader_task = cx.background_spawn(async move { - let mut chunks = stream.await?; - - while let Some(chunk) = chunks.stream.next().await { - if let Some(chunk) = chunk.log_err() { - // we don't process here because the API fails - // if we take too long between reads - tx.send(chunk).await? - } - } - tx.close().await?; - anyhow::Ok(()) - }); - - let mut request = Self { - parser: EditActionParser::new(), - editor_response: EditorResponse::Message(String::with_capacity(256)), - action_log, - project, - tool_log, - }; - - while let Some(chunk) = rx.next().await { - request.process_response_chunk(&chunk, cx).await?; - } - - reader_task.await?; - - request.finalize(cx).await - }) - } - - async fn process_response_chunk(&mut self, chunk: &str, cx: &mut AsyncApp) -> Result<()> { - let new_actions = self.parser.parse_chunk(chunk); - - if let EditorResponse::Message(ref mut message) = self.editor_response { - if new_actions.is_empty() { - message.push_str(chunk); - } - } - - if let Some((ref log, req_id)) = self.tool_log { - log.update(cx, |log, cx| { - log.push_editor_response_chunk(req_id, chunk, &new_actions, cx) - }) - .log_err(); - } - - for action in new_actions { - self.apply_action(action, cx).await?; - } - - Ok(()) - } - - async fn apply_action( - &mut self, - (action, source): (EditAction, String), - cx: &mut AsyncApp, - ) -> Result<()> { - let project_path = self.project.read_with(cx, |project, cx| { - project - .find_project_path(action.file_path(), cx) - .context("Path not found in project") - })??; - - let buffer = self - .project - .update(cx, |project, cx| project.open_buffer(project_path, cx))? - .await?; - - let result = match action { - EditAction::Replace { - old, - new, - file_path, - } => { - let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; - - cx.background_executor() - .spawn(Self::replace_diff(old, new, file_path, snapshot)) - .await - } - EditAction::Write { content, .. } => Ok(DiffResult::Diff( - buffer - .read_with(cx, |buffer, cx| buffer.diff(content, cx))? - .await, - )), - }?; - - match result { - DiffResult::SearchError(error) => { - self.push_search_error(error); - } - DiffResult::Diff(diff) => { - cx.update(|cx| { - self.action_log - .update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); - buffer.update(cx, |buffer, cx| { - buffer.finalize_last_transaction(); - buffer.apply_diff(diff, cx); - buffer.finalize_last_transaction(); - }); - self.action_log - .update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); - })?; - - self.push_applied_action(AppliedAction { source, buffer }); - } - } - - anyhow::Ok(()) - } - - fn push_search_error(&mut self, error: SearchError) { - match &mut self.editor_response { - EditorResponse::Message(_) => { - self.editor_response = EditorResponse::Actions { - applied: Vec::new(), - search_errors: vec![error], - }; - } - EditorResponse::Actions { search_errors, .. } => { - search_errors.push(error); - } - } - } - - fn push_applied_action(&mut self, action: AppliedAction) { - match &mut self.editor_response { - EditorResponse::Message(_) => { - self.editor_response = EditorResponse::Actions { - applied: vec![action], - search_errors: Vec::new(), - }; - } - EditorResponse::Actions { applied, .. } => { - applied.push(action); - } - } - } - - async fn replace_diff( - old: String, - new: String, - file_path: std::path::PathBuf, - snapshot: language::BufferSnapshot, - ) -> Result { - if snapshot.is_empty() { - let exists = snapshot - .file() - .map_or(false, |file| file.disk_state().exists()); - - let error = SearchError::EmptyBuffer { - file_path: file_path.display().to_string(), - exists, - search: old, - }; - - return Ok(DiffResult::SearchError(error)); - } - - let replace_result = - // Try to match exactly - replace_exact(&old, &new, &snapshot) - .await - // If that fails, try being flexible about indentation - .or_else(|| replace_with_flexible_indent(&old, &new, &snapshot)); - - let Some(diff) = replace_result else { - let error = SearchError::NoMatch { - search: old, - file_path: file_path.display().to_string(), - }; - - return Ok(DiffResult::SearchError(error)); - }; - - Ok(DiffResult::Diff(diff)) - } - - async fn finalize(self, cx: &mut AsyncApp) -> Result { - match self.editor_response { - EditorResponse::Message(message) => Err(anyhow!( - "No edits were applied! You might need to provide more context.\n\n{}", - message - )), - EditorResponse::Actions { - applied, - search_errors, - } => { - let mut output = String::with_capacity(1024); - - let parse_errors = self.parser.errors(); - let has_errors = !search_errors.is_empty() || !parse_errors.is_empty(); - - if has_errors { - let error_count = search_errors.len() + parse_errors.len(); - - if applied.is_empty() { - writeln!( - &mut output, - "{} errors occurred! No edits were applied.", - error_count, - )?; - } else { - writeln!( - &mut output, - "{} errors occurred, but {} edits were correctly applied.", - error_count, - applied.len(), - )?; - - writeln!( - &mut output, - "# {} SEARCH/REPLACE block(s) applied:\n\nDo not re-send these since they are already applied!\n", - applied.len() - )?; - } - } else { - write!( - &mut output, - "Successfully applied! Here's a list of applied edits:" - )?; - } - - let mut changed_buffers = HashSet::default(); - - for action in applied { - changed_buffers.insert(action.buffer.clone()); - write!(&mut output, "\n\n{}", action.source)?; - } - - for buffer in &changed_buffers { - self.project - .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))? - .await?; - } - - if !search_errors.is_empty() { - writeln!( - &mut output, - "\n\n## {} SEARCH/REPLACE block(s) failed to match:\n", - search_errors.len() - )?; - - for error in search_errors { - match error { - SearchError::NoMatch { file_path, search } => { - writeln!( - &mut output, - "### No exact match in: `{}`\n```\n{}\n```\n", - file_path, search, - )?; - } - SearchError::EmptyBuffer { - file_path, - exists: true, - search, - } => { - writeln!( - &mut output, - "### No match because `{}` is empty:\n```\n{}\n```\n", - file_path, search, - )?; - } - SearchError::EmptyBuffer { - file_path, - exists: false, - search, - } => { - writeln!( - &mut output, - "### No match because `{}` does not exist:\n```\n{}\n```\n", - file_path, search, - )?; - } - } - } - - write!( - &mut output, - "The SEARCH section must exactly match an existing block of lines including all white \ - space, comments, indentation, docstrings, etc." - )?; - } - - if !parse_errors.is_empty() { - writeln!( - &mut output, - "\n\n## {} SEARCH/REPLACE blocks failed to parse:", - parse_errors.len() - )?; - - for error in parse_errors { - writeln!(&mut output, "- {}", error)?; - } - } - - if has_errors { - writeln!( - &mut output, - "\n\nYou can fix errors by running the tool again. You can include instructions, \ - but errors are part of the conversation so you don't need to repeat them.", - )?; - - Err(anyhow!(output)) - } else { - Ok(output) - } - } - } - } -} diff --git a/crates/assistant_tools/src/edit_files_tool/description.md b/crates/assistant_tools/src/edit_files_tool/description.md deleted file mode 100644 index 0a70713a36..0000000000 --- a/crates/assistant_tools/src/edit_files_tool/description.md +++ /dev/null @@ -1,11 +0,0 @@ -Edit files in the current project by specifying instructions in natural language. - -IMPORTANT NOTE: If there is a find-replace tool, use that instead of this tool! This tool is only to be used as a fallback in case that tool is unavailable. Always prefer that tool if it is available. - -When using this tool, you should suggest one coherent edit that can be made to the codebase. - -When the set of edits you want to make is large or complex, feel free to invoke this tool multiple times, each time focusing on a specific change you wanna make. - -You should use this tool when you want to edit a subset of a file's contents, but not the entire file. You should not use this tool when you want to replace the entire contents of a file with completely different contents, and you absolutely must never use this tool to create new files from scratch. If you ever consider using this tool to create a new file from scratch, for any reason, instead you must reconsider and choose a different approach. - -DO NOT call this tool until the code to be edited appears in the conversation! You must use the `read-files` tool or ask the user to add it to context first. diff --git a/crates/assistant_tools/src/edit_files_tool/edit_action.rs b/crates/assistant_tools/src/edit_files_tool/edit_action.rs deleted file mode 100644 index 8e17bc603c..0000000000 --- a/crates/assistant_tools/src/edit_files_tool/edit_action.rs +++ /dev/null @@ -1,967 +0,0 @@ -use std::{ - mem::take, - ops::Range, - path::{Path, PathBuf}, -}; -use util::ResultExt; - -/// Represents an edit action to be performed on a file. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum EditAction { - /// Replace specific content in a file with new content - Replace { - file_path: PathBuf, - old: String, - new: String, - }, - /// Write content to a file (create or overwrite) - Write { file_path: PathBuf, content: String }, -} - -impl EditAction { - pub fn file_path(&self) -> &Path { - match self { - EditAction::Replace { file_path, .. } => file_path, - EditAction::Write { file_path, .. } => file_path, - } - } -} - -/// Parses edit actions from an LLM response. -/// See system.md for more details on the format. -#[derive(Debug)] -pub struct EditActionParser { - state: State, - line: usize, - column: usize, - marker_ix: usize, - action_source: Vec, - fence_start_offset: usize, - block_range: Range, - old_range: Range, - new_range: Range, - errors: Vec, -} - -#[derive(Debug, PartialEq, Eq)] -enum State { - /// Anywhere outside an action - Default, - /// After opening ```, in optional language tag - OpenFence, - /// In SEARCH marker - SearchMarker, - /// In search block or divider - SearchBlock, - /// In replace block or REPLACE marker - ReplaceBlock, - /// In closing ``` - CloseFence, -} - -/// used to avoid having source code that looks like git-conflict markers -macro_rules! marker_sym { - ($char:expr) => { - concat!($char, $char, $char, $char, $char, $char, $char) - }; -} - -const SEARCH_MARKER: &str = concat!(marker_sym!('<'), " SEARCH"); -const DIVIDER: &str = marker_sym!('='); -const NL_DIVIDER: &str = concat!("\n", marker_sym!('=')); -const REPLACE_MARKER: &str = concat!(marker_sym!('>'), " REPLACE"); -const NL_REPLACE_MARKER: &str = concat!("\n", marker_sym!('>'), " REPLACE"); -const FENCE: &str = "```"; - -impl EditActionParser { - /// Creates a new `EditActionParser` - pub fn new() -> Self { - Self { - state: State::Default, - line: 1, - column: 0, - action_source: Vec::new(), - fence_start_offset: 0, - marker_ix: 0, - block_range: Range::default(), - old_range: Range::default(), - new_range: Range::default(), - errors: Vec::new(), - } - } - - /// Processes a chunk of input text and returns any completed edit actions. - /// - /// This method can be called repeatedly with fragments of input. The parser - /// maintains its state between calls, allowing you to process streaming input - /// as it becomes available. Actions are only inserted once they are fully parsed. - /// - /// If a block fails to parse, it will simply be skipped and an error will be recorded. - /// All errors can be accessed through the `EditActionsParser::errors` method. - pub fn parse_chunk(&mut self, input: &str) -> Vec<(EditAction, String)> { - use State::*; - - let mut actions = Vec::new(); - - for byte in input.bytes() { - // Update line and column tracking - if byte == b'\n' { - self.line += 1; - self.column = 0; - } else { - self.column += 1; - } - - let action_offset = self.action_source.len(); - - match &self.state { - Default => match self.match_marker(byte, FENCE, false) { - MarkerMatch::Complete => { - self.fence_start_offset = action_offset + 1 - FENCE.len(); - self.to_state(OpenFence); - } - MarkerMatch::Partial => {} - MarkerMatch::None => { - if self.marker_ix > 0 { - self.marker_ix = 0; - } else if self.action_source.ends_with(b"\n") { - self.action_source.clear(); - } - } - }, - OpenFence => { - // skip language tag - if byte == b'\n' { - self.to_state(SearchMarker); - } - } - SearchMarker => { - if self.expect_marker(byte, SEARCH_MARKER, true) { - self.to_state(SearchBlock); - } - } - SearchBlock => { - if self.extend_block_range(byte, DIVIDER, NL_DIVIDER) { - self.old_range = take(&mut self.block_range); - self.to_state(ReplaceBlock); - } - } - ReplaceBlock => { - if self.extend_block_range(byte, REPLACE_MARKER, NL_REPLACE_MARKER) { - self.new_range = take(&mut self.block_range); - self.to_state(CloseFence); - } - } - CloseFence => { - if self.expect_marker(byte, FENCE, false) { - self.action_source.push(byte); - - if let Some(action) = self.action() { - actions.push(action); - } - - self.errors(); - self.reset(); - - continue; - } - } - }; - - self.action_source.push(byte); - } - - actions - } - - /// Returns a reference to the errors encountered during parsing. - pub fn errors(&self) -> &[ParseError] { - &self.errors - } - - fn action(&mut self) -> Option<(EditAction, String)> { - let old_range = take(&mut self.old_range); - let new_range = take(&mut self.new_range); - - let action_source = take(&mut self.action_source); - let action_source = String::from_utf8(action_source).log_err()?; - - let mut file_path_bytes = action_source[..self.fence_start_offset].to_owned(); - - if file_path_bytes.ends_with("\n") { - file_path_bytes.pop(); - if file_path_bytes.ends_with("\r") { - file_path_bytes.pop(); - } - } - - let file_path = PathBuf::from(file_path_bytes); - - if old_range.is_empty() { - return Some(( - EditAction::Write { - file_path, - content: action_source[new_range].to_owned(), - }, - action_source, - )); - } - - let old = action_source[old_range].to_owned(); - let new = action_source[new_range].to_owned(); - - let action = EditAction::Replace { - file_path, - old, - new, - }; - - Some((action, action_source)) - } - - fn to_state(&mut self, state: State) { - self.state = state; - self.marker_ix = 0; - } - - fn reset(&mut self) { - self.action_source.clear(); - self.block_range = Range::default(); - self.old_range = Range::default(); - self.new_range = Range::default(); - self.fence_start_offset = 0; - self.marker_ix = 0; - self.to_state(State::Default); - } - - fn expect_marker(&mut self, byte: u8, marker: &'static str, trailing_newline: bool) -> bool { - match self.match_marker(byte, marker, trailing_newline) { - MarkerMatch::Complete => true, - MarkerMatch::Partial => false, - MarkerMatch::None => { - self.errors.push(ParseError { - line: self.line, - column: self.column, - expected: marker, - found: byte, - }); - - self.reset(); - false - } - } - } - - fn extend_block_range(&mut self, byte: u8, marker: &str, nl_marker: &str) -> bool { - let marker = if self.block_range.is_empty() { - // do not require another newline if block is empty - marker - } else { - nl_marker - }; - - let offset = self.action_source.len(); - - match self.match_marker(byte, marker, true) { - MarkerMatch::Complete => { - if self.action_source[self.block_range.clone()].ends_with(b"\r") { - self.block_range.end -= 1; - } - - true - } - MarkerMatch::Partial => false, - MarkerMatch::None => { - if self.marker_ix > 0 { - self.marker_ix = 0; - self.block_range.end = offset; - - // The beginning of marker might match current byte - match self.match_marker(byte, marker, true) { - MarkerMatch::Complete => return true, - MarkerMatch::Partial => return false, - MarkerMatch::None => { /* no match, keep collecting */ } - } - } - - if self.block_range.is_empty() { - self.block_range.start = offset; - } - self.block_range.end = offset + 1; - - false - } - } - } - - fn match_marker(&mut self, byte: u8, marker: &str, trailing_newline: bool) -> MarkerMatch { - if trailing_newline && self.marker_ix >= marker.len() { - if byte == b'\n' { - MarkerMatch::Complete - } else if byte == b'\r' { - MarkerMatch::Partial - } else { - MarkerMatch::None - } - } else if byte == marker.as_bytes()[self.marker_ix] { - self.marker_ix += 1; - - if self.marker_ix < marker.len() || trailing_newline { - MarkerMatch::Partial - } else { - MarkerMatch::Complete - } - } else { - MarkerMatch::None - } - } -} - -#[derive(Debug)] -enum MarkerMatch { - None, - Partial, - Complete, -} - -#[derive(Debug, PartialEq, Eq)] -pub struct ParseError { - line: usize, - column: usize, - expected: &'static str, - found: u8, -} - -impl std::fmt::Display for ParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "input:{}:{}: Expected marker {:?}, found {:?}", - self.line, self.column, self.expected, self.found as char - ) - } -} - -pub fn edit_model_prompt() -> String { - include_str!("edit_prompt.md") - .to_string() - .replace("{{SEARCH_MARKER}}", SEARCH_MARKER) - .replace("{{DIVIDER}}", DIVIDER) - .replace("{{REPLACE_MARKER}}", REPLACE_MARKER) -} - -#[cfg(test)] -mod tests { - use super::*; - use rand::prelude::*; - use util::line_endings; - - const WRONG_MARKER: &str = concat!(marker_sym!('<'), " WRONG_MARKER"); - - #[test] - fn test_simple_edit_action() { - // Construct test input using format with multiline string literals - let input = format!( - r#"src/main.rs -``` -{} -fn original() {{}} -{} -fn replacement() {{}} -{} -``` -"#, - SEARCH_MARKER, DIVIDER, REPLACE_MARKER - ); - - let mut parser = EditActionParser::new(); - let actions = parser.parse_chunk(&input); - - assert_no_errors(&parser); - assert_eq!(actions.len(), 1); - assert_eq!( - actions[0].0, - EditAction::Replace { - file_path: PathBuf::from("src/main.rs"), - old: "fn original() {}".to_string(), - new: "fn replacement() {}".to_string(), - } - ); - } - - #[test] - fn test_with_language_tag() { - // Construct test input using format with multiline string literals - let input = format!( - r#"src/main.rs -```rust -{} -fn original() {{}} -{} -fn replacement() {{}} -{} -``` -"#, - SEARCH_MARKER, DIVIDER, REPLACE_MARKER - ); - - let mut parser = EditActionParser::new(); - let actions = parser.parse_chunk(&input); - - assert_no_errors(&parser); - assert_eq!(actions.len(), 1); - assert_eq!( - actions[0].0, - EditAction::Replace { - file_path: PathBuf::from("src/main.rs"), - old: "fn original() {}".to_string(), - new: "fn replacement() {}".to_string(), - } - ); - } - - #[test] - fn test_with_surrounding_text() { - // Construct test input using format with multiline string literals - let input = format!( - r#"Here's a modification I'd like to make to the file: - -src/main.rs -```rust -{} -fn original() {{}} -{} -fn replacement() {{}} -{} -``` - -This change makes the function better. -"#, - SEARCH_MARKER, DIVIDER, REPLACE_MARKER - ); - - let mut parser = EditActionParser::new(); - let actions = parser.parse_chunk(&input); - - assert_no_errors(&parser); - assert_eq!(actions.len(), 1); - assert_eq!( - actions[0].0, - EditAction::Replace { - file_path: PathBuf::from("src/main.rs"), - old: "fn original() {}".to_string(), - new: "fn replacement() {}".to_string(), - } - ); - } - - #[test] - fn test_multiple_edit_actions() { - // Construct test input using format with multiline string literals - let input = format!( - r#"First change: -src/main.rs -``` -{} -fn original() {{}} -{} -fn replacement() {{}} -{} -``` - -Second change: -src/utils.rs -```rust -{} -fn old_util() -> bool {{ false }} -{} -fn new_util() -> bool {{ true }} -{} -``` -"#, - SEARCH_MARKER, DIVIDER, REPLACE_MARKER, SEARCH_MARKER, DIVIDER, REPLACE_MARKER - ); - - let mut parser = EditActionParser::new(); - let actions = parser.parse_chunk(&input); - - assert_no_errors(&parser); - assert_eq!(actions.len(), 2); - - let (action, _) = &actions[0]; - assert_eq!( - action, - &EditAction::Replace { - file_path: PathBuf::from("src/main.rs"), - old: "fn original() {}".to_string(), - new: "fn replacement() {}".to_string(), - } - ); - let (action2, _) = &actions[1]; - assert_eq!( - action2, - &EditAction::Replace { - file_path: PathBuf::from("src/utils.rs"), - old: "fn old_util() -> bool { false }".to_string(), - new: "fn new_util() -> bool { true }".to_string(), - } - ); - } - - #[test] - fn test_multiline() { - // Construct test input using format with multiline string literals - let input = format!( - r#"src/main.rs -```rust -{} -fn original() {{ - println!("This is the original function"); - let x = 42; - if x > 0 {{ - println!("Positive number"); - }} -}} -{} -fn replacement() {{ - println!("This is the replacement function"); - let x = 100; - if x > 50 {{ - println!("Large number"); - }} else {{ - println!("Small number"); - }} -}} -{} -``` -"#, - SEARCH_MARKER, DIVIDER, REPLACE_MARKER - ); - - let mut parser = EditActionParser::new(); - let actions = parser.parse_chunk(&input); - - assert_no_errors(&parser); - assert_eq!(actions.len(), 1); - - let (action, _) = &actions[0]; - assert_eq!( - action, - &EditAction::Replace { - file_path: PathBuf::from("src/main.rs"), - old: "fn original() {\n println!(\"This is the original function\");\n let x = 42;\n if x > 0 {\n println!(\"Positive number\");\n }\n}".to_string(), - new: "fn replacement() {\n println!(\"This is the replacement function\");\n let x = 100;\n if x > 50 {\n println!(\"Large number\");\n } else {\n println!(\"Small number\");\n }\n}".to_string(), - } - ); - } - - #[test] - fn test_write_action() { - // Construct test input using format with multiline string literals - let input = format!( - r#"Create a new main.rs file: - -src/main.rs -```rust -{} -{} -fn new_function() {{ - println!("This function is being added"); -}} -{} -``` -"#, - SEARCH_MARKER, DIVIDER, REPLACE_MARKER - ); - - let mut parser = EditActionParser::new(); - let actions = parser.parse_chunk(&input); - - assert_no_errors(&parser); - assert_eq!(actions.len(), 1); - assert_eq!( - actions[0].0, - EditAction::Write { - file_path: PathBuf::from("src/main.rs"), - content: "fn new_function() {\n println!(\"This function is being added\");\n}" - .to_string(), - } - ); - } - - #[test] - fn test_empty_replace() { - // Construct test input using format with multiline string literals - let input = format!( - r#"src/main.rs -```rust -{} -fn this_will_be_deleted() {{ - println!("Deleting this function"); -}} -{} -{} -``` -"#, - SEARCH_MARKER, DIVIDER, REPLACE_MARKER - ); - - let mut parser = EditActionParser::new(); - let actions = parser.parse_chunk(&input); - - assert_no_errors(&parser); - assert_eq!(actions.len(), 1); - assert_eq!( - actions[0].0, - EditAction::Replace { - file_path: PathBuf::from("src/main.rs"), - old: "fn this_will_be_deleted() {\n println!(\"Deleting this function\");\n}" - .to_string(), - new: "".to_string(), - } - ); - - let mut parser = EditActionParser::new(); - let actions = parser.parse_chunk(&input.replace("\n", "\r\n")); - assert_no_errors(&parser); - assert_eq!(actions.len(), 1); - assert_eq!( - actions[0].0, - EditAction::Replace { - file_path: PathBuf::from("src/main.rs"), - old: - "fn this_will_be_deleted() {\r\n println!(\"Deleting this function\");\r\n}" - .to_string(), - new: "".to_string(), - } - ); - } - - #[test] - fn test_empty_both() { - // Construct test input using format with multiline string literals - let input = format!( - r#"src/main.rs -```rust -{} -{} -{} -``` -"#, - SEARCH_MARKER, DIVIDER, REPLACE_MARKER - ); - - let mut parser = EditActionParser::new(); - let actions = parser.parse_chunk(&input); - - assert_eq!(actions.len(), 1); - assert_eq!( - actions[0].0, - EditAction::Write { - file_path: PathBuf::from("src/main.rs"), - content: String::new(), - } - ); - assert_no_errors(&parser); - } - - #[test] - fn test_resumability() { - // Construct test input using format with multiline string literals - let input_part1 = format!("src/main.rs\n```rust\n{}\nfn ori", SEARCH_MARKER); - - let input_part2 = format!("ginal() {{}}\n{}\nfn replacement() {{}}", DIVIDER); - - let input_part3 = format!("\n{}\n```\n", REPLACE_MARKER); - - let mut parser = EditActionParser::new(); - let actions1 = parser.parse_chunk(&input_part1); - assert_no_errors(&parser); - assert_eq!(actions1.len(), 0); - - let actions2 = parser.parse_chunk(&input_part2); - // No actions should be complete yet - assert_no_errors(&parser); - assert_eq!(actions2.len(), 0); - - let actions3 = parser.parse_chunk(&input_part3); - // The third chunk should complete the action - assert_no_errors(&parser); - assert_eq!(actions3.len(), 1); - let (action, _) = &actions3[0]; - assert_eq!( - action, - &EditAction::Replace { - file_path: PathBuf::from("src/main.rs"), - old: "fn original() {}".to_string(), - new: "fn replacement() {}".to_string(), - } - ); - } - - #[test] - fn test_parser_state_preservation() { - let mut parser = EditActionParser::new(); - let first_chunk = format!("src/main.rs\n```rust\n{}\n", SEARCH_MARKER); - let actions1 = parser.parse_chunk(&first_chunk); - - // Check parser is in the correct state - assert_no_errors(&parser); - assert_eq!(parser.state, State::SearchBlock); - assert_eq!(parser.action_source, first_chunk.as_bytes()); - - // Continue parsing - let second_chunk = format!("original code\n{}\n", DIVIDER); - let actions2 = parser.parse_chunk(&second_chunk); - - assert_no_errors(&parser); - assert_eq!(parser.state, State::ReplaceBlock); - assert_eq!( - &parser.action_source[parser.old_range.clone()], - b"original code" - ); - - let third_chunk = format!("replacement code\n{}\n```\n", REPLACE_MARKER); - let actions3 = parser.parse_chunk(&third_chunk); - - // After complete parsing, state should reset - assert_no_errors(&parser); - assert_eq!(parser.state, State::Default); - assert_eq!(parser.action_source, b"\n"); - assert!(parser.old_range.is_empty()); - assert!(parser.new_range.is_empty()); - - assert_eq!(actions1.len(), 0); - assert_eq!(actions2.len(), 0); - assert_eq!(actions3.len(), 1); - } - - #[test] - fn test_invalid_search_marker() { - let input = format!( - r#"src/main.rs -```rust -{} -fn original() {{}} -{} -fn replacement() {{}} -{} -``` -"#, - WRONG_MARKER, DIVIDER, REPLACE_MARKER - ); - - let mut parser = EditActionParser::new(); - let actions = parser.parse_chunk(&input); - assert_eq!(actions.len(), 0); - - assert_eq!(parser.errors().len(), 1); - let error = &parser.errors()[0]; - - assert_eq!( - error.to_string(), - format!( - "input:3:9: Expected marker \"{}\", found 'W'", - SEARCH_MARKER - ) - ); - } - - #[test] - fn test_missing_closing_fence() { - // Construct test input using format with multiline string literals - let input = format!( - r#"src/main.rs -```rust -{} -fn original() {{}} -{} -fn replacement() {{}} -{} - - -src/utils.rs -```rust -{} -fn utils_func() {{}} -{} -fn new_utils_func() {{}} -{} -``` -"#, - SEARCH_MARKER, DIVIDER, REPLACE_MARKER, SEARCH_MARKER, DIVIDER, REPLACE_MARKER - ); - - let mut parser = EditActionParser::new(); - let actions = parser.parse_chunk(&input); - - // Only the second block should be parsed - assert_eq!(actions.len(), 1); - let (action, _) = &actions[0]; - assert_eq!( - action, - &EditAction::Replace { - file_path: PathBuf::from("src/utils.rs"), - old: "fn utils_func() {}".to_string(), - new: "fn new_utils_func() {}".to_string(), - } - ); - assert_eq!(parser.errors().len(), 1); - assert_eq!( - parser.errors()[0].to_string(), - "input:8:1: Expected marker \"```\", found '<'" - ); - - // The parser should continue after an error - assert_eq!(parser.state, State::Default); - } - - #[test] - fn test_parse_examples_in_edit_prompt() { - let mut parser = EditActionParser::new(); - let actions = parser.parse_chunk(&edit_model_prompt()); - assert_examples_in_edit_prompt(&actions, parser.errors()); - } - - #[gpui::test(iterations = 10)] - fn test_random_chunking_of_edit_prompt(mut rng: StdRng) { - let mut parser = EditActionParser::new(); - let mut remaining: &str = &edit_model_prompt(); - let mut actions = Vec::with_capacity(5); - - while !remaining.is_empty() { - let chunk_size = rng.gen_range(1..=std::cmp::min(remaining.len(), 100)); - - let (chunk, rest) = remaining.split_at(chunk_size); - - let chunk_actions = parser.parse_chunk(chunk); - actions.extend(chunk_actions); - remaining = rest; - } - - assert_examples_in_edit_prompt(&actions, parser.errors()); - } - - fn assert_examples_in_edit_prompt(actions: &[(EditAction, String)], errors: &[ParseError]) { - assert_eq!(actions.len(), 5); - - assert_eq!( - actions[0].0, - EditAction::Replace { - file_path: PathBuf::from("mathweb/flask/app.py"), - old: "from flask import Flask".to_string(), - new: line_endings!("import math\nfrom flask import Flask").to_string(), - }, - ); - - assert_eq!( - actions[1].0, - EditAction::Replace { - file_path: PathBuf::from("mathweb/flask/app.py"), - old: line_endings!("def factorial(n):\n \"compute factorial\"\n\n if n == 0:\n return 1\n else:\n return n * factorial(n-1)\n").to_string(), - new: "".to_string(), - } - ); - - assert_eq!( - actions[2].0, - EditAction::Replace { - file_path: PathBuf::from("mathweb/flask/app.py"), - old: " return str(factorial(n))".to_string(), - new: " return str(math.factorial(n))".to_string(), - }, - ); - - assert_eq!( - actions[3].0, - EditAction::Write { - file_path: PathBuf::from("hello.py"), - content: line_endings!( - "def hello():\n \"print a greeting\"\n\n print(\"hello\")" - ) - .to_string(), - }, - ); - - assert_eq!( - actions[4].0, - EditAction::Replace { - file_path: PathBuf::from("main.py"), - old: line_endings!( - "def hello():\n \"print a greeting\"\n\n print(\"hello\")" - ) - .to_string(), - new: "from hello import hello".to_string(), - }, - ); - - // The system prompt includes some text that would produce errors - assert_eq!( - errors[0].to_string(), - format!( - "input:102:1: Expected marker \"{}\", found '3'", - SEARCH_MARKER - ) - ); - #[cfg(not(windows))] - assert_eq!( - errors[1].to_string(), - format!( - "input:109:0: Expected marker \"{}\", found '\\n'", - SEARCH_MARKER - ) - ); - #[cfg(windows)] - assert_eq!( - errors[1].to_string(), - format!( - "input:108:1: Expected marker \"{}\", found '\\r'", - SEARCH_MARKER - ) - ); - } - - #[test] - fn test_print_error() { - let input = format!( - r#"src/main.rs -```rust -{} -fn original() {{}} -{} -fn replacement() {{}} -{} -``` -"#, - WRONG_MARKER, DIVIDER, REPLACE_MARKER - ); - - let mut parser = EditActionParser::new(); - parser.parse_chunk(&input); - - assert_eq!(parser.errors().len(), 1); - let error = &parser.errors()[0]; - let expected_error = format!( - r#"input:3:9: Expected marker "{}", found 'W'"#, - SEARCH_MARKER - ); - - assert_eq!(format!("{}", error), expected_error); - } - - // helpers - - fn assert_no_errors(parser: &EditActionParser) { - let errors = parser.errors(); - - assert!( - errors.is_empty(), - "Expected no errors, but found:\n\n{}", - errors - .iter() - .map(|e| e.to_string()) - .collect::>() - .join("\n") - ); - } -} diff --git a/crates/assistant_tools/src/edit_files_tool/edit_prompt.md b/crates/assistant_tools/src/edit_files_tool/edit_prompt.md deleted file mode 100644 index 6ef456a437..0000000000 --- a/crates/assistant_tools/src/edit_files_tool/edit_prompt.md +++ /dev/null @@ -1,134 +0,0 @@ -Act as an expert software developer. -Always use best practices when coding. -Respect and use existing conventions, libraries, etc that are already present in the code base. - -Take requests for changes to the supplied code. -Always reply to the user in the same language they are using. - -Once you understand the request, you MUST: - -1. Decide if you need to propose *SEARCH/REPLACE* edits to any files that haven't been added to the chat. You can create new files without asking! - -2. Think step-by-step and explain the needed changes in a few short sentences. - -3. Describe each change with a *SEARCH/REPLACE block* per the examples below. - -All changes to files must use this *SEARCH/REPLACE block* format. -ONLY EVER RETURN CODE IN A *SEARCH/REPLACE BLOCK*! - -# Example conversations: - -## USER: Change get_factorial() to use math.factorial - -## ASSISTANT: To make this change we need to modify `mathweb/flask/app.py` to: - -1. Import the math package. -2. Remove the existing factorial() function. -3. Update get_factorial() to call math.factorial instead. - -Here are the *SEARCH/REPLACE* blocks: - -mathweb/flask/app.py -```python -{{SEARCH_MARKER}} -from flask import Flask -{{DIVIDER}} -import math -from flask import Flask -{{REPLACE_MARKER}} -``` - -mathweb/flask/app.py -```python -{{SEARCH_MARKER}} -def factorial(n): - "compute factorial" - - if n == 0: - return 1 - else: - return n * factorial(n-1) - -{{DIVIDER}} -{{REPLACE_MARKER}} -``` - -mathweb/flask/app.py -```python -{{SEARCH_MARKER}} - return str(factorial(n)) -{{DIVIDER}} - return str(math.factorial(n)) -{{REPLACE_MARKER}} -``` - - -## USER: Refactor hello() into its own file. - -## ASSISTANT: To make this change we need to modify `main.py` and make a new file `hello.py`: - -1. Make a new hello.py file with hello() in it. -2. Remove hello() from main.py and replace it with an import. - -Here are the *SEARCH/REPLACE* blocks: - -hello.py -```python -{{SEARCH_MARKER}} -{{DIVIDER}} -def hello(): - "print a greeting" - - print("hello") -{{REPLACE_MARKER}} -``` - -main.py -```python -{{SEARCH_MARKER}} -def hello(): - "print a greeting" - - print("hello") -{{DIVIDER}} -from hello import hello -{{REPLACE_MARKER}} -``` -# *SEARCH/REPLACE block* Rules: - -Every *SEARCH/REPLACE block* must use this format: -1. The *FULL* file path alone on a line, verbatim. No bold asterisks, no quotes around it, no escaping of characters, etc. -2. The opening fence and code language, eg: ```python -3. The start of search block: {{SEARCH_MARKER}} -4. A contiguous chunk of lines to search for in the existing source code -5. The dividing line: {{DIVIDER}} -6. The lines to replace into the source code -7. The end of the replace block: {{REPLACE_MARKER}} -8. The closing fence: ``` - -Use the *FULL* file path, as shown to you by the user. Make sure to include the project's root directory name at the start of the path. *NEVER* specify the absolute path of the file! - -Every *SEARCH* section must *EXACTLY MATCH* the existing file content, character for character, including all comments, docstrings, etc. -If the file contains code or other data wrapped/escaped in json/xml/quotes or other containers, you need to propose edits to the literal contents of the file, including the container markup. - -*SEARCH/REPLACE* blocks will *only* replace the first match occurrence. -Including multiple unique *SEARCH/REPLACE* blocks if needed. -Include enough lines in each SEARCH section to uniquely match each set of lines that need to change. - -Keep *SEARCH/REPLACE* blocks concise. -Break large *SEARCH/REPLACE* blocks into a series of smaller blocks that each change a small portion of the file. -Include just the changing lines, and a few surrounding lines if needed for uniqueness. -Do not include long runs of unchanging lines in *SEARCH/REPLACE* blocks. - -Only create *SEARCH/REPLACE* blocks for files that have been read! Even though the conversation includes `read-file` tool results, you *CANNOT* issue your own reads. If the conversation doesn't include the code you need to edit, ask for it to be read explicitly. - -To move code within a file, use 2 *SEARCH/REPLACE* blocks: 1 to delete it from its current location, 1 to insert it in the new location. - -Pay attention to which filenames the user wants you to edit, especially if they are asking you to create a new file. - -If you want to put code in a new file, use a *SEARCH/REPLACE block* with: -- A new file path, including dir name if needed -- An empty `SEARCH` section -- The new file's contents in the `REPLACE` section - -ONLY EVER RETURN CODE IN A *SEARCH/REPLACE BLOCK*! diff --git a/crates/assistant_tools/src/edit_files_tool/log.rs b/crates/assistant_tools/src/edit_files_tool/log.rs deleted file mode 100644 index b4c5523d91..0000000000 --- a/crates/assistant_tools/src/edit_files_tool/log.rs +++ /dev/null @@ -1,417 +0,0 @@ -use std::path::Path; - -use collections::HashSet; -use feature_flags::FeatureFlagAppExt; -use gpui::{ - App, Empty, Entity, EventEmitter, FocusHandle, Focusable, Global, ListAlignment, ListState, - SharedString, Subscription, Window, actions, list, prelude::*, -}; -use release_channel::ReleaseChannel; -use settings::Settings; -use ui::prelude::*; -use workspace::{Item, Workspace, WorkspaceId, item::ItemEvent}; - -use super::edit_action::EditAction; - -actions!(debug, [EditTool]); - -pub fn init(cx: &mut App) { - if cx.is_staff() || ReleaseChannel::global(cx) == ReleaseChannel::Dev { - // Track events even before opening the log - EditToolLog::global(cx); - } - - cx.observe_new(|workspace: &mut Workspace, _, _| { - workspace.register_action(|workspace, _: &EditTool, window, cx| { - let viewer = cx.new(EditToolLogViewer::new); - workspace.add_item_to_active_pane(Box::new(viewer), None, true, window, cx) - }); - }) - .detach(); -} - -pub struct GlobalEditToolLog(Entity); - -impl Global for GlobalEditToolLog {} - -#[derive(Default)] -pub struct EditToolLog { - requests: Vec, -} - -#[derive(Clone, Copy, Hash, Eq, PartialEq)] -pub struct EditToolRequestId(u32); - -impl EditToolLog { - pub fn global(cx: &mut App) -> Entity { - match Self::try_global(cx) { - Some(entity) => entity, - None => { - let entity = cx.new(|_cx| Self::default()); - cx.set_global(GlobalEditToolLog(entity.clone())); - entity - } - } - } - - pub fn try_global(cx: &App) -> Option> { - cx.try_global::() - .map(|log| log.0.clone()) - } - - pub fn new_request( - &mut self, - instructions: String, - cx: &mut Context, - ) -> EditToolRequestId { - let id = EditToolRequestId(self.requests.len() as u32); - self.requests.push(EditToolRequest { - id, - instructions, - editor_response: None, - tool_output: None, - parsed_edits: Vec::new(), - }); - cx.emit(EditToolLogEvent::Inserted); - id - } - - pub fn push_editor_response_chunk( - &mut self, - id: EditToolRequestId, - chunk: &str, - new_actions: &[(EditAction, String)], - cx: &mut Context, - ) { - if let Some(request) = self.requests.get_mut(id.0 as usize) { - match &mut request.editor_response { - None => { - request.editor_response = Some(chunk.to_string()); - } - Some(response) => { - response.push_str(chunk); - } - } - request - .parsed_edits - .extend(new_actions.iter().cloned().map(|(action, _)| action)); - - cx.emit(EditToolLogEvent::Updated); - } - } - - pub fn set_tool_output( - &mut self, - id: EditToolRequestId, - tool_output: Result, - cx: &mut Context, - ) { - if let Some(request) = self.requests.get_mut(id.0 as usize) { - request.tool_output = Some(tool_output); - cx.emit(EditToolLogEvent::Updated); - } - } -} - -enum EditToolLogEvent { - Inserted, - Updated, -} - -impl EventEmitter for EditToolLog {} - -pub struct EditToolRequest { - id: EditToolRequestId, - instructions: String, - // we don't use a result here because the error might have occurred after we got a response - editor_response: Option, - parsed_edits: Vec, - tool_output: Option>, -} - -pub struct EditToolLogViewer { - focus_handle: FocusHandle, - log: Entity, - list_state: ListState, - expanded_edits: HashSet<(EditToolRequestId, usize)>, - _subscription: Subscription, -} - -impl EditToolLogViewer { - pub fn new(cx: &mut Context) -> Self { - let log = EditToolLog::global(cx); - - let subscription = cx.subscribe(&log, Self::handle_log_event); - - Self { - focus_handle: cx.focus_handle(), - log: log.clone(), - list_state: ListState::new( - log.read(cx).requests.len(), - ListAlignment::Bottom, - px(1024.), - { - let this = cx.entity().downgrade(); - move |ix, window: &mut Window, cx: &mut App| { - this.update(cx, |this, cx| this.render_request(ix, window, cx)) - .unwrap() - } - }, - ), - expanded_edits: HashSet::default(), - _subscription: subscription, - } - } - - fn handle_log_event( - &mut self, - _: Entity, - event: &EditToolLogEvent, - cx: &mut Context, - ) { - match event { - EditToolLogEvent::Inserted => { - let count = self.list_state.item_count(); - self.list_state.splice(count..count, 1); - } - EditToolLogEvent::Updated => {} - } - - cx.notify(); - } - - fn render_request( - &self, - index: usize, - _window: &mut Window, - cx: &mut Context, - ) -> AnyElement { - let requests = &self.log.read(cx).requests; - let request = &requests[index]; - - v_flex() - .gap_3() - .child(Self::render_section(IconName::ArrowRight, "Tool Input")) - .child(request.instructions.clone()) - .py_5() - .when(index + 1 < requests.len(), |element| { - element - .border_b_1() - .border_color(cx.theme().colors().border) - }) - .map(|parent| match &request.editor_response { - None => { - if request.tool_output.is_none() { - parent.child("...") - } else { - parent - } - } - Some(response) => parent - .child(Self::render_section( - IconName::ZedAssistant, - "Editor Response", - )) - .child(Label::new(response.clone()).buffer_font(cx)), - }) - .when(!request.parsed_edits.is_empty(), |parent| { - parent - .child(Self::render_section(IconName::Microscope, "Parsed Edits")) - .child( - v_flex() - .gap_2() - .children(request.parsed_edits.iter().enumerate().map( - |(index, edit)| { - self.render_edit_action(edit, request.id, index, cx) - }, - )), - ) - }) - .when_some(request.tool_output.as_ref(), |parent, output| { - parent - .child(Self::render_section(IconName::ArrowLeft, "Tool Output")) - .child(match output { - Ok(output) => Label::new(output.clone()).color(Color::Success), - Err(error) => Label::new(error.clone()).color(Color::Error), - }) - }) - .into_any() - } - - fn render_section(icon: IconName, title: &'static str) -> AnyElement { - h_flex() - .gap_1() - .child(Icon::new(icon).color(Color::Muted)) - .child(Label::new(title).size(LabelSize::Small).color(Color::Muted)) - .into_any() - } - - fn render_edit_action( - &self, - edit_action: &EditAction, - request_id: EditToolRequestId, - index: usize, - cx: &Context, - ) -> AnyElement { - let expanded_id = (request_id, index); - - match edit_action { - EditAction::Replace { - file_path, - old, - new, - } => self - .render_edit_action_container( - expanded_id, - &file_path, - [ - Self::render_block(IconName::MagnifyingGlass, "Search", old.clone(), cx) - .border_r_1() - .border_color(cx.theme().colors().border) - .into_any(), - Self::render_block(IconName::Replace, "Replace", new.clone(), cx) - .into_any(), - ], - cx, - ) - .into_any(), - EditAction::Write { file_path, content } => self - .render_edit_action_container( - expanded_id, - &file_path, - [ - Self::render_block(IconName::Pencil, "Write", content.clone(), cx) - .into_any(), - ], - cx, - ) - .into_any(), - } - } - - fn render_edit_action_container( - &self, - expanded_id: (EditToolRequestId, usize), - file_path: &Path, - content: impl IntoIterator, - cx: &Context, - ) -> AnyElement { - let is_expanded = self.expanded_edits.contains(&expanded_id); - - v_flex() - .child( - h_flex() - .bg(cx.theme().colors().element_background) - .border_1() - .border_color(cx.theme().colors().border) - .rounded_t_md() - .when(!is_expanded, |el| el.rounded_b_md()) - .py_1() - .px_2() - .gap_1() - .child( - ui::Disclosure::new(ElementId::Integer(expanded_id.1), is_expanded) - .on_click(cx.listener(move |this, _ev, _window, cx| { - if is_expanded { - this.expanded_edits.remove(&expanded_id); - } else { - this.expanded_edits.insert(expanded_id); - } - - cx.notify(); - })), - ) - .child(Label::new(file_path.display().to_string()).size(LabelSize::Small)), - ) - .child(if is_expanded { - h_flex() - .border_1() - .border_t_0() - .border_color(cx.theme().colors().border) - .rounded_b_md() - .children(content) - .into_any() - } else { - Empty.into_any() - }) - .into_any() - } - - fn render_block(icon: IconName, title: &'static str, content: String, cx: &App) -> Div { - v_flex() - .p_1() - .gap_1() - .flex_1() - .h_full() - .child( - h_flex() - .gap_1() - .child(Icon::new(icon).color(Color::Muted)) - .child(Label::new(title).size(LabelSize::Small).color(Color::Muted)), - ) - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) - .text_sm() - .child(content) - .child(div().flex_1()) - } -} - -impl EventEmitter<()> for EditToolLogViewer {} - -impl Focusable for EditToolLogViewer { - fn focus_handle(&self, _: &App) -> gpui::FocusHandle { - self.focus_handle.clone() - } -} - -impl Item for EditToolLogViewer { - type Event = (); - - fn to_item_events(_: &Self::Event, _: impl FnMut(ItemEvent)) {} - - fn tab_content_text(&self, _window: &Window, _cx: &App) -> Option { - Some("Edit Tool Log".into()) - } - - fn telemetry_event_text(&self) -> Option<&'static str> { - None - } - - fn clone_on_split( - &self, - _workspace_id: Option, - _window: &mut Window, - cx: &mut Context, - ) -> Option> - where - Self: Sized, - { - Some(cx.new(Self::new)) - } -} - -impl Render for EditToolLogViewer { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - if self.list_state.item_count() == 0 { - return v_flex() - .justify_center() - .size_full() - .gap_1() - .bg(cx.theme().colors().editor_background) - .text_center() - .text_lg() - .child("No requests yet") - .child( - div() - .text_ui(cx) - .child("Go ask the assistant to perform some edits"), - ); - } - - v_flex() - .p_4() - .bg(cx.theme().colors().editor_background) - .size_full() - .child(list(self.list_state.clone()).flex_grow()) - } -}