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