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