mod reindent;mod streaming_fuzzy_matcher;mod streaming_parser; use super::tool_permissions::resolve_creatable_global_skill_path;use crate::{Thread, ToolCallEventStream};use acp_thread::Diff;use action_log::ActionLog;use agent_client_protocol::schema::v1::{self as acp, ToolCallLocation, ToolCallUpdateFields};use anyhow::Result;use collections::HashSet;use futures::{FutureExt, channel::oneshot};use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};use language::language_settings::{self, FormatOnSave};use language::{Buffer, BufferEditSource, BufferEvent, LanguageRegistry};use language_model::LanguageModelToolResultContent;use project::lsp_store::{FormatTrigger, LspFormatTarget};use project::{AgentLocation, Project, ProjectPath};use reindent::{Reindenter, compute_indent_delta, compute_rest_indent_delta};use schemars::JsonSchema;use serde::{Deserialize, Serialize, de::DeserializeOwned};use std::ops::Range;use std::path::PathBuf;use std::sync::Arc;use streaming_diff::{CharOperation, StreamingDiff};use streaming_fuzzy_matcher::StreamingFuzzyMatcher;use streaming_parser::{EditEvent, StreamingParser, WriteEvent};use text::ToOffset;use ui::SharedString;use util::rel_path::RelPath;use util::{Deferred, ResultExt}; /// Operating mode used internally by `EditSession`/`Pipeline` to choose between/// applying granular edits (the `edit_file` tool) or replacing/creating the/// entire file content (the `write_file` tool).#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub(crate) enum EditSessionMode { Write, Edit,} /// A single edit operation that replaces old text with new text/// Properly escape all text fields as valid JSON strings./// Remember to escape special characters like newlines (`\n`) and quotes (`"`) in JSON strings.#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]pub struct Edit { /// The exact text to find in the file. This will be matched using fuzzy matching /// to handle minor differences in whitespace or formatting. /// /// Be minimal with replacements: /// - For unique lines, include only those lines /// - For non-unique lines, include enough context to identify them pub old_text: String, /// The text to replace it with pub new_text: String,} #[derive(Clone, Default, Debug, Deserialize)]pub struct PartialEdit { #[serde(default)] pub old_text: Option<String>, #[serde(default)] pub new_text: Option<String>,} #[derive(Debug, Serialize, Deserialize)]#[serde(untagged)]pub enum EditSessionOutput { Success { #[serde(alias = "original_path")] input_path: PathBuf, new_text: String, old_text: Arc<String>, #[serde(default)] diff: String, }, Error { error: String, #[serde(default, skip_serializing_if = "Option::is_none")] input_path: Option<PathBuf>, #[serde(default, skip_serializing_if = "String::is_empty")] diff: String, },} impl EditSessionOutput { pub fn error(error: impl Into<String>) -> Self { Self::Error { error: error.into(), input_path: None, diff: String::new(), } }} impl std::fmt::Display for EditSessionOutput { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { EditSessionOutput::Success { diff, input_path, .. } => { if diff.is_empty() { write!(f, "No edits were made.") } else { write!(f, "Edited {} successfully", input_path.display()) } } EditSessionOutput::Error { error, diff, input_path, } => { write!(f, "{error}\n")?; if let Some(input_path) = input_path && !diff.is_empty() { write!( f, "Edited {}:\n\n```diff\n{diff}\n```", input_path.display() ) } else { write!(f, "No edits were made.") } } } }} impl From<EditSessionOutput> for LanguageModelToolResultContent { fn from(output: EditSessionOutput) -> Self { output.to_string().into() }} pub(crate) struct EditSessionContext { project: Entity<Project>, thread: WeakEntity<Thread>, action_log: Entity<ActionLog>, language_registry: Arc<LanguageRegistry>,} impl EditSessionContext { pub(crate) fn new( project: Entity<Project>, thread: WeakEntity<Thread>, action_log: Entity<ActionLog>, language_registry: Arc<LanguageRegistry>, ) -> Self { Self { project, thread, action_log, language_registry, } } pub(crate) fn authorize( &self, tool_name: &str, path: &PathBuf, event_stream: &ToolCallEventStream, cx: &mut App, ) -> Task<Result<()>> { super::tool_permissions::authorize_file_edit( tool_name, path, &self.thread, event_stream, cx, ) } fn set_agent_location(&self, buffer: WeakEntity<Buffer>, position: text::Anchor, cx: &mut App) { let should_update_agent_location = self .thread .read_with(cx, |thread, _cx| !thread.is_subagent()) .unwrap_or_default(); if should_update_agent_location { self.project.update(cx, |project, cx| { project.set_agent_location(Some(AgentLocation { buffer, position }), cx); }); } } async fn ensure_buffer_saved(&self, buffer: &Entity<Buffer>, cx: &mut AsyncApp) { let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| { let settings = language_settings::LanguageSettings::for_buffer(buffer, cx); settings.format_on_save != FormatOnSave::Off }); if format_on_save_enabled { self.project .update(cx, |project, cx| { project.format( HashSet::from_iter([buffer.clone()]), LspFormatTarget::Buffers, false, FormatTrigger::Save, cx, ) }) .await .log_err(); } self.project .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) .await .log_err(); self.action_log.update(cx, |log, cx| { log.buffer_edited(buffer.clone(), cx); }); } pub(crate) fn initial_title_from_path( &self, path: &std::path::Path, default: &str, cx: &App, ) -> SharedString { let project = self.project.read(cx); if let Some(project_path) = project.find_project_path(path, cx) && let Some(short) = project.short_full_path_for_project_path(&project_path, cx) { return short.into(); } let display = path.to_string_lossy(); if display.is_empty() { default.into() } else { display.into_owned().into() } } pub(crate) fn replay_output( &self, output: EditSessionOutput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Result<()> { match output { EditSessionOutput::Success { input_path, old_text, new_text, .. } => { event_stream.update_diff(cx.new(|cx| { Diff::finalized( input_path.to_string_lossy().into_owned(), Some(old_text.to_string()), new_text, self.language_registry.clone(), cx, ) })); Ok(()) } EditSessionOutput::Error { .. } => Ok(()), } }} pub(crate) enum EditSessionResult { Completed(EditSession), Failed { error: String, session: Option<EditSession>, },} pub(crate) async fn run_session( result: EditSessionResult, event_stream: &ToolCallEventStream, cx: &mut AsyncApp,) -> Result<EditSessionOutput, EditSessionOutput> { match result { EditSessionResult::Completed(session) => { session .context .ensure_buffer_saved(&session.buffer, cx) .await; let (new_text, diff) = session.compute_new_text_and_diff(cx).await; Ok(EditSessionOutput::Success { old_text: session.old_text.clone(), new_text, input_path: session.input_path, diff, }) } EditSessionResult::Failed { error, session: Some(session), } => { session .context .ensure_buffer_saved(&session.buffer, cx) .await; let (_new_text, diff) = session.compute_new_text_and_diff(cx).await; if diff.is_empty() { event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![ acp::ToolCallContent::Content(acp::Content::new(error.clone())), ])); } Err(EditSessionOutput::Error { error, input_path: Some(session.input_path), diff, }) } EditSessionResult::Failed { error, session: None, } => { event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![ acp::ToolCallContent::Content(acp::Content::new(error.clone())), ])); Err(EditSessionOutput::Error { error, input_path: None, diff: String::new(), }) } }} pub(crate) fn initial_title_from_partial_path<P>( context: &EditSessionContext, raw_input: serde_json::Value, extract_path: impl FnOnce(&P) -> Option<String>, default: &str, cx: &App,) -> SharedStringwhere P: DeserializeOwned,{ if let Ok(partial) = serde_json::from_value::<P>(raw_input) && let Some(raw_path) = extract_path(&partial) { let trimmed = raw_path.trim(); if !trimmed.is_empty() { return context.initial_title_from_path(std::path::Path::new(trimmed), default, cx); } } default.into()} pub(crate) struct EditSession { abs_path: PathBuf, pub(crate) input_path: PathBuf, pub(crate) buffer: Entity<Buffer>, pub(crate) old_text: Arc<String>, diff: Entity<Diff>, parser: StreamingParser, pipeline: Pipeline, context: Arc<EditSessionContext>, _finalize_diff_guard: Deferred<Box<dyn FnOnce()>>,} /// The destination of an edit session, identified by its absolute path on/// disk. `project_path` is `Some` for files that live inside one of the/// project's worktrees (i.e. that the standard project-path machinery can/// resolve), and `None` for global skill files reached through the/// `~/.agents/skills` allowlist.struct EditSessionTarget { abs_path: PathBuf, project_path: Option<ProjectPath>,} enum Pipeline { Write(WritePipeline), Edit(EditPipeline),} struct WritePipeline { content_written: bool,} struct EditPipeline { current_edit: Option<EditPipelineEntry>, file_changed_since_last_read: bool,} enum EditPipelineEntry { ResolvingOldText { matcher: StreamingFuzzyMatcher, }, StreamingNewText { streaming_diff: StreamingDiff, edit_cursor: usize, reindenter: Reindenter, original_snapshot: text::BufferSnapshot, },} impl Pipeline { fn new(mode: EditSessionMode, file_changed_since_last_read: bool) -> Self { match mode { EditSessionMode::Write => Self::Write(WritePipeline { content_written: false, }), EditSessionMode::Edit => Self::Edit(EditPipeline { current_edit: None, file_changed_since_last_read, }), } }} impl WritePipeline { fn process_event( &mut self, event: &WriteEvent, buffer: &Entity<Buffer>, context: &EditSessionContext, cx: &mut AsyncApp, ) { let WriteEvent::ContentChunk { chunk } = event; let (buffer_id, buffer_len) = buffer.read_with(cx, |buffer, _cx| (buffer.remote_id(), buffer.len())); let edit_range = if self.content_written { buffer_len..buffer_len } else { 0..buffer_len }; agent_edit_buffer( buffer, [(edit_range, chunk.as_str())], &context.action_log, cx, ); cx.update(|cx| { context.set_agent_location( buffer.downgrade(), text::Anchor::max_for_buffer(buffer_id), cx, ); }); self.content_written = true; }} impl EditPipeline { fn ensure_resolving_old_text(&mut self, buffer: &Entity<Buffer>, cx: &mut AsyncApp) { if self.current_edit.is_none() { let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot()); self.current_edit = Some(EditPipelineEntry::ResolvingOldText { matcher: StreamingFuzzyMatcher::new(snapshot), }); } } fn process_event( &mut self, event: &EditEvent, buffer: &Entity<Buffer>, diff: &Entity<Diff>, abs_path: &PathBuf, context: &EditSessionContext, event_stream: &ToolCallEventStream, cx: &mut AsyncApp, ) -> Result<(), String> { match event { EditEvent::OldTextChunk { chunk, done: false, .. } => { log::debug!("old_text_chunk: done=false, chunk='{}'", chunk); self.ensure_resolving_old_text(buffer, cx); if let Some(EditPipelineEntry::ResolvingOldText { matcher }) = &mut self.current_edit && !chunk.is_empty() { if let Some(match_range) = matcher.push(chunk, None) { let anchor_range = buffer.read_with(cx, |buffer, _cx| { buffer.anchor_range_outside(match_range.clone()) }); diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx)); cx.update(|cx| { let position = buffer.read(cx).anchor_before(match_range.end); context.set_agent_location(buffer.downgrade(), position, cx); }); } } } EditEvent::OldTextChunk { edit_index, chunk, done: true, } => { log::debug!("old_text_chunk: done=true, chunk='{}'", chunk); self.ensure_resolving_old_text(buffer, cx); Lines 1–500 of 1269. Load more
mod reindent; mod streaming_fuzzy_matcher; mod streaming_parser; use super::tool_permissions::resolve_creatable_global_skill_path; use crate::{Thread, ToolCallEventStream}; use acp_thread::Diff; use action_log::ActionLog; use agent_client_protocol::schema::v1::{self as acp, ToolCallLocation, ToolCallUpdateFields}; use anyhow::Result; use collections::HashSet; use futures::{FutureExt, channel::oneshot}; use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity}; use language::language_settings::{self, FormatOnSave}; use language::{Buffer, BufferEditSource, BufferEvent, LanguageRegistry}; use language_model::LanguageModelToolResultContent; use project::lsp_store::{FormatTrigger, LspFormatTarget}; use project::{AgentLocation, Project, ProjectPath}; use reindent::{Reindenter, compute_indent_delta, compute_rest_indent_delta}; use schemars::JsonSchema; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use std::ops::Range; use std::path::PathBuf; use std::sync::Arc; use streaming_diff::{CharOperation, StreamingDiff}; use streaming_fuzzy_matcher::StreamingFuzzyMatcher; use streaming_parser::{EditEvent, StreamingParser, WriteEvent}; use text::ToOffset; use ui::SharedString; use util::rel_path::RelPath; use util::{Deferred, ResultExt}; /// Operating mode used internally by `EditSession`/`Pipeline` to choose between /// applying granular edits (the `edit_file` tool) or replacing/creating the /// entire file content (the `write_file` tool). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum EditSessionMode { Write, Edit, } /// A single edit operation that replaces old text with new text /// Properly escape all text fields as valid JSON strings. /// Remember to escape special characters like newlines (`\n`) and quotes (`"`) in JSON strings. #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] pub struct Edit { /// The exact text to find in the file. This will be matched using fuzzy matching /// to handle minor differences in whitespace or formatting. /// /// Be minimal with replacements: /// - For unique lines, include only those lines /// - For non-unique lines, include enough context to identify them pub old_text: String, /// The text to replace it with pub new_text: String, } #[derive(Clone, Default, Debug, Deserialize)] pub struct PartialEdit { #[serde(default)] pub old_text: Option<String>, #[serde(default)] pub new_text: Option<String>, } #[derive(Debug, Serialize, Deserialize)] #[serde(untagged)] pub enum EditSessionOutput { Success { #[serde(alias = "original_path")] input_path: PathBuf, new_text: String, old_text: Arc<String>, #[serde(default)] diff: String, }, Error { error: String, #[serde(default, skip_serializing_if = "Option::is_none")] input_path: Option<PathBuf>, #[serde(default, skip_serializing_if = "String::is_empty")] diff: String, }, } impl EditSessionOutput { pub fn error(error: impl Into<String>) -> Self { Self::Error { error: error.into(), input_path: None, diff: String::new(), } } } impl std::fmt::Display for EditSessionOutput { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { EditSessionOutput::Success { diff, input_path, .. } => { if diff.is_empty() { write!(f, "No edits were made.") } else { write!(f, "Edited {} successfully", input_path.display()) } } EditSessionOutput::Error { error, diff, input_path, } => { write!(f, "{error}\n")?; if let Some(input_path) = input_path && !diff.is_empty() { write!( f, "Edited {}:\n\n```diff\n{diff}\n```", input_path.display() ) } else { write!(f, "No edits were made.") } } } } } impl From<EditSessionOutput> for LanguageModelToolResultContent { fn from(output: EditSessionOutput) -> Self { output.to_string().into() } } pub(crate) struct EditSessionContext { project: Entity<Project>, thread: WeakEntity<Thread>, action_log: Entity<ActionLog>, language_registry: Arc<LanguageRegistry>, } impl EditSessionContext { pub(crate) fn new( project: Entity<Project>, thread: WeakEntity<Thread>, action_log: Entity<ActionLog>, language_registry: Arc<LanguageRegistry>, ) -> Self { Self { project, thread, action_log, language_registry, } } pub(crate) fn authorize( &self, tool_name: &str, path: &PathBuf, event_stream: &ToolCallEventStream, cx: &mut App, ) -> Task<Result<()>> { super::tool_permissions::authorize_file_edit( tool_name, path, &self.thread, event_stream, cx, ) } fn set_agent_location(&self, buffer: WeakEntity<Buffer>, position: text::Anchor, cx: &mut App) { let should_update_agent_location = self .thread .read_with(cx, |thread, _cx| !thread.is_subagent()) .unwrap_or_default(); if should_update_agent_location { self.project.update(cx, |project, cx| { project.set_agent_location(Some(AgentLocation { buffer, position }), cx); }); } } async fn ensure_buffer_saved(&self, buffer: &Entity<Buffer>, cx: &mut AsyncApp) { let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| { let settings = language_settings::LanguageSettings::for_buffer(buffer, cx); settings.format_on_save != FormatOnSave::Off }); if format_on_save_enabled { self.project .update(cx, |project, cx| { project.format( HashSet::from_iter([buffer.clone()]), LspFormatTarget::Buffers, false, FormatTrigger::Save, cx, ) }) .await .log_err(); } self.project .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) .await .log_err(); self.action_log.update(cx, |log, cx| { log.buffer_edited(buffer.clone(), cx); }); } pub(crate) fn initial_title_from_path( &self, path: &std::path::Path, default: &str, cx: &App, ) -> SharedString { let project = self.project.read(cx); if let Some(project_path) = project.find_project_path(path, cx) && let Some(short) = project.short_full_path_for_project_path(&project_path, cx) { return short.into(); } let display = path.to_string_lossy(); if display.is_empty() { default.into() } else { display.into_owned().into() } } pub(crate) fn replay_output( &self, output: EditSessionOutput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Result<()> { match output { EditSessionOutput::Success { input_path, old_text, new_text, .. } => { event_stream.update_diff(cx.new(|cx| { Diff::finalized( input_path.to_string_lossy().into_owned(), Some(old_text.to_string()), new_text, self.language_registry.clone(), cx, ) })); Ok(()) } EditSessionOutput::Error { .. } => Ok(()), } } } pub(crate) enum EditSessionResult { Completed(EditSession), Failed { error: String, session: Option<EditSession>, }, } pub(crate) async fn run_session( result: EditSessionResult, event_stream: &ToolCallEventStream, cx: &mut AsyncApp, ) -> Result<EditSessionOutput, EditSessionOutput> { match result { EditSessionResult::Completed(session) => { session .context .ensure_buffer_saved(&session.buffer, cx) .await; let (new_text, diff) = session.compute_new_text_and_diff(cx).await; Ok(EditSessionOutput::Success { old_text: session.old_text.clone(), new_text, input_path: session.input_path, diff, }) } EditSessionResult::Failed { error, session: Some(session), } => { session .context .ensure_buffer_saved(&session.buffer, cx) .await; let (_new_text, diff) = session.compute_new_text_and_diff(cx).await; if diff.is_empty() { event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![ acp::ToolCallContent::Content(acp::Content::new(error.clone())), ])); } Err(EditSessionOutput::Error { error, input_path: Some(session.input_path), diff, }) } EditSessionResult::Failed { error, session: None, } => { event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![ acp::ToolCallContent::Content(acp::Content::new(error.clone())), ])); Err(EditSessionOutput::Error { error, input_path: None, diff: String::new(), }) } } } pub(crate) fn initial_title_from_partial_path<P>( context: &EditSessionContext, raw_input: serde_json::Value, extract_path: impl FnOnce(&P) -> Option<String>, default: &str, cx: &App, ) -> SharedString where P: DeserializeOwned, { if let Ok(partial) = serde_json::from_value::<P>(raw_input) && let Some(raw_path) = extract_path(&partial) { let trimmed = raw_path.trim(); if !trimmed.is_empty() { return context.initial_title_from_path(std::path::Path::new(trimmed), default, cx); } } default.into() } pub(crate) struct EditSession { abs_path: PathBuf, pub(crate) input_path: PathBuf, pub(crate) buffer: Entity<Buffer>, pub(crate) old_text: Arc<String>, diff: Entity<Diff>, parser: StreamingParser, pipeline: Pipeline, context: Arc<EditSessionContext>, _finalize_diff_guard: Deferred<Box<dyn FnOnce()>>, } /// The destination of an edit session, identified by its absolute path on /// disk. `project_path` is `Some` for files that live inside one of the /// project's worktrees (i.e. that the standard project-path machinery can /// resolve), and `None` for global skill files reached through the /// `~/.agents/skills` allowlist. struct EditSessionTarget { abs_path: PathBuf, project_path: Option<ProjectPath>, } enum Pipeline { Write(WritePipeline), Edit(EditPipeline), } struct WritePipeline { content_written: bool, } struct EditPipeline { current_edit: Option<EditPipelineEntry>, file_changed_since_last_read: bool, } enum EditPipelineEntry { ResolvingOldText { matcher: StreamingFuzzyMatcher, }, StreamingNewText { streaming_diff: StreamingDiff, edit_cursor: usize, reindenter: Reindenter, original_snapshot: text::BufferSnapshot, }, } impl Pipeline { fn new(mode: EditSessionMode, file_changed_since_last_read: bool) -> Self { match mode { EditSessionMode::Write => Self::Write(WritePipeline { content_written: false, }), EditSessionMode::Edit => Self::Edit(EditPipeline { current_edit: None, file_changed_since_last_read, }), } } } impl WritePipeline { fn process_event( &mut self, event: &WriteEvent, buffer: &Entity<Buffer>, context: &EditSessionContext, cx: &mut AsyncApp, ) { let WriteEvent::ContentChunk { chunk } = event; let (buffer_id, buffer_len) = buffer.read_with(cx, |buffer, _cx| (buffer.remote_id(), buffer.len())); let edit_range = if self.content_written { buffer_len..buffer_len } else { 0..buffer_len }; agent_edit_buffer( buffer, [(edit_range, chunk.as_str())], &context.action_log, cx, ); cx.update(|cx| { context.set_agent_location( buffer.downgrade(), text::Anchor::max_for_buffer(buffer_id), cx, ); }); self.content_written = true; } } impl EditPipeline { fn ensure_resolving_old_text(&mut self, buffer: &Entity<Buffer>, cx: &mut AsyncApp) { if self.current_edit.is_none() { let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot()); self.current_edit = Some(EditPipelineEntry::ResolvingOldText { matcher: StreamingFuzzyMatcher::new(snapshot), }); } } fn process_event( &mut self, event: &EditEvent, buffer: &Entity<Buffer>, diff: &Entity<Diff>, abs_path: &PathBuf, context: &EditSessionContext, event_stream: &ToolCallEventStream, cx: &mut AsyncApp, ) -> Result<(), String> { match event { EditEvent::OldTextChunk { chunk, done: false, .. } => { log::debug!("old_text_chunk: done=false, chunk='{}'", chunk); self.ensure_resolving_old_text(buffer, cx); if let Some(EditPipelineEntry::ResolvingOldText { matcher }) = &mut self.current_edit && !chunk.is_empty() { if let Some(match_range) = matcher.push(chunk, None) { let anchor_range = buffer.read_with(cx, |buffer, _cx| { buffer.anchor_range_outside(match_range.clone()) }); diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx)); cx.update(|cx| { let position = buffer.read(cx).anchor_before(match_range.end); context.set_agent_location(buffer.downgrade(), position, cx); }); } } } EditEvent::OldTextChunk { edit_index, chunk, done: true, } => { log::debug!("old_text_chunk: done=true, chunk='{}'", chunk); self.ensure_resolving_old_text(buffer, cx);