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}