edit_file_tool.rs

  1use crate::{
  2    replace::{replace_exact, replace_with_flexible_indent},
  3    schema::json_schema_for,
  4};
  5use anyhow::{Context as _, Result, anyhow};
  6use assistant_tool::{ActionLog, AnyToolCard, Tool, ToolCard, ToolResult, ToolUseStatus};
  7use buffer_diff::{BufferDiff, BufferDiffSnapshot};
  8use editor::{Editor, EditorMode, MultiBuffer, PathKey};
  9use gpui::{
 10    Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Context, Entity, EntityId,
 11    Task, TextStyleRefinement, WeakEntity, pulsating_between,
 12};
 13use language::{
 14    Anchor, Buffer, Capability, LanguageRegistry, LineEnding, OffsetRangeExt, Rope, TextBuffer,
 15    language_settings::SoftWrap,
 16};
 17use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat};
 18use project::{AgentLocation, Project};
 19use schemars::JsonSchema;
 20use serde::{Deserialize, Serialize};
 21use settings::Settings;
 22use std::{
 23    path::{Path, PathBuf},
 24    sync::Arc,
 25    time::Duration,
 26};
 27use theme::ThemeSettings;
 28use ui::{Disclosure, Tooltip, Window, prelude::*};
 29use util::ResultExt;
 30use workspace::Workspace;
 31
 32pub struct EditFileTool;
 33
 34#[derive(Debug, Serialize, Deserialize, JsonSchema)]
 35pub struct EditFileToolInput {
 36    /// A user-friendly markdown description of what's being replaced. This will be shown in the UI.
 37    ///
 38    /// <example>Fix API endpoint URLs</example>
 39    /// <example>Update copyright year in `page_footer`</example>
 40    ///
 41    /// Make sure to include this field before all the others in the input object
 42    /// so that we can display it immediately.
 43    pub display_description: String,
 44
 45    /// The full path of the file to modify in the project.
 46    ///
 47    /// WARNING: When specifying which file path need changing, you MUST
 48    /// start each path with one of the project's root directories.
 49    ///
 50    /// The following examples assume we have two root directories in the project:
 51    /// - backend
 52    /// - frontend
 53    ///
 54    /// <example>
 55    /// `backend/src/main.rs`
 56    ///
 57    /// Notice how the file path starts with root-1. Without that, the path
 58    /// would be ambiguous and the call would fail!
 59    /// </example>
 60    ///
 61    /// <example>
 62    /// `frontend/db.js`
 63    /// </example>
 64    pub path: PathBuf,
 65
 66    /// The text to replace.
 67    pub old_string: String,
 68
 69    /// The text to replace it with.
 70    pub new_string: String,
 71}
 72
 73#[derive(Debug, Serialize, Deserialize, JsonSchema)]
 74struct PartialInput {
 75    #[serde(default)]
 76    path: String,
 77    #[serde(default)]
 78    display_description: String,
 79    #[serde(default)]
 80    old_string: String,
 81    #[serde(default)]
 82    new_string: String,
 83}
 84
 85const DEFAULT_UI_TEXT: &str = "Editing file";
 86
 87impl Tool for EditFileTool {
 88    fn name(&self) -> String {
 89        "edit_file".into()
 90    }
 91
 92    fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
 93        false
 94    }
 95
 96    fn description(&self) -> String {
 97        include_str!("edit_file_tool/description.md").to_string()
 98    }
 99
