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_show_indent_guides(false, cx);
308            editor.set_read_only(true);
309            editor.set_show_breakpoints(false, cx);
310            editor.set_show_code_actions(false, cx);
311            editor.set_show_git_diff_gutter(false, cx);
312            editor.set_expand_all_diff_hunks(cx);
313            editor
314        });
315        Self {
316            editor_unique_id: editor.entity_id(),
317            path,
318            project,
319            editor,
320            multibuffer,
321            diff_task: None,
322            preview_expanded: true,
323            error_expanded: false,
324            full_height_expanded: false,
325            total_lines: None,
326        }
327    }
328
329    pub fn has_diff(&self) -> bool {
330        self.total_lines.is_some()
331    }
332
333    pub fn set_diff(
334        &mut self,
335        path: Arc<Path>,
336        old_text: String,
337        new_text: String,
338        cx: &mut Context<Self>,
339    ) {
340        let language_registry = self.project.read(cx).languages().clone();
341        self.diff_task = Some(cx.spawn(async move |this, cx| {
342            let buffer = build_buffer(new_text, path.clone(), &language_registry, cx).await?;
343            let buffer_diff = build_buffer_diff(old_text, &buffer, &language_registry, cx).await?;
344
345            this.update(cx, |this, cx| {
346                this.total_lines = this.multibuffer.update(cx, |multibuffer, cx| {
347                    let snapshot = buffer.read(cx).snapshot();
348                    let diff = buffer_diff.read(cx);
349                    let diff_hunk_ranges = diff
350                        .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
351                        .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
352                        .collect::<Vec<_>>();
353                    multibuffer.clear(cx);
354                    let (_, is_newly_added) = multibuffer.set_excerpts_for_path(
355                        PathKey::for_buffer(&buffer, cx),
356                        buffer,
357                        diff_hunk_ranges,
358                        editor::DEFAULT_MULTIBUFFER_CONTEXT,
359                        cx,
360                    );
361                    debug_assert!(is_newly_added);
362                    multibuffer.add_diff(buffer_diff, cx);
363                    let end = multibuffer.len(cx);
364                    Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
365                });
366
367                cx.notify();
368            })
369        }));
370    }
371}
372
373impl ToolCard for EditFileToolCard {
374    fn render(
375        &mut self,
376        status: &ToolUseStatus,
377        window: &mut Window,
378        workspace: WeakEntity<Workspace>,
379        cx: &mut Context<Self>,
380    ) -> impl IntoElement {
381        let (failed, error_message) = match status {
382            ToolUseStatus::Error(err) => (true, Some(err.to_string())),
383            _ => (false, None),
384        };
385
386        let path_label_button = h_flex()
387            .id(("edit-tool-path-label-button", self.editor_unique_id))
388            .w_full()
389            .max_w_full()
390            .px_1()
391            .gap_0p5()
392            .cursor_pointer()
393            .rounded_sm()
394            .opacity(0.8)
395            .hover(|label| {
396                label
397                    .opacity(1.)
398                    .bg(cx.theme().colors().element_hover.opacity(0.5))
399            })
400            .tooltip(Tooltip::text("Jump to File"))
401            .child(
402                h_flex()
403                    .child(
404                        Icon::new(IconName::Pencil)
405                            .size(IconSize::XSmall)
406                            .color(Color::Muted),
407                    )
408                    .child(
409                        div()
410                            .text_size(rems(0.8125))
411                            .child(self.path.display().to_string())
412                            .ml_1p5()
413                            .mr_0p5(),
414                    )
415                    .child(
416                        Icon::new(IconName::ArrowUpRight)
417                            .size(IconSize::XSmall)
418                            .color(Color::Ignored),
419                    ),
420            )
421            .on_click({
422                let path = self.path.clone();
423                let workspace = workspace.clone();
424                move |_, window, cx| {
425                    workspace
426                        .update(cx, {
427                            |workspace, cx| {
428                                let Some(project_path) =
429                                    workspace.project().read(cx).find_project_path(&path, cx)
430                                else {
431                                    return;
432                                };
433                                let open_task =
434                                    workspace.open_path(project_path, None, true, window, cx);
435                                window
436                                    .spawn(cx, async move |cx| {
437                                        let item = open_task.await?;
438                                        if let Some(active_editor) = item.downcast::<Editor>() {
439                                            active_editor
440                                                .update_in(cx, |editor, window, cx| {
441                                                    editor.go_to_singleton_buffer_point(
442                                                        language::Point::new(0, 0),
443                                                        window,
444                                                        cx,
445                                                    );
446                                                })
447                                                .log_err();
448                                        }
449                                        anyhow::Ok(())
450                                    })
451                                    .detach_and_log_err(cx);
452                            }
453                        })
454                        .ok();
455                }
456            })
457            .into_any_element();
458
459        let codeblock_header_bg = cx
460            .theme()
461            .colors()
462            .element_background
463            .blend(cx.theme().colors().editor_foreground.opacity(0.025));
464
465        let codeblock_header = h_flex()
466            .flex_none()
467            .p_1()
468            .gap_1()
469            .justify_between()
470            .rounded_t_md()
471            .when(!failed, |header| header.bg(codeblock_header_bg))
472            .child(path_label_button)
473            .when(failed, |header| {
474                header.child(
475                    h_flex()
476                        .gap_1()
477                        .child(
478                            Icon::new(IconName::Close)
479                                .size(IconSize::Small)
480                                .color(Color::Error),
481                        )
482                        .child(
483                            Disclosure::new(
484                                ("edit-file-error-disclosure", self.editor_unique_id),
485                                self.error_expanded,
486                            )
487                            .opened_icon(IconName::ChevronUp)
488                            .closed_icon(IconName::ChevronDown)
489                            .on_click(cx.listener(
490                                move |this, _event, _window, _cx| {
491                                    this.error_expanded = !this.error_expanded;
492                                },
493                            )),
494                        ),
495                )
496            })
497            .when(!failed && self.has_diff(), |header| {
498                header.child(
499                    Disclosure::new(
500                        ("edit-file-disclosure", self.editor_unique_id),
501                        self.preview_expanded,
502                    )
503                    .opened_icon(IconName::ChevronUp)
504                    .closed_icon(IconName::ChevronDown)
505                    .on_click(cx.listener(
506                        move |this, _event, _window, _cx| {
507                            this.preview_expanded = !this.preview_expanded;
508                        },
509                    )),
510                )
511            });
512
513        let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| {
514            let line_height = editor
515                .style()
516                .map(|style| style.text.line_height_in_pixels(window.rem_size()))
517                .unwrap_or_default();
518
519            let element = editor.render(window, cx);
520            (element.into_any_element(), line_height)
521        });
522
523        let (full_height_icon, full_height_tooltip_label) = if self.full_height_expanded {
524            (IconName::ChevronUp, "Collapse Code Block")
525        } else {
526            (IconName::ChevronDown, "Expand Code Block")
527        };
528
529        let gradient_overlay = div()
530            .absolute()
531            .bottom_0()
532            .left_0()
533            .w_full()
534            .h_2_5()
535            .rounded_b_lg()
536            .bg(gpui::linear_gradient(
537                0.,
538                gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
539                gpui::linear_color_stop(cx.theme().colors().editor_background.opacity(0.), 1.),
540            ));
541
542        let border_color = cx.theme().colors().border.opacity(0.6);
543
544        const DEFAULT_COLLAPSED_LINES: u32 = 10;
545        let is_collapsible = self.total_lines.unwrap_or(0) > DEFAULT_COLLAPSED_LINES;
546
547        let waiting_for_diff = {
548            let styles = [
549                ("w_4_5", (0.1, 0.85), 2000),
550                ("w_1_4", (0.2, 0.75), 2200),
551                ("w_2_4", (0.15, 0.64), 1900),
552                ("w_3_5", (0.25, 0.72), 2300),
553                ("w_2_5", (0.3, 0.56), 1800),
554            ];
555
556            let mut container = v_flex()
557                .p_3()
558                .gap_1p5()
559                .border_t_1()
560                .border_color(border_color)
561                .bg(cx.theme().colors().editor_background);
562
563            for (width_method, pulse_range, duration_ms) in styles.iter() {
564                let (min_opacity, max_opacity) = *pulse_range;
565                let placeholder = match *width_method {
566                    "w_4_5" => div().w_3_4(),
567                    "w_1_4" => div().w_1_4(),
568                    "w_2_4" => div().w_2_4(),
569                    "w_3_5" => div().w_3_5(),
570                    "w_2_5" => div().w_2_5(),
571                    _ => div().w_1_2(),
572                }
573                .id("loading_div")
574                .h_2()
575                .rounded_full()
576                .bg(cx.theme().colors().element_active)
577                .with_animation(
578                    "loading_pulsate",
579                    Animation::new(Duration::from_millis(*duration_ms))
580                        .repeat()
581                        .with_easing(pulsating_between(min_opacity, max_opacity)),
582                    |label, delta| label.opacity(delta),
583                );
584
585                container = container.child(placeholder);
586            }
587
588            container
589        };
590
591        v_flex()
592            .mb_2()
593            .border_1()
594            .when(failed, |card| card.border_dashed())
595            .border_color(border_color)
596            .rounded_lg()
597            .overflow_hidden()
598            .child(codeblock_header)
599            .when(failed && self.error_expanded, |card| {
600                card.child(
601                    v_flex()
602                        .p_2()
603                        .gap_1()
604                        .border_t_1()
605                        .border_dashed()
606                        .border_color(border_color)
607                        .bg(cx.theme().colors().editor_background)
608                        .rounded_b_md()
609                        .child(
610                            Label::new("Error")
611                                .size(LabelSize::XSmall)
612                                .color(Color::Error),
613                        )
614                        .child(
615                            div()
616                                .rounded_md()
617                                .text_ui_sm(cx)
618                                .bg(cx.theme().colors().editor_background)
619                                .children(
620                                    error_message
621                                        .map(|error| div().child(error).into_any_element()),
622                                ),
623                        ),
624                )
625            })
626            .when(!self.has_diff() && !failed, |card| {
627                card.child(waiting_for_diff)
628            })
629            .when(
630                !failed && self.preview_expanded && self.has_diff(),
631                |card| {
632                    card.child(
633                        v_flex()
634                            .relative()
635                            .h_full()
636                            .when(!self.full_height_expanded, |editor_container| {
637                                editor_container
638                                    .max_h(DEFAULT_COLLAPSED_LINES as f32 * editor_line_height)
639                            })
640                            .overflow_hidden()
641                            .border_t_1()
642                            .border_color(border_color)
643                            .bg(cx.theme().colors().editor_background)
644                            .child(editor)
645                            .when(
646                                !self.full_height_expanded && is_collapsible,
647                                |editor_container| editor_container.child(gradient_overlay),
648                            ),
649                    )
650                    .when(is_collapsible, |editor_container| {
651                        editor_container.child(
652                            h_flex()
653                                .id(("expand-button", self.editor_unique_id))
654                                .flex_none()
655                                .cursor_pointer()
656                                .h_5()
657                                .justify_center()
658                                .rounded_b_md()
659                                .border_t_1()
660                                .border_color(border_color)
661                                .bg(cx.theme().colors().editor_background)
662                                .hover(|style| {
663                                    style.bg(cx.theme().colors().element_hover.opacity(0.1))
664                                })
665                                .child(
666                                    Icon::new(full_height_icon)
667                                        .size(IconSize::Small)
668                                        .color(Color::Muted),
669                                )
670                                .tooltip(Tooltip::text(full_height_tooltip_label))
671                                .on_click(cx.listener(move |this, _event, _window, _cx| {
672                                    this.full_height_expanded = !this.full_height_expanded;
673                                })),
674                        )
675                    })
676                },
677            )
678    }
679}
680
681async fn build_buffer(
682    mut text: String,
683    path: Arc<Path>,
684    language_registry: &Arc<language::LanguageRegistry>,
685    cx: &mut AsyncApp,
686) -> Result<Entity<Buffer>> {
687    let line_ending = LineEnding::detect(&text);
688    LineEnding::normalize(&mut text);
689    let text = Rope::from(text);
690    let language = cx
691        .update(|_cx| language_registry.language_for_file_path(&path))?
692        .await
693        .ok();
694    let buffer = cx.new(|cx| {
695        let buffer = TextBuffer::new_normalized(
696            0,
697            cx.entity_id().as_non_zero_u64().into(),
698            line_ending,
699            text,
700        );
701        let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
702        buffer.set_language(language, cx);
703        buffer
704    })?;
705    Ok(buffer)
706}
707
708async fn build_buffer_diff(
709    mut old_text: String,
710    buffer: &Entity<Buffer>,
711    language_registry: &Arc<LanguageRegistry>,
712    cx: &mut AsyncApp,
713) -> Result<Entity<BufferDiff>> {
714    LineEnding::normalize(&mut old_text);
715
716    let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
717
718    let base_buffer = cx
719        .update(|cx| {
720            Buffer::build_snapshot(
721                old_text.clone().into(),
722                buffer.language().cloned(),
723                Some(language_registry.clone()),
724                cx,
725            )
726        })?
727        .await;
728
729    let diff_snapshot = cx
730        .update(|cx| {
731            BufferDiffSnapshot::new_with_base_buffer(
732                buffer.text.clone(),
733                Some(old_text.into()),
734                base_buffer,
735                cx,
736            )
737        })?
738        .await;
739
740    cx.new(|cx| {
741        let mut diff = BufferDiff::new(&buffer.text, cx);
742        diff.set_snapshot(diff_snapshot, &buffer.text, cx);
743        diff
744    })
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750    use serde_json::json;
751
752    #[test]
753    fn still_streaming_ui_text_with_path() {
754        let input = json!({
755            "path": "src/main.rs",
756            "display_description": "",
757            "old_string": "old code",
758            "new_string": "new code"
759        });
760
761        assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs");
762    }
763
764    #[test]
765    fn still_streaming_ui_text_with_description() {
766        let input = json!({
767            "path": "",
768            "display_description": "Fix error handling",
769            "old_string": "old code",
770            "new_string": "new code"
771        });
772
773        assert_eq!(
774            EditFileTool.still_streaming_ui_text(&input),
775            "Fix error handling",
776        );
777    }
778
779    #[test]
780    fn still_streaming_ui_text_with_path_and_description() {
781        let input = json!({
782            "path": "src/main.rs",
783            "display_description": "Fix error handling",
784            "old_string": "old code",
785            "new_string": "new code"
786        });
787
788        assert_eq!(
789            EditFileTool.still_streaming_ui_text(&input),
790            "Fix error handling",
791        );
792    }
793
794    #[test]
795    fn still_streaming_ui_text_no_path_or_description() {
796        let input = json!({
797            "path": "",
798            "display_description": "",
799            "old_string": "old code",
800            "new_string": "new code"
801        });
802
803        assert_eq!(
804            EditFileTool.still_streaming_ui_text(&input),
805            DEFAULT_UI_TEXT,
806        );
807    }
808
809    #[test]
810    fn still_streaming_ui_text_with_null() {
811        let input = serde_json::Value::Null;
812
813        assert_eq!(
814            EditFileTool.still_streaming_ui_text(&input),
815            DEFAULT_UI_TEXT,
816        );
817    }
818}