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::{AgentLocation, 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            // Set the agent's location to the top of the file
168            project
169                .update(cx, |project, cx| {
170                    project.set_agent_location(
171                        Some(AgentLocation {
172                            buffer: buffer.downgrade(),
173                            position: language::Anchor::MIN,
174                        }),
175                        cx,
176                    );
177                })
178                .ok();
179
180            let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
181
182            if input.old_string.is_empty() {
183                return Err(anyhow!(
184                    "`old_string` can't be empty, use another tool if you want to create a file."
185                ));
186            }
187
188            if input.old_string == input.new_string {
189                return Err(anyhow!(
190                    "The `old_string` and `new_string` are identical, so no changes would be made."
191                ));
192            }
193
194            let result = cx
195                .background_spawn(async move {
196                    // Try to match exactly
197                    let diff = replace_exact(&input.old_string, &input.new_string, &snapshot)
198                        .await
199                        // If that fails, try being flexible about indentation
200                        .or_else(|| {
201                            replace_with_flexible_indent(
202                                &input.old_string,
203                                &input.new_string,
204                                &snapshot,
205                            )
206                        })?;
207
208                    if diff.edits.is_empty() {
209                        return None;
210                    }
211
212                    let old_text = snapshot.text();
213
214                    Some((old_text, diff))
215                })
216                .await;
217
218            let Some((old_text, diff)) = result else {
219                let err = buffer.read_with(cx, |buffer, _cx| {
220                    let file_exists = buffer
221                        .file()
222                        .map_or(false, |file| file.disk_state().exists());
223
224                    if !file_exists {
225                        anyhow!("{} does not exist", input.path.display())
226                    } else if buffer.is_empty() {
227                        anyhow!(
228                            "{} is empty, so the provided `old_string` wasn't found.",
229                            input.path.display()
230                        )
231                    } else {
232                        anyhow!("Failed to match the provided `old_string`")
233                    }
234                })?;
235
236                return Err(err);
237            };
238
239            let snapshot = cx.update(|cx| {
240                action_log.update(cx, |log, cx| log.track_buffer(buffer.clone(), cx));
241
242                let base_version = diff.base_version.clone();
243                let snapshot = buffer.update(cx, |buffer, cx| {
244                    buffer.finalize_last_transaction();
245                    buffer.apply_diff(diff, cx);
246                    buffer.finalize_last_transaction();
247                    buffer.snapshot()
248                });
249                action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
250
251                // Set the agent's location to the position of the first edit
252                if let Some(first_edit) = snapshot.edits_since::<usize>(&base_version).next() {
253                    let position = snapshot.anchor_before(first_edit.new.start);
254                    project.update(cx, |project, cx| {
255                        project.set_agent_location(
256                            Some(AgentLocation {
257                                buffer: buffer.downgrade(),
258                                position,
259                            }),
260                            cx,
261                        );
262                    })
263                }
264
265                snapshot
266            })?;
267
268            project
269                .update(cx, |project, cx| project.save_buffer(buffer, cx))?
270                .await?;
271
272            let new_text = snapshot.text();
273            let diff_str = cx
274                .background_spawn({
275                    let old_text = old_text.clone();
276                    let new_text = new_text.clone();
277                    async move { language::unified_diff(&old_text, &new_text) }
278                })
279                .await;
280
281            if let Some(card) = card_clone {
282                card.update(cx, |card, cx| {
283                    card.set_diff(project_path.path.clone(), old_text, new_text, cx);
284                })
285                .log_err();
286            }
287
288            Ok(format!(
289                "Edited {}:\n\n```diff\n{}\n```",
290                input.path.display(),
291                diff_str
292            ))
293        });
294
295        ToolResult {
296            output: task,
297            card: card.map(AnyToolCard::from),
298        }
299    }
300}
301
302pub struct EditFileToolCard {
303    path: PathBuf,
304    editor: Entity<Editor>,
305    multibuffer: Entity<MultiBuffer>,
306    project: Entity<Project>,
307    diff_task: Option<Task<Result<()>>>,
308    preview_expanded: bool,
309    error_expanded: bool,
310    full_height_expanded: bool,
311    total_lines: Option<u32>,
312    editor_unique_id: EntityId,
313}
314
315impl EditFileToolCard {
316    pub fn new(path: PathBuf, project: Entity<Project>, window: &mut Window, cx: &mut App) -> Self {
317        let multibuffer = cx.new(|_| MultiBuffer::without_headers(Capability::ReadOnly));
318        let editor = cx.new(|cx| {
319            let mut editor = Editor::new(
320                EditorMode::Full {
321                    scale_ui_elements_with_buffer_font_size: false,
322                    show_active_line_background: false,
323                    sized_by_content: true,
324                },
325                multibuffer.clone(),
326                Some(project.clone()),
327                window,
328                cx,
329            );
330            editor.set_show_gutter(false, cx);
331            editor.disable_inline_diagnostics();
332            editor.disable_expand_excerpt_buttons(cx);
333            editor.set_soft_wrap_mode(SoftWrap::None, cx);
334            editor.scroll_manager.set_forbid_vertical_scroll(true);
335            editor.set_show_scrollbars(false, cx);
336            editor.set_show_indent_guides(false, cx);
337            editor.set_read_only(true);
338            editor.set_show_breakpoints(false, cx);
339            editor.set_show_code_actions(false, cx);
340            editor.set_show_git_diff_gutter(false, cx);
341            editor.set_expand_all_diff_hunks(cx);
342            editor
343        });
344        Self {
345            editor_unique_id: editor.entity_id(),
346            path,
347            project,
348            editor,
349            multibuffer,
350            diff_task: None,
351            preview_expanded: true,
352            error_expanded: false,
353            full_height_expanded: false,
354            total_lines: None,
355        }
356    }
357
358    pub fn has_diff(&self) -> bool {
359        self.total_lines.is_some()
360    }
361
362    pub fn set_diff(
363        &mut self,
364        path: Arc<Path>,
365        old_text: String,
366        new_text: String,
367        cx: &mut Context<Self>,
368    ) {
369        let language_registry = self.project.read(cx).languages().clone();
370        self.diff_task = Some(cx.spawn(async move |this, cx| {
371            let buffer = build_buffer(new_text, path.clone(), &language_registry, cx).await?;
372            let buffer_diff = build_buffer_diff(old_text, &buffer, &language_registry, cx).await?;
373
374            this.update(cx, |this, cx| {
375                this.total_lines = this.multibuffer.update(cx, |multibuffer, cx| {
376                    let snapshot = buffer.read(cx).snapshot();
377                    let diff = buffer_diff.read(cx);
378                    let diff_hunk_ranges = diff
379                        .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
380                        .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
381                        .collect::<Vec<_>>();
382                    multibuffer.clear(cx);
383                    let (_, is_newly_added) = multibuffer.set_excerpts_for_path(
384                        PathKey::for_buffer(&buffer, cx),
385                        buffer,
386                        diff_hunk_ranges,
387                        editor::DEFAULT_MULTIBUFFER_CONTEXT,
388                        cx,
389                    );
390                    debug_assert!(is_newly_added);
391                    multibuffer.add_diff(buffer_diff, cx);
392                    let end = multibuffer.len(cx);
393                    Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
394                });
395
396                cx.notify();
397            })
398        }));
399    }
400}
401
402impl ToolCard for EditFileToolCard {
403    fn render(
404        &mut self,
405        status: &ToolUseStatus,
406        window: &mut Window,
407        workspace: WeakEntity<Workspace>,
408        cx: &mut Context<Self>,
409    ) -> impl IntoElement {
410        let (failed, error_message) = match status {
411            ToolUseStatus::Error(err) => (true, Some(err.to_string())),
412            _ => (false, None),
413        };
414
415        let path_label_button = h_flex()
416            .id(("edit-tool-path-label-button", self.editor_unique_id))
417            .w_full()
418            .max_w_full()
419            .px_1()
420            .gap_0p5()
421            .cursor_pointer()
422            .rounded_sm()
423            .opacity(0.8)
424            .hover(|label| {
425                label
426                    .opacity(1.)
427                    .bg(cx.theme().colors().element_hover.opacity(0.5))
428            })
429            .tooltip(Tooltip::text("Jump to File"))
430            .child(
431                h_flex()
432                    .child(
433                        Icon::new(IconName::Pencil)
434                            .size(IconSize::XSmall)
435                            .color(Color::Muted),
436                    )
437                    .child(
438                        div()
439                            .text_size(rems(0.8125))
440                            .child(self.path.display().to_string())
441                            .ml_1p5()
442                            .mr_0p5(),
443                    )
444                    .child(
445                        Icon::new(IconName::ArrowUpRight)
446                            .size(IconSize::XSmall)
447                            .color(Color::Ignored),
448                    ),
449            )
450            .on_click({
451                let path = self.path.clone();
452                let workspace = workspace.clone();
453                move |_, window, cx| {
454                    workspace
455                        .update(cx, {
456                            |workspace, cx| {
457                                let Some(project_path) =
458                                    workspace.project().read(cx).find_project_path(&path, cx)
459                                else {
460                                    return;
461                                };
462                                let open_task =
463                                    workspace.open_path(project_path, None, true, window, cx);
464                                window
465                                    .spawn(cx, async move |cx| {
466                                        let item = open_task.await?;
467                                        if let Some(active_editor) = item.downcast::<Editor>() {
468                                            active_editor
469                                                .update_in(cx, |editor, window, cx| {
470                                                    editor.go_to_singleton_buffer_point(
471                                                        language::Point::new(0, 0),
472                                                        window,
473                                                        cx,
474                                                    );
475                                                })
476                                                .log_err();
477                                        }
478                                        anyhow::Ok(())
479                                    })
480                                    .detach_and_log_err(cx);
481                            }
482                        })
483                        .ok();
484                }
485            })
486            .into_any_element();
487
488        let codeblock_header_bg = cx
489            .theme()
490            .colors()
491            .element_background
492            .blend(cx.theme().colors().editor_foreground.opacity(0.025));
493
494        let codeblock_header = h_flex()
495            .flex_none()
496            .p_1()
497            .gap_1()
498            .justify_between()
499            .rounded_t_md()
500            .when(!failed, |header| header.bg(codeblock_header_bg))
501            .child(path_label_button)
502            .when(failed, |header| {
503                header.child(
504                    h_flex()
505                        .gap_1()
506                        .child(
507                            Icon::new(IconName::Close)
508                                .size(IconSize::Small)
509                                .color(Color::Error),
510                        )
511                        .child(
512                            Disclosure::new(
513                                ("edit-file-error-disclosure", self.editor_unique_id),
514                                self.error_expanded,
515                            )
516                            .opened_icon(IconName::ChevronUp)
517                            .closed_icon(IconName::ChevronDown)
518                            .on_click(cx.listener(
519                                move |this, _event, _window, _cx| {
520                                    this.error_expanded = !this.error_expanded;
521                                },
522                            )),
523                        ),
524                )
525            })
526            .when(!failed && self.has_diff(), |header| {
527                header.child(
528                    Disclosure::new(
529                        ("edit-file-disclosure", self.editor_unique_id),
530                        self.preview_expanded,
531                    )
532                    .opened_icon(IconName::ChevronUp)
533                    .closed_icon(IconName::ChevronDown)
534                    .on_click(cx.listener(
535                        move |this, _event, _window, _cx| {
536                            this.preview_expanded = !this.preview_expanded;
537                        },
538                    )),
539                )
540            });
541
542        let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| {
543            let line_height = editor
544                .style()
545                .map(|style| style.text.line_height_in_pixels(window.rem_size()))
546                .unwrap_or_default();
547
548            let element = editor.render(window, cx);
549            (element.into_any_element(), line_height)
550        });
551
552        let (full_height_icon, full_height_tooltip_label) = if self.full_height_expanded {
553            (IconName::ChevronUp, "Collapse Code Block")
554        } else {
555            (IconName::ChevronDown, "Expand Code Block")
556        };
557
558        let gradient_overlay = div()
559            .absolute()
560            .bottom_0()
561            .left_0()
562            .w_full()
563            .h_2_5()
564            .rounded_b_lg()
565            .bg(gpui::linear_gradient(
566                0.,
567                gpui::linear_color_stop(cx.theme().colors().editor_background, 0.),
568                gpui::linear_color_stop(cx.theme().colors().editor_background.opacity(0.), 1.),
569            ));
570
571        let border_color = cx.theme().colors().border.opacity(0.6);
572
573        const DEFAULT_COLLAPSED_LINES: u32 = 10;
574        let is_collapsible = self.total_lines.unwrap_or(0) > DEFAULT_COLLAPSED_LINES;
575
576        let waiting_for_diff = {
577            let styles = [
578                ("w_4_5", (0.1, 0.85), 2000),
579                ("w_1_4", (0.2, 0.75), 2200),
580                ("w_2_4", (0.15, 0.64), 1900),
581                ("w_3_5", (0.25, 0.72), 2300),
582                ("w_2_5", (0.3, 0.56), 1800),
583            ];
584
585            let mut container = v_flex()
586                .p_3()
587                .gap_1p5()
588                .border_t_1()
589                .border_color(border_color)
590                .bg(cx.theme().colors().editor_background);
591
592            for (width_method, pulse_range, duration_ms) in styles.iter() {
593                let (min_opacity, max_opacity) = *pulse_range;
594                let placeholder = match *width_method {
595                    "w_4_5" => div().w_3_4(),
596                    "w_1_4" => div().w_1_4(),
597                    "w_2_4" => div().w_2_4(),
598                    "w_3_5" => div().w_3_5(),
599                    "w_2_5" => div().w_2_5(),
600                    _ => div().w_1_2(),
601                }
602                .id("loading_div")
603                .h_2()
604                .rounded_full()
605                .bg(cx.theme().colors().element_active)
606                .with_animation(
607                    "loading_pulsate",
608                    Animation::new(Duration::from_millis(*duration_ms))
609                        .repeat()
610                        .with_easing(pulsating_between(min_opacity, max_opacity)),
611                    |label, delta| label.opacity(delta),
612                );
613
614                container = container.child(placeholder);
615            }
616
617            container
618        };
619
620        v_flex()
621            .mb_2()
622            .border_1()
623            .when(failed, |card| card.border_dashed())
624            .border_color(border_color)
625            .rounded_lg()
626            .overflow_hidden()
627            .child(codeblock_header)
628            .when(failed && self.error_expanded, |card| {
629                card.child(
630                    v_flex()
631                        .p_2()
632                        .gap_1()
633                        .border_t_1()
634                        .border_dashed()
635                        .border_color(border_color)
636                        .bg(cx.theme().colors().editor_background)
637                        .rounded_b_md()
638                        .child(
639                            Label::new("Error")
640                                .size(LabelSize::XSmall)
641                                .color(Color::Error),
642                        )
643                        .child(
644                            div()
645                                .rounded_md()
646                                .text_ui_sm(cx)
647                                .bg(cx.theme().colors().editor_background)
648                                .children(
649                                    error_message
650                                        .map(|error| div().child(error).into_any_element()),
651                                ),
652                        ),
653                )
654            })
655            .when(!self.has_diff() && !failed, |card| {
656                card.child(waiting_for_diff)
657            })
658            .when(
659                !failed && self.preview_expanded && self.has_diff(),
660                |card| {
661                    card.child(
662                        v_flex()
663                            .relative()
664                            .h_full()
665                            .when(!self.full_height_expanded, |editor_container| {
666                                editor_container
667                                    .max_h(DEFAULT_COLLAPSED_LINES as f32 * editor_line_height)
668                            })
669                            .overflow_hidden()
670                            .border_t_1()
671                            .border_color(border_color)
672                            .bg(cx.theme().colors().editor_background)
673                            .child(editor)
674                            .when(
675                                !self.full_height_expanded && is_collapsible,
676                                |editor_container| editor_container.child(gradient_overlay),
677                            ),
678                    )
679                    .when(is_collapsible, |editor_container| {
680                        editor_container.child(
681                            h_flex()
682                                .id(("expand-button", self.editor_unique_id))
683                                .flex_none()
684                                .cursor_pointer()
685                                .h_5()
686                                .justify_center()
687                                .rounded_b_md()
688                                .border_t_1()
689                                .border_color(border_color)
690                                .bg(cx.theme().colors().editor_background)
691                                .hover(|style| {
692                                    style.bg(cx.theme().colors().element_hover.opacity(0.1))
693                                })
694                                .child(
695                                    Icon::new(full_height_icon)
696                                        .size(IconSize::Small)
697                                        .color(Color::Muted),
698                                )
699                                .tooltip(Tooltip::text(full_height_tooltip_label))
700                                .on_click(cx.listener(move |this, _event, _window, _cx| {
701                                    this.full_height_expanded = !this.full_height_expanded;
702                                })),
703                        )
704                    })
705                },
706            )
707    }
708}
709
710async fn build_buffer(
711    mut text: String,
712    path: Arc<Path>,
713    language_registry: &Arc<language::LanguageRegistry>,
714    cx: &mut AsyncApp,
715) -> Result<Entity<Buffer>> {
716    let line_ending = LineEnding::detect(&text);
717    LineEnding::normalize(&mut text);
718    let text = Rope::from(text);
719    let language = cx
720        .update(|_cx| language_registry.language_for_file_path(&path))?
721        .await
722        .ok();
723    let buffer = cx.new(|cx| {
724        let buffer = TextBuffer::new_normalized(
725            0,
726            cx.entity_id().as_non_zero_u64().into(),
727            line_ending,
728            text,
729        );
730        let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
731        buffer.set_language(language, cx);
732        buffer
733    })?;
734    Ok(buffer)
735}
736
737async fn build_buffer_diff(
738    mut old_text: String,
739    buffer: &Entity<Buffer>,
740    language_registry: &Arc<LanguageRegistry>,
741    cx: &mut AsyncApp,
742) -> Result<Entity<BufferDiff>> {
743    LineEnding::normalize(&mut old_text);
744
745    let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
746
747    let base_buffer = cx
748        .update(|cx| {
749            Buffer::build_snapshot(
750                old_text.clone().into(),
751                buffer.language().cloned(),
752                Some(language_registry.clone()),
753                cx,
754            )
755        })?
756        .await;
757
758    let diff_snapshot = cx
759        .update(|cx| {
760            BufferDiffSnapshot::new_with_base_buffer(
761                buffer.text.clone(),
762                Some(old_text.into()),
763                base_buffer,
764                cx,
765            )
766        })?
767        .await;
768
769    cx.new(|cx| {
770        let mut diff = BufferDiff::new(&buffer.text, cx);
771        diff.set_snapshot(diff_snapshot, &buffer.text, cx);
772        diff
773    })
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use serde_json::json;
780
781    #[test]
782    fn still_streaming_ui_text_with_path() {
783        let input = json!({
784            "path": "src/main.rs",
785            "display_description": "",
786            "old_string": "old code",
787            "new_string": "new code"
788        });
789
790        assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs");
791    }
792
793    #[test]
794    fn still_streaming_ui_text_with_description() {
795        let input = json!({
796            "path": "",
797            "display_description": "Fix error handling",
798            "old_string": "old code",
799            "new_string": "new code"
800        });
801
802        assert_eq!(
803            EditFileTool.still_streaming_ui_text(&input),
804            "Fix error handling",
805        );
806    }
807
808    #[test]
809    fn still_streaming_ui_text_with_path_and_description() {
810        let input = json!({
811            "path": "src/main.rs",
812            "display_description": "Fix error handling",
813            "old_string": "old code",
814            "new_string": "new code"
815        });
816
817        assert_eq!(
818            EditFileTool.still_streaming_ui_text(&input),
819            "Fix error handling",
820        );
821    }
822
823    #[test]
824    fn still_streaming_ui_text_no_path_or_description() {
825        let input = json!({
826            "path": "",
827            "display_description": "",
828            "old_string": "old code",
829            "new_string": "new code"
830        });
831
832        assert_eq!(
833            EditFileTool.still_streaming_ui_text(&input),
834            DEFAULT_UI_TEXT,
835        );
836    }
837
838    #[test]
839    fn still_streaming_ui_text_with_null() {
840        let input = serde_json::Value::Null;
841
842        assert_eq!(
843            EditFileTool.still_streaming_ui_text(&input),
844            DEFAULT_UI_TEXT,
845        );
846    }
847}