100    fn icon(&self) -> IconName {
101        IconName::Pencil
102    }
103
104    fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
105        json_schema_for::<EditFileToolInput>(format)
106    }
107
108    fn ui_text(&self, input: &serde_json::Value) -> String {
109        match serde_json::from_value::<EditFileToolInput>(input.clone()) {
110            Ok(input) => input.display_description,
111            Err(_) => "Editing file".to_string(),
112        }
113    }
114
115    fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String {
116        if let Some(input) = serde_json::from_value::<PartialInput>(input.clone()).ok() {
117            let description = input.display_description.trim();
118            if !description.is_empty() {
119                return description.to_string();
120            }
121
122            let path = input.path.trim();
123            if !path.is_empty() {
124                return path.to_string();
125            }
126        }
127
128        DEFAULT_UI_TEXT.to_string()
129    }
130
131    fn run(
132        self: Arc<Self>,
133        input: serde_json::Value,
134        _messages: &[LanguageModelRequestMessage],
135        project: Entity<Project>,
136        action_log: Entity<ActionLog>,
137        window: Option<AnyWindowHandle>,
138        cx: &mut App,
139    ) -> ToolResult {
140        let input = match serde_json::from_value::<EditFileToolInput>(input) {
141            Ok(input) => input,
142            Err(err) => return Task::ready(Err(anyhow!(err))).into(),
143        };
144
145        let card = window.and_then(|window| {
146            window
147                .update(cx, |_, window, cx| {
148                    cx.new(|cx| {
149                        EditFileToolCard::new(input.path.clone(), project.clone(), window, cx)
150                    })
151                })
152                .ok()
153        });
154
155        let card_clone = card.clone();
156        let task = cx.spawn(async move |cx: &mut AsyncApp| {
157            let project_path = project.read_with(cx, |project, cx| {
158                project
159                    .find_project_path(&input.path, cx)
160                    .context("Path not found in project")
161            })??;
162
163            let buffer = project
164                .update(cx, |project, cx| {
165                    project.open_buffer(project_path.clone(), cx)
166                })?
167                .await?;
168
169            // Set the agent's location to the top of the file
170            project
171                .update(cx, |project, cx| {
172                    project.set_agent_location(
173                        Some(AgentLocation {
174                            buffer: buffer.downgrade(),
175                            position: language::Anchor::MIN,
176                        }),
177                        cx,
178                    );
179                })
180                .ok();
181
182            let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
183
184            if input.old_string.is_empty() {
185                return Err(anyhow!(
186                    "`old_string` can't be empty, use another tool if you want to create a file."
187                ));
188            }
189
190            if input.old_string == input.new_string {
191                return Err(anyhow!(
192                    "The `old_string` and `new_string` are identical, so no changes would be made."
193                ));
194            }
195
196            let result = cx
197                .background_spawn(async move {
198                    // Try to match exactly
199                    let diff = replace_exact(&input.old_string, &input.new_string, &snapshot)
200                        .await
201                        // If that fails, try being flexible about indentation
202                        .or_else(|| {
203                            replace_with_flexible_indent(
204                                &input.old_string,
205                                &input.new_string,
206                                &snapshot,
207                            )
208                        })?;
209
210                    if diff.edits.is_empty() {
211                        return None;
212                    }
213
214                    let old_text = snapshot.text();
215
216                    Some((old_text, diff))
217                })
218                .await;
219
220            let Some((old_text, diff)) = result else {
221                let err = buffer.read_with(cx, |buffer, _cx| {
222                    let file_exists = buffer
223                        .file()
224                        .map_or(false, |file| file.disk_state().exists());
225
226                    if !file_exists {
227                        anyhow!("{} does not exist", input.path.display())
228                    } else if buffer.is_empty() {
229                        anyhow!(
230                            "{} is empty, so the provided `old_string` wasn't found.",
231                            input.path.display()
232                        )
233                    } else {
234                        anyhow!("Failed to match the provided `old_string`")
235                    }
236                })?;
237
238                return Err(err);
239            };
240
241            let snapshot = cx.update(|cx| {
242                action_log.update(cx, |log, cx| log.track_buffer(buffer.clone(), cx));
243
244                let base_version = diff.base_version.clone();
245                let snapshot = buffer.update(cx, |buffer, cx| {
246                    buffer.finalize_last_transaction();
247                    buffer.apply_diff(diff, cx);
248                    buffer.finalize_last_transaction();
249                    buffer.snapshot()
250                });
251                action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
252
253                // Set the agent's location to the position of the first edit
254                if let Some(first_edit) = snapshot.edits_since::<usize>(&base_version).next() {
255                    let position = snapshot.anchor_before(first_edit.new.start);
256                    project.update(cx, |project, cx| {
257                        project.set_agent_location(
258                            Some(AgentLocation {
259                                buffer: buffer.downgrade(),
260                                position,
261                            }),
262                            cx,
263                        );
264                    })
265                }
266
267                snapshot
268            })?;
269
270            project
271                .update(cx, |project, cx| project.save_buffer(buffer, cx))?
272                .await?;
273
274            let new_text = snapshot.text();
275            let diff_str = cx
276                .background_spawn({
277                    let old_text = old_text.clone();
278                    let new_text = new_text.clone();
279                    async move { language::unified_diff(&old_text, &new_text) }
280                })
281                .await;
282
283            if let Some(card) = card_clone {
284                card.update(cx, |card, cx| {
285                    card.set_diff(project_path.path.clone(), old_text, new_text, cx);
286                })
287                .log_err();
288            }
289
290            Ok(format!(
291                "Edited {}:\n\n```diff\n{}\n```",
292                input.path.display(),
293                diff_str
294            ))
295        });
296
297        ToolResult {
298            output: task,
299            card: card.map(AnyToolCard::from),
300        }
301    }
302}
303
304pub struct EditFileToolCard {
305    path: PathBuf,
306    editor: Entity<Editor>,
307    multibuffer: Entity<MultiBuffer>,
308    project: Entity<Project>,
309    diff_task: Option<Task<Result<()>>>,
310    preview_expanded: bool,
311    error_expanded: bool,
312    full_height_expanded: bool,
313    total_lines: Option<u32>,
314    editor_unique_id: EntityId,
315}
316
317impl EditFileToolCard {
318    pub fn new(path: PathBuf, project: Entity<Project>, window: &mut Window, cx: &mut App) -> Self {
319        let multibuffer = cx.new(|_| MultiBuffer::without_headers(Capability::ReadOnly));
320        let editor = cx.new(|cx| {
321            let mut editor = Editor::new(
322                EditorMode::Full {
323                    scale_ui_elements_with_buffer_font_size: false,
324                    show_active_line_background: false,
325                    sized_by_content: true,
326                },
327                multibuffer.clone(),
328                Some(project.clone()),
329                window,
330                cx,
331            );
332            editor.set_show_gutter(false, cx);
333            editor.disable_inline_diagnostics();
334            editor.disable_expand_excerpt_buttons(cx);
335            editor.set_soft_wrap_mode(SoftWrap::None, cx);
336            editor.scroll_manager.set_forbid_vertical_scroll(true);
337            editor.set_show_scrollbars(false, cx);
338            editor.set_show_indent_guides(false, cx);
339            editor.set_read_only(true);
340            editor.set_show_breakpoints(false, cx);
341            editor.set_show_code_actions(false, cx);
342            editor.set_show_git_diff_gutter(false, cx);
343            editor.set_expand_all_diff_hunks(cx);
344            editor
345        });
346        Self {
347            editor_unique_id: editor.entity_id(),
348            path,
349            project,
350            editor,
351            multibuffer,
352            diff_task: None,
353            preview_expanded: true,
354            error_expanded: false,
355            full_height_expanded: false,
356            total_lines: None,
357        }
358    }
359
360    pub fn has_diff(&self) -> bool {
361        self.total_lines.is_some()
362    }
363
364    pub fn set_diff(
365        &mut self,
366        path: Arc<Path>,
367        old_text: String,
368        new_text: String,
369        cx: &mut Context<Self>,
370    ) {
371        let language_registry = self.project.read(cx).languages().clone();
372        self.diff_task = Some(cx.spawn(async move |this, cx| {
373            let buffer = build_buffer(new_text, path.clone(), &language_registry, cx).await?;
374            let buffer_diff = build_buffer_diff(old_text, &buffer, &language_registry, cx).await?;
375
376            this.update(cx, |this, cx| {
377                this.total_lines = this.multibuffer.update(cx, |multibuffer, cx| {
378                    let snapshot = buffer.read(cx).snapshot();
379                    let diff = buffer_diff.read(cx);
380                    let diff_hunk_ranges = diff
381                        .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
382                        .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
383                        .collect::<Vec<_>>();
384                    multibuffer.clear(cx);
385                    let (_, is_newly_added) = multibuffer.set_excerpts_for_path(
386                        PathKey::for_buffer(&buffer, cx),
387                        buffer,
388                        diff_hunk_ranges,
389                        editor::DEFAULT_MULTIBUFFER_CONTEXT,
390                        cx,
391                    );
392                    debug_assert!(is_newly_added);
393                    multibuffer.add_diff(buffer_diff, cx);
394                    let end = multibuffer.len(cx);
395                    Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
396                });
397
398                cx.notify();
399            })
400        }));
401    }
402}
403
404impl ToolCard for EditFileToolCard {
405    fn render(
406        &mut self,
407        status: &ToolUseStatus,
408        window: &mut Window,
409        workspace: WeakEntity<Workspace>,
410        cx: &mut Context<Self>,
411    ) -> impl IntoElement {
412        let (failed, error_message) = match status {
413            ToolUseStatus::Error(err) => (true, Some(err.to_string())),
414            _ => (false, None),
415        };
416
417        let path_label_button = h_flex()
418            .id(("edit-tool-path-label-button", self.editor_unique_id))
419            .w_full()
420            .max_w_full()
421            .px_1()
422            .gap_0p5()
423            .cursor_pointer()
424            .rounded_sm()
425            .opacity(0.8)
426            .hover(|label| {
427                label
428                    .opacity(1.)
429                    .bg(cx.theme().colors().element_hover.opacity(0.5))
430            })
431            .tooltip(Tooltip::text("Jump to File"))
432            .child(
433                h_flex()
434                    .child(
435                        Icon::new(IconName::Pencil)
436                            .size(IconSize::XSmall)
437                            .color(Color::Muted),
438                    )
439                    .child(
440                        div()
441                            .text_size(rems(0.8125))
442                            .child(self.path.display().to_string())
443                            .ml_1p5()
444                            .mr_0p5(),
445                    )
446                    .child(
447                        Icon::new(IconName::ArrowUpRight)
448                            .size(IconSize::XSmall)
449                            .color(Color::Ignored),
450                    ),
451            )
452            .on_click({
453                let path = self.path.clone();
454                let workspace = workspace.clone();
455                move |_, window, cx| {
456                    workspace
457                        .update(cx, {
458                            |workspace, cx| {
459                                let Some(project_path) =
460                                    workspace.project().read(cx).find_project_path(&path, cx)
461                                else {
462                                    return;
463                                };
464                                let open_task =
465                                    workspace.open_path(project_path, None, true, window, cx);
466                                window
467                                    .spawn(cx, async move |cx| {
468                                        let item = open_task.await?;
469                                        if let Some(active_editor) = item.downcast::<Editor>() {
470                                            active_editor
471                                                .update_in(cx, |editor, window, cx| {
472                                                    editor.go_to_singleton_buffer_point(
473                                                        language::Point::new(0, 0),
474                                                        window,
475                                                        cx,
476                                                    );
477                                                })
478                                                .log_err();
479                                        }
480                                        anyhow::Ok(())
481                                    })
482                                    .detach_and_log_err(cx);
483                            }
484                        })
485                        .ok();
486                }
487            })
488            .into_any_element();
489
490        let codeblock_header_bg = cx
491            .theme()
492            .colors()
493            .element_background
494            .blend(cx.theme().colors().editor_foreground.opacity(0.025));
495
496        let codeblock_header = h_flex()
497            .flex_none()
498            .p_1()
499            .gap_1()
500            .justify_between()
501            .rounded_t_md()
502            .when(!failed, |header| header.bg(codeblock_header_bg))
503            .child(path_label_button)
504            .when(failed, |header| {
505                header.child(
506                    h_flex()
507                        .gap_1()
508                        .child(
509                            Icon::new(IconName::Close)
510                                .size(IconSize::Small)
511                                .color(Color::Error),
512                        )
513                        .child(
514                            Disclosure::new(
515                                ("edit-file-error-disclosure", self.editor_unique_id),
516                                self.error_expanded,
517                            )
518                            .opened_icon(IconName::ChevronUp)
519                            .closed_icon(IconName::ChevronDown)
520                            .on_click(cx.listener(
521                                move |this, _event, _window, _cx| {
522                                    this.error_expanded = !this.error_expanded;
523                                },
524                            )),
525                        ),
526                )
527            })
528            .when(!failed && self.has_diff(), |header| {
529                header.child(
530                    Disclosure::new(
531                        ("edit-file-disclosure", self.editor_unique_id),
532                        self.preview_expanded,
533                    )
534                    .opened_icon(IconName::ChevronUp)
535                    .closed_icon(IconName::ChevronDown)
536                    .on_click(cx.listener(
537                        move |this, _event, _window, _cx| {
538                            this.preview_expanded = !this.preview_expanded;
539                        },
540                    )),
541                )
542            });
543
544        let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| {
545            let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
546
547            editor.set_text_style_refinement(TextStyleRefinement {
548                font_size: Some(ui_font_size.into()),
549                ..TextStyleRefinement::default()
550            });
551
552            let line_height = editor
553                .style()
554                .map(|style| style.text.line_height_in_pixels(window.rem_size()))
555                .unwrap_or_default();
556
557            let element = editor.render(window, cx);
558            (element.into_any_element(), line_height)
559        });
560
561        let (full_height_icon, full_height_tooltip_label) = if self.full_height_expanded {
562            (IconName::ChevronUp, "Collapse Code Block")
563        } else {
564            (IconName::ChevronDown, "Expand Code Block")
565        };
566
567        let gradient_overlay = div()
568            .absolute()
569            .bottom_0()
570            .left_0()
571            .w_full()
572            .h_2_5()
573            .rounded_b_lg()
574            .bg(gpui::linear_gradient(
575                0.,
576                gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
577                gpui::linear_color_stop(cx.theme().colors().editor_background.opacity(0.), 1.),
578            ));
579
580        let border_color = cx.theme().colors().border.opacity(0.6);
581
582        const DEFAULT_COLLAPSED_LINES: u32 = 10;
583        let is_collapsible = self.total_lines.unwrap_or(0) > DEFAULT_COLLAPSED_LINES;
584
585        let waiting_for_diff = {
586            let styles = [
587                ("w_4_5", (0.1, 0.85), 2000),
588                ("w_1_4", (0.2, 0.75), 2200),
589                ("w_2_4", (0.15, 0.64), 1900),
590                ("w_3_5", (0.25, 0.72), 2300),
591                ("w_2_5", (0.3, 0.56), 1800),
592            ];
593
594            let mut container = v_flex()
595                .p_3()
596                .gap_1p5()
597                .border_t_1()
598                .border_color(border_color)
599                .bg(cx.theme().colors().editor_background);
600
601            for (width_method, pulse_range, duration_ms) in styles.iter() {
602                let (min_opacity, max_opacity) = *pulse_range;
603                let placeholder = match *width_method {
604                    "w_4_5" => div().w_3_4(),
605                    "w_1_4" => div().w_1_4(),
606                    "w_2_4" => div().w_2_4(),
607                    "w_3_5" => div().w_3_5(),
608                    "w_2_5" => div().w_2_5(),
609                    _ => div().w_1_2(),
610                }
611                .id("loading_div")
612                .h_2()
613                .rounded_full()
614                .bg(cx.theme().colors().element_active)
615                .with_animation(
616                    "loading_pulsate",
617                    Animation::new(Duration::from_millis(*duration_ms))
618                        .repeat()
619                        .with_easing(pulsating_between(min_opacity, max_opacity)),
620                    |label, delta| label.opacity(delta),
621                );
622
623                container = container.child(placeholder);
624            }
625
626            container
627        };
628
629        v_flex()
630            .mb_2()
631            .border_1()
632            .when(failed, |card| card.border_dashed())
633            .border_color(border_color)
634            .rounded_lg()
635            .overflow_hidden()
636            .child(codeblock_header)
637            .when(failed && self.error_expanded, |card| {
638                card.child(
639                    v_flex()
640                        .p_2()
641                        .gap_1()
642                        .border_t_1()
643                        .border_dashed()
644                        .border_color(border_color)
645                        .bg(cx.theme().colors().editor_background)
646                        .rounded_b_md()
647                        .child(
648                            Label::new("Error")
649                                .size(LabelSize::XSmall)
650                                .color(Color::Error),
651                        )
652                        .child(
653                            div()
654                                .rounded_md()
655                                .text_ui_sm(cx)
656                                .bg(cx.theme().colors().editor_background)
657                                .children(
658                                    error_message
659                                        .map(|error| div().child(error).into_any_element()),
660                                ),
661                        ),
662                )
663            })
664            .when(!self.has_diff() && !failed, |card| {
665                card.child(waiting_for_diff)
666            })
667            .when(
668                !failed && self.preview_expanded && self.has_diff(),
669                |card| {
670                    card.child(
671                        v_flex()
672                            .relative()
673                            .h_full()
674                            .when(!self.full_height_expanded, |editor_container| {
675                                editor_container
676                                    .max_h(DEFAULT_COLLAPSED_LINES as f32 * editor_line_height)
677                            })
678                            .overflow_hidden()
679                            .border_t_1()
680                            .border_color(border_color)
681                            .bg(cx.theme().colors().editor_background)
682                            .child(editor)
683                            .when(
684                                !self.full_height_expanded && is_collapsible,
685                                |editor_container| editor_container.child(gradient_overlay),
686                            ),
687                    )
688                    .when(is_collapsible, |editor_container| {
689                        editor_container.child(
690                            h_flex()
691                                .id(("expand-button", self.editor_unique_id))
692                                .flex_none()
693                                .cursor_pointer()
694                                .h_5()
695                                .justify_center()
696                                .rounded_b_md()
697                                .border_t_1()
698                                .border_color(border_color)
699                                .bg(cx.theme().colors().editor_background)
700                                .hover(|style| {
701                                    style.bg(cx.theme().colors().element_hover.opacity(0.1))
702                                })
703                                .child(
704                                    Icon::new(full_height_icon)
705                                        .size(IconSize::Small)
706                                        .color(Color::Muted),
707                                )
708                                .tooltip(Tooltip::text(full_height_tooltip_label))
709                                .on_click(cx.listener(move |this, _event, _window, _cx| {
710                                    this.full_height_expanded = !this.full_height_expanded;
711                                })),
712                        )
713                    })
714                },
715            )
716    }
717}
718
719async fn build_buffer(
720    mut text: String,
721    path: Arc<Path>,
722    language_registry: &Arc<language::LanguageRegistry>,
723    cx: &mut AsyncApp,
724) -> Result<Entity<Buffer>> {
725    let line_ending = LineEnding::detect(&text);
726    LineEnding::normalize(&mut text);
727    let text = Rope::from(text);
728    let language = cx
729        .update(|_cx| language_registry.language_for_file_path(&path))?
730        .await
731        .ok();
732    let buffer = cx.new(|cx| {
733        let buffer = TextBuffer::new_normalized(
734            0,
735            cx.entity_id().as_non_zero_u64().into(),
736            line_ending,
737            text,
738        );
739        let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
740        buffer.set_language(language, cx);
741        buffer
742    })?;
743    Ok(buffer)
744}
745
746async fn build_buffer_diff(
747    mut old_text: String,
748    buffer: &Entity<Buffer>,
749    language_registry: &Arc<LanguageRegistry>,
750    cx: &mut AsyncApp,
751) -> Result<Entity<BufferDiff>> {
752    LineEnding::normalize(&mut old_text);
753
754    let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
755
756    let base_buffer = cx
757        .update(|cx| {
758            Buffer::build_snapshot(
759                old_text.clone().into(),
760                buffer.language().cloned(),
761                Some(language_registry.clone()),
762                cx,
763            )
764        })?
765        .await;
766
767    let diff_snapshot = cx
768        .update(|cx| {
769            BufferDiffSnapshot::new_with_base_buffer(
770                buffer.text.clone(),
771                Some(old_text.into()),
772                base_buffer,
773                cx,
774            )
775        })?
776        .await;
777
778    cx.new(|cx| {
779        let mut diff = BufferDiff::new(&buffer.text, cx);
780        diff.set_snapshot(diff_snapshot, &buffer.text, cx);
781        diff
782    })
783}
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788    use serde_json::json;
789
790    #[test]
791    fn still_streaming_ui_text_with_path() {
792        let input = json!({
793            "path": "src/main.rs",
794            "display_description": "",
795            "old_string": "old code",
796            "new_string": "new code"
797        });
798
799        assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs");
800    }
801
802    #[test]
803    fn still_streaming_ui_text_with_description() {
804        let input = json!({
805            "path": "",
806            "display_description": "Fix error handling",
807            "old_string": "old code",
808            "new_string": "new code"
809        });
810
811        assert_eq!(
812            EditFileTool.still_streaming_ui_text(&input),
813            "Fix error handling",
814        );
815    }
816
817    #[test]
818    fn still_streaming_ui_text_with_path_and_description() {
819        let input = json!({
820            "path": "src/main.rs",
821            "display_description": "Fix error handling",
822            "old_string": "old code",
823            "new_string": "new code"
824        });
825
826        assert_eq!(
827            EditFileTool.still_streaming_ui_text(&input),
828            "Fix error handling",
829        );
830    }
831
832    #[test]
833    fn still_streaming_ui_text_no_path_or_description() {
834        let input = json!({
835            "path": "",
836            "display_description": "",
837            "old_string": "old code",
838            "new_string": "new code"
839        });
840
841        assert_eq!(
842            EditFileTool.still_streaming_ui_text(&input),
843            DEFAULT_UI_TEXT,
844        );
845    }
846
847    #[test]
848    fn still_streaming_ui_text_with_null() {
849        let input = serde_json::Value::Null;
850
851        assert_eq!(
852            EditFileTool.still_streaming_ui_text(&input),
853            DEFAULT_UI_TEXT,
854        );
855    }
856}