1use crate::{
2 Templates,
3 edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat},
4 schema::json_schema_for,
5 ui::{COLLAPSED_LINES, ToolOutputPreview},
6};
7use agent_settings;
8use anyhow::{Context as _, Result, anyhow};
9use assistant_tool::{
10 ActionLog, AnyToolCard, Tool, ToolCard, ToolResult, ToolResultContent, ToolResultOutput,
11 ToolUseStatus,
12};
13use buffer_diff::{BufferDiff, BufferDiffSnapshot};
14use editor::{Editor, EditorMode, MinimapVisibility, MultiBuffer, PathKey};
15use futures::StreamExt;
16use gpui::{
17 Animation, AnimationExt, AnyWindowHandle, App, AppContext, AsyncApp, Entity, Task,
18 TextStyleRefinement, Transformation, WeakEntity, percentage, pulsating_between, px,
19};
20use indoc::formatdoc;
21use language::{
22 Anchor, Buffer, Capability, LanguageRegistry, LineEnding, OffsetRangeExt, Point, Rope,
23 TextBuffer,
24 language_settings::{self, FormatOnSave, SoftWrap},
25};
26use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
27use markdown::{Markdown, MarkdownElement, MarkdownStyle};
28use project::{
29 Project, ProjectPath,
30 lsp_store::{FormatTrigger, LspFormatTarget},
31};
32use schemars::JsonSchema;
33use serde::{Deserialize, Serialize};
34use settings::Settings;
35use std::{
36 cmp::Reverse,
37 collections::HashSet,
38 ops::Range,
39 path::{Path, PathBuf},
40 sync::Arc,
41 time::Duration,
42};
43use theme::ThemeSettings;
44use ui::{Disclosure, Tooltip, prelude::*};
45use util::ResultExt;
46use workspace::Workspace;
47
48pub struct EditFileTool;
49
50#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
51pub struct EditFileToolInput {
52 /// A one-line, user-friendly markdown description of the edit. This will be
53 /// shown in the UI and also passed to another model to perform the edit.
54 ///
55 /// Be terse, but also descriptive in what you want to achieve with this
56 /// edit. Avoid generic instructions.
57 ///
58 /// NEVER mention the file path in this description.
59 ///
60 /// <example>Fix API endpoint URLs</example>
61 /// <example>Update copyright year in `page_footer`</example>
62 ///
63 /// Make sure to include this field before all the others in the input object
64 /// so that we can display it immediately.
65 pub display_description: String,
66
67 /// The full path of the file to create or modify in the project.
68 ///
69 /// WARNING: When specifying which file path need changing, you MUST
70 /// start each path with one of the project's root directories.
71 ///
72 /// The following examples assume we have two root directories in the project:
73 /// - /a/b/backend
74 /// - /c/d/frontend
75 ///
76 /// <example>
77 /// `backend/src/main.rs`
78 ///
79 /// Notice how the file path starts with `backend`. Without that, the path
80 /// would be ambiguous and the call would fail!
81 /// </example>
82 ///
83 /// <example>
84 /// `frontend/db.js`
85 /// </example>
86 pub path: PathBuf,
87
88 /// The mode of operation on the file. Possible values:
89 /// - 'edit': Make granular edits to an existing file.
90 /// - 'create': Create a new file if it doesn't exist.
91 /// - 'overwrite': Replace the entire contents of an existing file.
92 ///
93 /// When a file already exists or you just created it, prefer editing
94 /// it as opposed to recreating it from scratch.
95 pub mode: EditFileMode,
96}
97
98#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
99#[serde(rename_all = "lowercase")]
100pub enum EditFileMode {
101 Edit,
102 Create,
103 Overwrite,
104}
105
106#[derive(Debug, Serialize, Deserialize, JsonSchema)]
107pub struct EditFileToolOutput {
108 pub original_path: PathBuf,
109 pub new_text: String,
110 pub old_text: Arc<String>,
111 pub raw_output: Option<EditAgentOutput>,
112}
113
114#[derive(Debug, Serialize, Deserialize, JsonSchema)]
115struct PartialInput {
116 #[serde(default)]
117 path: String,
118 #[serde(default)]
119 display_description: String,
120}
121
122const DEFAULT_UI_TEXT: &str = "Editing file";
123
124impl Tool for EditFileTool {
125 fn name(&self) -> String {
126 "edit_file".into()
127 }
128
129 fn needs_confirmation(
130 &self,
131 input: &serde_json::Value,
132 project: &Entity<Project>,
133 cx: &App,
134 ) -> bool {
135 if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
136 return false;
137 }
138
139 let Ok(input) = serde_json::from_value::<EditFileToolInput>(input.clone()) else {
140 // If it's not valid JSON, it's going to error and confirming won't do anything.
141 return false;
142 };
143
144 let path = Path::new(&input.path);
145
146 // If any path component is ".zed", then this could affect
147 // the editor in ways beyond the project source, so prompt.
148 if path
149 .components()
150 .any(|component| component.as_os_str() == ".zed")
151 {
152 return true;
153 }
154
155 // If the path is outside the project, then prompt.
156 let is_outside_project = project
157 .read(cx)
158 .find_project_path(&input.path, cx)
159 .is_none();
160 if is_outside_project {
161 return true;
162 }
163
164 false
165 }
166
167 fn may_perform_edits(&self) -> bool {
168 true
169 }
170
171 fn description(&self) -> String {
172 include_str!("edit_file_tool/description.md").to_string()
173 }
174
175 fn icon(&self) -> IconName {
176 IconName::ToolPencil
177 }
178
179 fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
180 json_schema_for::<EditFileToolInput>(format)
181 }
182
183 fn ui_text(&self, input: &serde_json::Value) -> String {
184 match serde_json::from_value::<EditFileToolInput>(input.clone()) {
185 Ok(input) => {
186 let path = Path::new(&input.path);
187 let mut description = input.display_description.clone();
188
189 // Add context about why confirmation may be needed
190 if path.components().any(|c| c.as_os_str() == ".zed") {
191 description.push_str(" (Zed settings)");
192 }
193
194 description
195 }
196 Err(_) => "Editing file".to_string(),
197 }
198 }
199
200 fn still_streaming_ui_text(&self, input: &serde_json::Value) -> String {
201 if let Some(input) = serde_json::from_value::<PartialInput>(input.clone()).ok() {
202 let description = input.display_description.trim();
203 if !description.is_empty() {
204 return description.to_string();
205 }
206
207 let path = input.path.trim();
208 if !path.is_empty() {
209 return path.to_string();
210 }
211 }
212
213 DEFAULT_UI_TEXT.to_string()
214 }
215
216 fn run(
217 self: Arc<Self>,
218 input: serde_json::Value,
219 request: Arc<LanguageModelRequest>,
220 project: Entity<Project>,
221 action_log: Entity<ActionLog>,
222 model: Arc<dyn LanguageModel>,
223 window: Option<AnyWindowHandle>,
224 cx: &mut App,
225 ) -> ToolResult {
226 let input = match serde_json::from_value::<EditFileToolInput>(input) {
227 Ok(input) => input,
228 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
229 };
230
231 let project_path = match resolve_path(&input, project.clone(), cx) {
232 Ok(path) => path,
233 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
234 };
235
236 let card = window.and_then(|window| {
237 window
238 .update(cx, |_, window, cx| {
239 cx.new(|cx| {
240 EditFileToolCard::new(input.path.clone(), project.clone(), window, cx)
241 })
242 })
243 .ok()
244 });
245
246 let card_clone = card.clone();
247 let action_log_clone = action_log.clone();
248 let task = cx.spawn(async move |cx: &mut AsyncApp| {
249 let edit_format = EditFormat::from_model(model.clone())?;
250 let edit_agent = EditAgent::new(
251 model,
252 project.clone(),
253 action_log_clone,
254 Templates::new(),
255 edit_format,
256 );
257
258 let buffer = project
259 .update(cx, |project, cx| {
260 project.open_buffer(project_path.clone(), cx)
261 })?
262 .await?;
263
264 let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
265 let old_text = cx
266 .background_spawn({
267 let old_snapshot = old_snapshot.clone();
268 async move { Arc::new(old_snapshot.text()) }
269 })
270 .await;
271
272 if let Some(card) = card_clone.as_ref() {
273 card.update(cx, |card, cx| card.initialize(buffer.clone(), cx))?;
274 }
275
276 let (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {
277 edit_agent.edit(
278 buffer.clone(),
279 input.display_description.clone(),
280 &request,
281 cx,
282 )
283 } else {
284 edit_agent.overwrite(
285 buffer.clone(),
286 input.display_description.clone(),
287 &request,
288 cx,
289 )
290 };
291
292 let mut hallucinated_old_text = false;
293 let mut ambiguous_ranges = Vec::new();
294 while let Some(event) = events.next().await {
295 match event {
296 EditAgentOutputEvent::Edited => {
297 if let Some(card) = card_clone.as_ref() {
298 card.update(cx, |card, cx| card.update_diff(cx))?;
299 }
300 }
301 EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,
302 EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges,
303 EditAgentOutputEvent::ResolvingEditRange(range) => {
304 if let Some(card) = card_clone.as_ref() {
305 card.update(cx, |card, cx| card.reveal_range(range, cx))?;
306 }
307 }
308 }
309 }
310 let agent_output = output.await?;
311
312 // If format_on_save is enabled, format the buffer
313 let format_on_save_enabled = buffer
314 .read_with(cx, |buffer, cx| {
315 let settings = language_settings::language_settings(
316 buffer.language().map(|l| l.name()),
317 buffer.file(),
318 cx,
319 );
320 !matches!(settings.format_on_save, FormatOnSave::Off)
321 })
322 .unwrap_or(false);
323
324 if format_on_save_enabled {
325 action_log.update(cx, |log, cx| {
326 log.buffer_edited(buffer.clone(), cx);
327 })?;
328 let format_task = project.update(cx, |project, cx| {
329 project.format(
330 HashSet::from_iter([buffer.clone()]),
331 LspFormatTarget::Buffers,
332 false, // Don't push to history since the tool did it.
333 FormatTrigger::Save,
334 cx,
335 )
336 })?;
337 format_task.await.log_err();
338 }
339
340 project
341 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?
342 .await?;
343
344 // Notify the action log that we've edited the buffer (*after* formatting has completed).
345 action_log.update(cx, |log, cx| {
346 log.buffer_edited(buffer.clone(), cx);
347 })?;
348
349 let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
350 let (new_text, diff) = cx
351 .background_spawn({
352 let new_snapshot = new_snapshot.clone();
353 let old_text = old_text.clone();
354 async move {
355 let new_text = new_snapshot.text();
356 let diff = language::unified_diff(&old_text, &new_text);
357
358 (new_text, diff)
359 }
360 })
361 .await;
362
363 let output = EditFileToolOutput {
364 original_path: project_path.path.to_path_buf(),
365 new_text: new_text.clone(),
366 old_text,
367 raw_output: Some(agent_output),
368 };
369
370 if let Some(card) = card_clone {
371 card.update(cx, |card, cx| {
372 card.update_diff(cx);
373 card.finalize(cx)
374 })
375 .log_err();
376 }
377
378 let input_path = input.path.display();
379 if diff.is_empty() {
380 anyhow::ensure!(
381 !hallucinated_old_text,
382 formatdoc! {"
383 Some edits were produced but none of them could be applied.
384 Read the relevant sections of {input_path} again so that
385 I can perform the requested edits.
386 "}
387 );
388 anyhow::ensure!(
389 ambiguous_ranges.is_empty(),
390 {
391 let line_numbers = ambiguous_ranges
392 .iter()
393 .map(|range| range.start.to_string())
394 .collect::<Vec<_>>()
395 .join(", ");
396 formatdoc! {"
397 <old_text> matches more than one position in the file (lines: {line_numbers}). Read the
398 relevant sections of {input_path} again and extend <old_text> so
399 that I can perform the requested edits.
400 "}
401 }
402 );
403 Ok(ToolResultOutput {
404 content: ToolResultContent::Text("No edits were made.".into()),
405 output: serde_json::to_value(output).ok(),
406 })
407 } else {
408 Ok(ToolResultOutput {
409 content: ToolResultContent::Text(format!(
410 "Edited {}:\n\n```diff\n{}\n```",
411 input_path, diff
412 )),
413 output: serde_json::to_value(output).ok(),
414 })
415 }
416 });
417
418 ToolResult {
419 output: task,
420 card: card.map(AnyToolCard::from),
421 }
422 }
423
424 fn deserialize_card(
425 self: Arc<Self>,
426 output: serde_json::Value,
427 project: Entity<Project>,
428 window: &mut Window,
429 cx: &mut App,
430 ) -> Option<AnyToolCard> {
431 let output = match serde_json::from_value::<EditFileToolOutput>(output) {
432 Ok(output) => output,
433 Err(_) => return None,
434 };
435
436 let card = cx.new(|cx| {
437 EditFileToolCard::new(output.original_path.clone(), project.clone(), window, cx)
438 });
439
440 cx.spawn({
441 let path: Arc<Path> = output.original_path.into();
442 let language_registry = project.read(cx).languages().clone();
443 let card = card.clone();
444 async move |cx| {
445 let buffer =
446 build_buffer(output.new_text, path.clone(), &language_registry, cx).await?;
447 let buffer_diff =
448 build_buffer_diff(output.old_text.clone(), &buffer, &language_registry, cx)
449 .await?;
450 card.update(cx, |card, cx| {
451 card.multibuffer.update(cx, |multibuffer, cx| {
452 let snapshot = buffer.read(cx).snapshot();
453 let diff = buffer_diff.read(cx);
454 let diff_hunk_ranges = diff
455 .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
456 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
457 .collect::<Vec<_>>();
458
459 multibuffer.set_excerpts_for_path(
460 PathKey::for_buffer(&buffer, cx),
461 buffer,
462 diff_hunk_ranges,
463 editor::DEFAULT_MULTIBUFFER_CONTEXT,
464 cx,
465 );
466 multibuffer.add_diff(buffer_diff, cx);
467 let end = multibuffer.len(cx);
468 card.total_lines =
469 Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1);
470 });
471
472 cx.notify();
473 })?;
474 anyhow::Ok(())
475 }
476 })
477 .detach_and_log_err(cx);
478
479 Some(card.into())
480 }
481}
482
483/// Validate that the file path is valid, meaning:
484///
485/// - For `edit` and `overwrite`, the path must point to an existing file.
486/// - For `create`, the file must not already exist, but it's parent dir must exist.
487fn resolve_path(
488 input: &EditFileToolInput,
489 project: Entity<Project>,
490 cx: &mut App,
491) -> Result<ProjectPath> {
492 let project = project.read(cx);
493
494 match input.mode {
495 EditFileMode::Edit | EditFileMode::Overwrite => {
496 let path = project
497 .find_project_path(&input.path, cx)
498 .context("Can't edit file: path not found")?;
499
500 let entry = project
501 .entry_for_path(&path, cx)
502 .context("Can't edit file: path not found")?;
503
504 anyhow::ensure!(entry.is_file(), "Can't edit file: path is a directory");
505 Ok(path)
506 }
507
508 EditFileMode::Create => {
509 if let Some(path) = project.find_project_path(&input.path, cx) {
510 anyhow::ensure!(
511 project.entry_for_path(&path, cx).is_none(),
512 "Can't create file: file already exists"
513 );
514 }
515
516 let parent_path = input
517 .path
518 .parent()
519 .context("Can't create file: incorrect path")?;
520
521 let parent_project_path = project.find_project_path(&parent_path, cx);
522
523 let parent_entry = parent_project_path
524 .as_ref()
525 .and_then(|path| project.entry_for_path(&path, cx))
526 .context("Can't create file: parent directory doesn't exist")?;
527
528 anyhow::ensure!(
529 parent_entry.is_dir(),
530 "Can't create file: parent is not a directory"
531 );
532
533 let file_name = input
534 .path
535 .file_name()
536 .context("Can't create file: invalid filename")?;
537
538 let new_file_path = parent_project_path.map(|parent| ProjectPath {
539 path: Arc::from(parent.path.join(file_name)),
540 ..parent
541 });
542
543 new_file_path.context("Can't create file")
544 }
545 }
546}
547
548pub struct EditFileToolCard {
549 path: PathBuf,
550 editor: Entity<Editor>,
551 multibuffer: Entity<MultiBuffer>,
552 project: Entity<Project>,
553 buffer: Option<Entity<Buffer>>,
554 base_text: Option<Arc<String>>,
555 buffer_diff: Option<Entity<BufferDiff>>,
556 revealed_ranges: Vec<Range<Anchor>>,
557 diff_task: Option<Task<Result<()>>>,
558 preview_expanded: bool,
559 error_expanded: Option<Entity<Markdown>>,
560 full_height_expanded: bool,
561 total_lines: Option<u32>,
562}
563
564impl EditFileToolCard {
565 pub fn new(path: PathBuf, project: Entity<Project>, window: &mut Window, cx: &mut App) -> Self {
566 let expand_edit_card = agent_settings::AgentSettings::get_global(cx).expand_edit_card;
567 let multibuffer = cx.new(|_| MultiBuffer::without_headers(Capability::ReadOnly));
568
569 let editor = cx.new(|cx| {
570 let mut editor = Editor::new(
571 EditorMode::Full {
572 scale_ui_elements_with_buffer_font_size: false,
573 show_active_line_background: false,
574 sized_by_content: true,
575 },
576 multibuffer.clone(),
577 Some(project.clone()),
578 window,
579 cx,
580 );
581 editor.set_show_gutter(false, cx);
582 editor.disable_inline_diagnostics();
583 editor.disable_expand_excerpt_buttons(cx);
584 // Keep horizontal scrollbar so user can scroll horizontally if needed
585 editor.set_show_vertical_scrollbar(false, cx);
586 editor.set_minimap_visibility(MinimapVisibility::Disabled, window, cx);
587 editor.set_soft_wrap_mode(SoftWrap::None, cx);
588 editor.scroll_manager.set_forbid_vertical_scroll(true);
589 editor.set_show_indent_guides(false, cx);
590 editor.set_read_only(true);
591 editor.set_show_breakpoints(false, cx);
592 editor.set_show_code_actions(false, cx);
593 editor.set_show_git_diff_gutter(false, cx);
594 editor.set_expand_all_diff_hunks(cx);
595 editor
596 });
597 Self {
598 path,
599 project,
600 editor,
601 multibuffer,
602 buffer: None,
603 base_text: None,
604 buffer_diff: None,
605 revealed_ranges: Vec::new(),
606 diff_task: None,
607 preview_expanded: true,
608 error_expanded: None,
609 full_height_expanded: expand_edit_card,
610 total_lines: None,
611 }
612 }
613
614 pub fn initialize(&mut self, buffer: Entity<Buffer>, cx: &mut App) {
615 let buffer_snapshot = buffer.read(cx).snapshot();
616 let base_text = buffer_snapshot.text();
617 let language_registry = buffer.read(cx).language_registry();
618 let text_snapshot = buffer.read(cx).text_snapshot();
619
620 // Create a buffer diff with the current text as the base
621 let buffer_diff = cx.new(|cx| {
622 let mut diff = BufferDiff::new(&text_snapshot, cx);
623 let _ = diff.set_base_text(
624 buffer_snapshot.clone(),
625 language_registry,
626 text_snapshot,
627 cx,
628 );
629 diff
630 });
631
632 self.buffer = Some(buffer.clone());
633 self.base_text = Some(base_text.into());
634 self.buffer_diff = Some(buffer_diff.clone());
635
636 // Add the diff to the multibuffer
637 self.multibuffer
638 .update(cx, |multibuffer, cx| multibuffer.add_diff(buffer_diff, cx));
639 }
640
641 pub fn is_loading(&self) -> bool {
642 self.total_lines.is_none()
643 }
644
645 pub fn update_diff(&mut self, cx: &mut Context<Self>) {
646 let Some(buffer) = self.buffer.as_ref() else {
647 return;
648 };
649 let Some(buffer_diff) = self.buffer_diff.as_ref() else {
650 return;
651 };
652
653 let buffer = buffer.clone();
654 let buffer_diff = buffer_diff.clone();
655 let base_text = self.base_text.clone();
656 self.diff_task = Some(cx.spawn(async move |this, cx| {
657 let text_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot())?;
658 let diff_snapshot = BufferDiff::update_diff(
659 buffer_diff.clone(),
660 text_snapshot.clone(),
661 base_text,
662 false,
663 false,
664 None,
665 None,
666 cx,
667 )
668 .await?;
669 buffer_diff.update(cx, |diff, cx| {
670 diff.set_snapshot(diff_snapshot, &text_snapshot, cx)
671 })?;
672 this.update(cx, |this, cx| this.update_visible_ranges(cx))
673 }));
674 }
675
676 pub fn reveal_range(&mut self, range: Range<Anchor>, cx: &mut Context<Self>) {
677 self.revealed_ranges.push(range);
678 self.update_visible_ranges(cx);
679 }
680
681 fn update_visible_ranges(&mut self, cx: &mut Context<Self>) {
682 let Some(buffer) = self.buffer.as_ref() else {
683 return;
684 };
685
686 let ranges = self.excerpt_ranges(cx);
687 self.total_lines = self.multibuffer.update(cx, |multibuffer, cx| {
688 multibuffer.set_excerpts_for_path(
689 PathKey::for_buffer(buffer, cx),
690 buffer.clone(),
691 ranges,
692 editor::DEFAULT_MULTIBUFFER_CONTEXT,
693 cx,
694 );
695 let end = multibuffer.len(cx);
696 Some(multibuffer.snapshot(cx).offset_to_point(end).row + 1)
697 });
698 cx.notify();
699 }
700
701 fn excerpt_ranges(&self, cx: &App) -> Vec<Range<Point>> {
702 let Some(buffer) = self.buffer.as_ref() else {
703 return Vec::new();
704 };
705 let Some(diff) = self.buffer_diff.as_ref() else {
706 return Vec::new();
707 };
708
709 let buffer = buffer.read(cx);
710 let diff = diff.read(cx);
711 let mut ranges = diff
712 .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &buffer, cx)
713 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&buffer))
714 .collect::<Vec<_>>();
715 ranges.extend(
716 self.revealed_ranges
717 .iter()
718 .map(|range| range.to_point(&buffer)),
719 );
720 ranges.sort_unstable_by_key(|range| (range.start, Reverse(range.end)));
721
722 // Merge adjacent ranges
723 let mut ranges = ranges.into_iter().peekable();
724 let mut merged_ranges = Vec::new();
725 while let Some(mut range) = ranges.next() {
726 while let Some(next_range) = ranges.peek() {
727 if range.end >= next_range.start {
728 range.end = range.end.max(next_range.end);
729 ranges.next();
730 } else {
731 break;
732 }
733 }
734
735 merged_ranges.push(range);
736 }
737 merged_ranges
738 }
739
740 pub fn finalize(&mut self, cx: &mut Context<Self>) -> Result<()> {
741 let ranges = self.excerpt_ranges(cx);
742 let buffer = self.buffer.take().context("card was already finalized")?;
743 let base_text = self
744 .base_text
745 .take()
746 .context("card was already finalized")?;
747 let language_registry = self.project.read(cx).languages().clone();
748
749 // Replace the buffer in the multibuffer with the snapshot
750 let buffer = cx.new(|cx| {
751 let language = buffer.read(cx).language().cloned();
752 let buffer = TextBuffer::new_normalized(
753 0,
754 cx.entity_id().as_non_zero_u64().into(),
755 buffer.read(cx).line_ending(),
756 buffer.read(cx).as_rope().clone(),
757 );
758 let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
759 buffer.set_language(language, cx);
760 buffer
761 });
762
763 let buffer_diff = cx.spawn({
764 let buffer = buffer.clone();
765 let language_registry = language_registry.clone();
766 async move |_this, cx| {
767 build_buffer_diff(base_text, &buffer, &language_registry, cx).await
768 }
769 });
770
771 cx.spawn(async move |this, cx| {
772 let buffer_diff = buffer_diff.await?;
773 this.update(cx, |this, cx| {
774 this.multibuffer.update(cx, |multibuffer, cx| {
775 let path_key = PathKey::for_buffer(&buffer, cx);
776 multibuffer.clear(cx);
777 multibuffer.set_excerpts_for_path(
778 path_key,
779 buffer,
780 ranges,
781 editor::DEFAULT_MULTIBUFFER_CONTEXT,
782 cx,
783 );
784 multibuffer.add_diff(buffer_diff.clone(), cx);
785 });
786
787 cx.notify();
788 })
789 })
790 .detach_and_log_err(cx);
791 Ok(())
792 }
793}
794
795impl ToolCard for EditFileToolCard {
796 fn render(
797 &mut self,
798 status: &ToolUseStatus,
799 window: &mut Window,
800 workspace: WeakEntity<Workspace>,
801 cx: &mut Context<Self>,
802 ) -> impl IntoElement {
803 let error_message = match status {
804 ToolUseStatus::Error(err) => Some(err),
805 _ => None,
806 };
807
808 let running_or_pending = match status {
809 ToolUseStatus::Running | ToolUseStatus::Pending => Some(()),
810 _ => None,
811 };
812
813 let should_show_loading = running_or_pending.is_some() && !self.full_height_expanded;
814
815 let path_label_button = h_flex()
816 .id(("edit-tool-path-label-button", self.editor.entity_id()))
817 .w_full()
818 .max_w_full()
819 .px_1()
820 .gap_0p5()
821 .cursor_pointer()
822 .rounded_sm()
823 .opacity(0.8)
824 .hover(|label| {
825 label
826 .opacity(1.)
827 .bg(cx.theme().colors().element_hover.opacity(0.5))
828 })
829 .tooltip(Tooltip::text("Jump to File"))
830 .child(
831 h_flex()
832 .child(
833 Icon::new(IconName::ToolPencil)
834 .size(IconSize::Small)
835 .color(Color::Muted),
836 )
837 .child(
838 div()
839 .text_size(rems(0.8125))
840 .child(self.path.display().to_string())
841 .ml_1p5()
842 .mr_0p5(),
843 )
844 .child(
845 Icon::new(IconName::ArrowUpRight)
846 .size(IconSize::XSmall)
847 .color(Color::Ignored),
848 ),
849 )
850 .on_click({
851 let path = self.path.clone();
852 let workspace = workspace.clone();
853 move |_, window, cx| {
854 workspace
855 .update(cx, {
856 |workspace, cx| {
857 let Some(project_path) =
858 workspace.project().read(cx).find_project_path(&path, cx)
859 else {
860 return;
861 };
862 let open_task =
863 workspace.open_path(project_path, None, true, window, cx);
864 window
865 .spawn(cx, async move |cx| {
866 let item = open_task.await?;
867 if let Some(active_editor) = item.downcast::<Editor>() {
868 active_editor
869 .update_in(cx, |editor, window, cx| {
870 let snapshot =
871 editor.buffer().read(cx).snapshot(cx);
872 let first_hunk = editor
873 .diff_hunks_in_ranges(
874 &[editor::Anchor::min()
875 ..editor::Anchor::max()],
876 &snapshot,
877 )
878 .next();
879 if let Some(first_hunk) = first_hunk {
880 let first_hunk_start =
881 first_hunk.multi_buffer_range().start;
882 editor.change_selections(
883 Default::default(),
884 window,
885 cx,
886 |selections| {
887 selections.select_anchor_ranges([
888 first_hunk_start
889 ..first_hunk_start,
890 ]);
891 },
892 )
893 }
894 })
895 .log_err();
896 }
897 anyhow::Ok(())
898 })
899 .detach_and_log_err(cx);
900 }
901 })
902 .ok();
903 }
904 })
905 .into_any_element();
906
907 let codeblock_header_bg = cx
908 .theme()
909 .colors()
910 .element_background
911 .blend(cx.theme().colors().editor_foreground.opacity(0.025));
912
913 let codeblock_header = h_flex()
914 .flex_none()
915 .p_1()
916 .gap_1()
917 .justify_between()
918 .rounded_t_md()
919 .when(error_message.is_none(), |header| {
920 header.bg(codeblock_header_bg)
921 })
922 .child(path_label_button)
923 .when(should_show_loading, |header| {
924 header.pr_1p5().child(
925 Icon::new(IconName::ArrowCircle)
926 .size(IconSize::XSmall)
927 .color(Color::Info)
928 .with_animation(
929 "arrow-circle",
930 Animation::new(Duration::from_secs(2)).repeat(),
931 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
932 ),
933 )
934 })
935 .when_some(error_message, |header, error_message| {
936 header.child(
937 h_flex()
938 .gap_1()
939 .child(
940 Icon::new(IconName::Close)
941 .size(IconSize::Small)
942 .color(Color::Error),
943 )
944 .child(
945 Disclosure::new(
946 ("edit-file-error-disclosure", self.editor.entity_id()),
947 self.error_expanded.is_some(),
948 )
949 .opened_icon(IconName::ChevronUp)
950 .closed_icon(IconName::ChevronDown)
951 .on_click(cx.listener({
952 let error_message = error_message.clone();
953
954 move |this, _event, _window, cx| {
955 if this.error_expanded.is_some() {
956 this.error_expanded.take();
957 } else {
958 this.error_expanded = Some(cx.new(|cx| {
959 Markdown::new(error_message.clone(), None, None, cx)
960 }))
961 }
962 cx.notify();
963 }
964 })),
965 ),
966 )
967 })
968 .when(error_message.is_none() && !self.is_loading(), |header| {
969 header.child(
970 Disclosure::new(
971 ("edit-file-disclosure", self.editor.entity_id()),
972 self.preview_expanded,
973 )
974 .opened_icon(IconName::ChevronUp)
975 .closed_icon(IconName::ChevronDown)
976 .on_click(cx.listener(
977 move |this, _event, _window, _cx| {
978 this.preview_expanded = !this.preview_expanded;
979 },
980 )),
981 )
982 });
983
984 let (editor, editor_line_height) = self.editor.update(cx, |editor, cx| {
985 let line_height = editor
986 .style()
987 .map(|style| style.text.line_height_in_pixels(window.rem_size()))
988 .unwrap_or_default();
989
990 editor.set_text_style_refinement(TextStyleRefinement {
991 font_size: Some(
992 TextSize::Small
993 .rems(cx)
994 .to_pixels(ThemeSettings::get_global(cx).agent_font_size(cx))
995 .into(),
996 ),
997 ..TextStyleRefinement::default()
998 });
999 let element = editor.render(window, cx);
1000 (element.into_any_element(), line_height)
1001 });
1002
1003 let border_color = cx.theme().colors().border.opacity(0.6);
1004
1005 let waiting_for_diff = {
1006 let styles = [
1007 ("w_4_5", (0.1, 0.85), 2000),
1008 ("w_1_4", (0.2, 0.75), 2200),
1009 ("w_2_4", (0.15, 0.64), 1900),
1010 ("w_3_5", (0.25, 0.72), 2300),
1011 ("w_2_5", (0.3, 0.56), 1800),
1012 ];
1013
1014 let mut container = v_flex()
1015 .p_3()
1016 .gap_1()
1017 .border_t_1()
1018 .rounded_b_md()
1019 .border_color(border_color)
1020 .bg(cx.theme().colors().editor_background);
1021
1022 for (width_method, pulse_range, duration_ms) in styles.iter() {
1023 let (min_opacity, max_opacity) = *pulse_range;
1024 let placeholder = match *width_method {
1025 "w_4_5" => div().w_3_4(),
1026 "w_1_4" => div().w_1_4(),
1027 "w_2_4" => div().w_2_4(),
1028 "w_3_5" => div().w_3_5(),
1029 "w_2_5" => div().w_2_5(),
1030 _ => div().w_1_2(),
1031 }
1032 .id("loading_div")
1033 .h_1()
1034 .rounded_full()
1035 .bg(cx.theme().colors().element_active)
1036 .with_animation(
1037 "loading_pulsate",
1038 Animation::new(Duration::from_millis(*duration_ms))
1039 .repeat()
1040 .with_easing(pulsating_between(min_opacity, max_opacity)),
1041 |label, delta| label.opacity(delta),
1042 );
1043
1044 container = container.child(placeholder);
1045 }
1046
1047 container
1048 };
1049
1050 v_flex()
1051 .mb_2()
1052 .border_1()
1053 .when(error_message.is_some(), |card| card.border_dashed())
1054 .border_color(border_color)
1055 .rounded_md()
1056 .overflow_hidden()
1057 .child(codeblock_header)
1058 .when_some(self.error_expanded.as_ref(), |card, error_markdown| {
1059 card.child(
1060 v_flex()
1061 .p_2()
1062 .gap_1()
1063 .border_t_1()
1064 .border_dashed()
1065 .border_color(border_color)
1066 .bg(cx.theme().colors().editor_background)
1067 .rounded_b_md()
1068 .child(
1069 Label::new("Error")
1070 .size(LabelSize::XSmall)
1071 .color(Color::Error),
1072 )
1073 .child(
1074 div()
1075 .rounded_md()
1076 .text_ui_sm(cx)
1077 .bg(cx.theme().colors().editor_background)
1078 .child(MarkdownElement::new(
1079 error_markdown.clone(),
1080 markdown_style(window, cx),
1081 )),
1082 ),
1083 )
1084 })
1085 .when(self.is_loading() && error_message.is_none(), |card| {
1086 card.child(waiting_for_diff)
1087 })
1088 .when(self.preview_expanded && !self.is_loading(), |card| {
1089 let editor_view = v_flex()
1090 .relative()
1091 .h_full()
1092 .when(!self.full_height_expanded, |editor_container| {
1093 editor_container.max_h(px(COLLAPSED_LINES as f32 * editor_line_height.0))
1094 })
1095 .overflow_hidden()
1096 .border_t_1()
1097 .border_color(border_color)
1098 .bg(cx.theme().colors().editor_background)
1099 .child(editor);
1100
1101 card.child(
1102 ToolOutputPreview::new(editor_view.into_any_element(), self.editor.entity_id())
1103 .with_total_lines(self.total_lines.unwrap_or(0) as usize)
1104 .toggle_state(self.full_height_expanded)
1105 .with_collapsed_fade()
1106 .on_toggle({
1107 let this = cx.entity().downgrade();
1108 move |is_expanded, _window, cx| {
1109 if let Some(this) = this.upgrade() {
1110 this.update(cx, |this, _cx| {
1111 this.full_height_expanded = is_expanded;
1112 });
1113 }
1114 }
1115 }),
1116 )
1117 })
1118 }
1119}
1120
1121fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
1122 let theme_settings = ThemeSettings::get_global(cx);
1123 let ui_font_size = TextSize::Default.rems(cx);
1124 let mut text_style = window.text_style();
1125
1126 text_style.refine(&TextStyleRefinement {
1127 font_family: Some(theme_settings.ui_font.family.clone()),
1128 font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
1129 font_features: Some(theme_settings.ui_font.features.clone()),
1130 font_size: Some(ui_font_size.into()),
1131 color: Some(cx.theme().colors().text),
1132 ..Default::default()
1133 });
1134
1135 MarkdownStyle {
1136 base_text_style: text_style.clone(),
1137 selection_background_color: cx.theme().colors().element_selection_background,
1138 ..Default::default()
1139 }
1140}
1141
1142async fn build_buffer(
1143 mut text: String,
1144 path: Arc<Path>,
1145 language_registry: &Arc<language::LanguageRegistry>,
1146 cx: &mut AsyncApp,
1147) -> Result<Entity<Buffer>> {
1148 let line_ending = LineEnding::detect(&text);
1149 LineEnding::normalize(&mut text);
1150 let text = Rope::from(text);
1151 let language = cx
1152 .update(|_cx| language_registry.language_for_file_path(&path))?
1153 .await
1154 .ok();
1155 let buffer = cx.new(|cx| {
1156 let buffer = TextBuffer::new_normalized(
1157 0,
1158 cx.entity_id().as_non_zero_u64().into(),
1159 line_ending,
1160 text,
1161 );
1162 let mut buffer = Buffer::build(buffer, None, Capability::ReadWrite);
1163 buffer.set_language(language, cx);
1164 buffer
1165 })?;
1166 Ok(buffer)
1167}
1168
1169async fn build_buffer_diff(
1170 old_text: Arc<String>,
1171 buffer: &Entity<Buffer>,
1172 language_registry: &Arc<LanguageRegistry>,
1173 cx: &mut AsyncApp,
1174) -> Result<Entity<BufferDiff>> {
1175 let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
1176
1177 let old_text_rope = cx
1178 .background_spawn({
1179 let old_text = old_text.clone();
1180 async move { Rope::from(old_text.as_str()) }
1181 })
1182 .await;
1183 let base_buffer = cx
1184 .update(|cx| {
1185 Buffer::build_snapshot(
1186 old_text_rope,
1187 buffer.language().cloned(),
1188 Some(language_registry.clone()),
1189 cx,
1190 )
1191 })?
1192 .await;
1193
1194 let diff_snapshot = cx
1195 .update(|cx| {
1196 BufferDiffSnapshot::new_with_base_buffer(
1197 buffer.text.clone(),
1198 Some(old_text),
1199 base_buffer,
1200 cx,
1201 )
1202 })?
1203 .await;
1204
1205 let secondary_diff = cx.new(|cx| {
1206 let mut diff = BufferDiff::new(&buffer, cx);
1207 diff.set_snapshot(diff_snapshot.clone(), &buffer, cx);
1208 diff
1209 })?;
1210
1211 cx.new(|cx| {
1212 let mut diff = BufferDiff::new(&buffer.text, cx);
1213 diff.set_snapshot(diff_snapshot, &buffer, cx);
1214 diff.set_secondary_diff(secondary_diff);
1215 diff
1216 })
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221 use super::*;
1222 use client::TelemetrySettings;
1223 use fs::{FakeFs, Fs};
1224 use gpui::{TestAppContext, UpdateGlobal};
1225 use language_model::fake_provider::FakeLanguageModel;
1226 use serde_json::json;
1227 use settings::SettingsStore;
1228 use util::path;
1229
1230 #[gpui::test]
1231 async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
1232 init_test(cx);
1233
1234 let fs = FakeFs::new(cx.executor());
1235 fs.insert_tree("/root", json!({})).await;
1236 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1237 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1238 let model = Arc::new(FakeLanguageModel::default());
1239 let result = cx
1240 .update(|cx| {
1241 let input = serde_json::to_value(EditFileToolInput {
1242 display_description: "Some edit".into(),
1243 path: "root/nonexistent_file.txt".into(),
1244 mode: EditFileMode::Edit,
1245 })
1246 .unwrap();
1247 Arc::new(EditFileTool)
1248 .run(
1249 input,
1250 Arc::default(),
1251 project.clone(),
1252 action_log,
1253 model,
1254 None,
1255 cx,
1256 )
1257 .output
1258 })
1259 .await;
1260 assert_eq!(
1261 result.unwrap_err().to_string(),
1262 "Can't edit file: path not found"
1263 );
1264 }
1265
1266 #[gpui::test]
1267 async fn test_resolve_path_for_creating_file(cx: &mut TestAppContext) {
1268 let mode = &EditFileMode::Create;
1269
1270 let result = test_resolve_path(mode, "root/new.txt", cx);
1271 assert_resolved_path_eq(result.await, "new.txt");
1272
1273 let result = test_resolve_path(mode, "new.txt", cx);
1274 assert_resolved_path_eq(result.await, "new.txt");
1275
1276 let result = test_resolve_path(mode, "dir/new.txt", cx);
1277 assert_resolved_path_eq(result.await, "dir/new.txt");
1278
1279 let result = test_resolve_path(mode, "root/dir/subdir/existing.txt", cx);
1280 assert_eq!(
1281 result.await.unwrap_err().to_string(),
1282 "Can't create file: file already exists"
1283 );
1284
1285 let result = test_resolve_path(mode, "root/dir/nonexistent_dir/new.txt", cx);
1286 assert_eq!(
1287 result.await.unwrap_err().to_string(),
1288 "Can't create file: parent directory doesn't exist"
1289 );
1290 }
1291
1292 #[gpui::test]
1293 async fn test_resolve_path_for_editing_file(cx: &mut TestAppContext) {
1294 let mode = &EditFileMode::Edit;
1295
1296 let path_with_root = "root/dir/subdir/existing.txt";
1297 let path_without_root = "dir/subdir/existing.txt";
1298 let result = test_resolve_path(mode, path_with_root, cx);
1299 assert_resolved_path_eq(result.await, path_without_root);
1300
1301 let result = test_resolve_path(mode, path_without_root, cx);
1302 assert_resolved_path_eq(result.await, path_without_root);
1303
1304 let result = test_resolve_path(mode, "root/nonexistent.txt", cx);
1305 assert_eq!(
1306 result.await.unwrap_err().to_string(),
1307 "Can't edit file: path not found"
1308 );
1309
1310 let result = test_resolve_path(mode, "root/dir", cx);
1311 assert_eq!(
1312 result.await.unwrap_err().to_string(),
1313 "Can't edit file: path is a directory"
1314 );
1315 }
1316
1317 async fn test_resolve_path(
1318 mode: &EditFileMode,
1319 path: &str,
1320 cx: &mut TestAppContext,
1321 ) -> anyhow::Result<ProjectPath> {
1322 init_test(cx);
1323
1324 let fs = FakeFs::new(cx.executor());
1325 fs.insert_tree(
1326 "/root",
1327 json!({
1328 "dir": {
1329 "subdir": {
1330 "existing.txt": "hello"
1331 }
1332 }
1333 }),
1334 )
1335 .await;
1336 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1337
1338 let input = EditFileToolInput {
1339 display_description: "Some edit".into(),
1340 path: path.into(),
1341 mode: mode.clone(),
1342 };
1343
1344 let result = cx.update(|cx| resolve_path(&input, project, cx));
1345 result
1346 }
1347
1348 fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &str) {
1349 let actual = path
1350 .expect("Should return valid path")
1351 .path
1352 .to_str()
1353 .unwrap()
1354 .replace("\\", "/"); // Naive Windows paths normalization
1355 assert_eq!(actual, expected);
1356 }
1357
1358 #[test]
1359 fn still_streaming_ui_text_with_path() {
1360 let input = json!({
1361 "path": "src/main.rs",
1362 "display_description": "",
1363 "old_string": "old code",
1364 "new_string": "new code"
1365 });
1366
1367 assert_eq!(EditFileTool.still_streaming_ui_text(&input), "src/main.rs");
1368 }
1369
1370 #[test]
1371 fn still_streaming_ui_text_with_description() {
1372 let input = json!({
1373 "path": "",
1374 "display_description": "Fix error handling",
1375 "old_string": "old code",
1376 "new_string": "new code"
1377 });
1378
1379 assert_eq!(
1380 EditFileTool.still_streaming_ui_text(&input),
1381 "Fix error handling",
1382 );
1383 }
1384
1385 #[test]
1386 fn still_streaming_ui_text_with_path_and_description() {
1387 let input = json!({
1388 "path": "src/main.rs",
1389 "display_description": "Fix error handling",
1390 "old_string": "old code",
1391 "new_string": "new code"
1392 });
1393
1394 assert_eq!(
1395 EditFileTool.still_streaming_ui_text(&input),
1396 "Fix error handling",
1397 );
1398 }
1399
1400 #[test]
1401 fn still_streaming_ui_text_no_path_or_description() {
1402 let input = json!({
1403 "path": "",
1404 "display_description": "",
1405 "old_string": "old code",
1406 "new_string": "new code"
1407 });
1408
1409 assert_eq!(
1410 EditFileTool.still_streaming_ui_text(&input),
1411 DEFAULT_UI_TEXT,
1412 );
1413 }
1414
1415 #[test]
1416 fn still_streaming_ui_text_with_null() {
1417 let input = serde_json::Value::Null;
1418
1419 assert_eq!(
1420 EditFileTool.still_streaming_ui_text(&input),
1421 DEFAULT_UI_TEXT,
1422 );
1423 }
1424
1425 fn init_test(cx: &mut TestAppContext) {
1426 cx.update(|cx| {
1427 let settings_store = SettingsStore::test(cx);
1428 cx.set_global(settings_store);
1429 language::init(cx);
1430 TelemetrySettings::register(cx);
1431 agent_settings::AgentSettings::register(cx);
1432 Project::init_settings(cx);
1433 });
1434 }
1435
1436 #[gpui::test]
1437 async fn test_format_on_save(cx: &mut TestAppContext) {
1438 init_test(cx);
1439
1440 let fs = FakeFs::new(cx.executor());
1441 fs.insert_tree("/root", json!({"src": {}})).await;
1442
1443 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1444
1445 // Set up a Rust language with LSP formatting support
1446 let rust_language = Arc::new(language::Language::new(
1447 language::LanguageConfig {
1448 name: "Rust".into(),
1449 matcher: language::LanguageMatcher {
1450 path_suffixes: vec!["rs".to_string()],
1451 ..Default::default()
1452 },
1453 ..Default::default()
1454 },
1455 None,
1456 ));
1457
1458 // Register the language and fake LSP
1459 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
1460 language_registry.add(rust_language);
1461
1462 let mut fake_language_servers = language_registry.register_fake_lsp(
1463 "Rust",
1464 language::FakeLspAdapter {
1465 capabilities: lsp::ServerCapabilities {
1466 document_formatting_provider: Some(lsp::OneOf::Left(true)),
1467 ..Default::default()
1468 },
1469 ..Default::default()
1470 },
1471 );
1472
1473 // Create the file
1474 fs.save(
1475 path!("/root/src/main.rs").as_ref(),
1476 &"initial content".into(),
1477 language::LineEnding::Unix,
1478 )
1479 .await
1480 .unwrap();
1481
1482 // Open the buffer to trigger LSP initialization
1483 let buffer = project
1484 .update(cx, |project, cx| {
1485 project.open_local_buffer(path!("/root/src/main.rs"), cx)
1486 })
1487 .await
1488 .unwrap();
1489
1490 // Register the buffer with language servers
1491 let _handle = project.update(cx, |project, cx| {
1492 project.register_buffer_with_language_servers(&buffer, cx)
1493 });
1494
1495 const UNFORMATTED_CONTENT: &str = "fn main() {println!(\"Hello!\");}\n";
1496 const FORMATTED_CONTENT: &str =
1497 "This file was formatted by the fake formatter in the test.\n";
1498
1499 // Get the fake language server and set up formatting handler
1500 let fake_language_server = fake_language_servers.next().await.unwrap();
1501 fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>({
1502 |_, _| async move {
1503 Ok(Some(vec![lsp::TextEdit {
1504 range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(1, 0)),
1505 new_text: FORMATTED_CONTENT.to_string(),
1506 }]))
1507 }
1508 });
1509
1510 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1511 let model = Arc::new(FakeLanguageModel::default());
1512
1513 // First, test with format_on_save enabled
1514 cx.update(|cx| {
1515 SettingsStore::update_global(cx, |store, cx| {
1516 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
1517 cx,
1518 |settings| {
1519 settings.defaults.format_on_save = Some(FormatOnSave::On);
1520 settings.defaults.formatter =
1521 Some(language::language_settings::SelectedFormatter::Auto);
1522 },
1523 );
1524 });
1525 });
1526
1527 // Have the model stream unformatted content
1528 let edit_result = {
1529 let edit_task = cx.update(|cx| {
1530 let input = serde_json::to_value(EditFileToolInput {
1531 display_description: "Create main function".into(),
1532 path: "root/src/main.rs".into(),
1533 mode: EditFileMode::Overwrite,
1534 })
1535 .unwrap();
1536 Arc::new(EditFileTool)
1537 .run(
1538 input,
1539 Arc::default(),
1540 project.clone(),
1541 action_log.clone(),
1542 model.clone(),
1543 None,
1544 cx,
1545 )
1546 .output
1547 });
1548
1549 // Stream the unformatted content
1550 cx.executor().run_until_parked();
1551 model.stream_last_completion_response(UNFORMATTED_CONTENT.to_string());
1552 model.end_last_completion_stream();
1553
1554 edit_task.await
1555 };
1556 assert!(edit_result.is_ok());
1557
1558 // Wait for any async operations (e.g. formatting) to complete
1559 cx.executor().run_until_parked();
1560
1561 // Read the file to verify it was formatted automatically
1562 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1563 assert_eq!(
1564 // Ignore carriage returns on Windows
1565 new_content.replace("\r\n", "\n"),
1566 FORMATTED_CONTENT,
1567 "Code should be formatted when format_on_save is enabled"
1568 );
1569
1570 let stale_buffer_count = action_log.read_with(cx, |log, cx| log.stale_buffers(cx).count());
1571
1572 assert_eq!(
1573 stale_buffer_count, 0,
1574 "BUG: Buffer is incorrectly marked as stale after format-on-save. Found {} stale buffers. \
1575 This causes the agent to think the file was modified externally when it was just formatted.",
1576 stale_buffer_count
1577 );
1578
1579 // Next, test with format_on_save disabled
1580 cx.update(|cx| {
1581 SettingsStore::update_global(cx, |store, cx| {
1582 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
1583 cx,
1584 |settings| {
1585 settings.defaults.format_on_save = Some(FormatOnSave::Off);
1586 },
1587 );
1588 });
1589 });
1590
1591 // Stream unformatted edits again
1592 let edit_result = {
1593 let edit_task = cx.update(|cx| {
1594 let input = serde_json::to_value(EditFileToolInput {
1595 display_description: "Update main function".into(),
1596 path: "root/src/main.rs".into(),
1597 mode: EditFileMode::Overwrite,
1598 })
1599 .unwrap();
1600 Arc::new(EditFileTool)
1601 .run(
1602 input,
1603 Arc::default(),
1604 project.clone(),
1605 action_log.clone(),
1606 model.clone(),
1607 None,
1608 cx,
1609 )
1610 .output
1611 });
1612
1613 // Stream the unformatted content
1614 cx.executor().run_until_parked();
1615 model.stream_last_completion_response(UNFORMATTED_CONTENT.to_string());
1616 model.end_last_completion_stream();
1617
1618 edit_task.await
1619 };
1620 assert!(edit_result.is_ok());
1621
1622 // Wait for any async operations (e.g. formatting) to complete
1623 cx.executor().run_until_parked();
1624
1625 // Verify the file was not formatted
1626 let new_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1627 assert_eq!(
1628 // Ignore carriage returns on Windows
1629 new_content.replace("\r\n", "\n"),
1630 UNFORMATTED_CONTENT,
1631 "Code should not be formatted when format_on_save is disabled"
1632 );
1633 }
1634
1635 #[gpui::test]
1636 async fn test_remove_trailing_whitespace(cx: &mut TestAppContext) {
1637 init_test(cx);
1638
1639 let fs = FakeFs::new(cx.executor());
1640 fs.insert_tree("/root", json!({"src": {}})).await;
1641
1642 // Create a simple file with trailing whitespace
1643 fs.save(
1644 path!("/root/src/main.rs").as_ref(),
1645 &"initial content".into(),
1646 language::LineEnding::Unix,
1647 )
1648 .await
1649 .unwrap();
1650
1651 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1652 let action_log = cx.new(|_| ActionLog::new(project.clone()));
1653 let model = Arc::new(FakeLanguageModel::default());
1654
1655 // First, test with remove_trailing_whitespace_on_save enabled
1656 cx.update(|cx| {
1657 SettingsStore::update_global(cx, |store, cx| {
1658 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
1659 cx,
1660 |settings| {
1661 settings.defaults.remove_trailing_whitespace_on_save = Some(true);
1662 },
1663 );
1664 });
1665 });
1666
1667 const CONTENT_WITH_TRAILING_WHITESPACE: &str =
1668 "fn main() { \n println!(\"Hello!\"); \n}\n";
1669
1670 // Have the model stream content that contains trailing whitespace
1671 let edit_result = {
1672 let edit_task = cx.update(|cx| {
1673 let input = serde_json::to_value(EditFileToolInput {
1674 display_description: "Create main function".into(),
1675 path: "root/src/main.rs".into(),
1676 mode: EditFileMode::Overwrite,
1677 })
1678 .unwrap();
1679 Arc::new(EditFileTool)
1680 .run(
1681 input,
1682 Arc::default(),
1683 project.clone(),
1684 action_log.clone(),
1685 model.clone(),
1686 None,
1687 cx,
1688 )
1689 .output
1690 });
1691
1692 // Stream the content with trailing whitespace
1693 cx.executor().run_until_parked();
1694 model.stream_last_completion_response(CONTENT_WITH_TRAILING_WHITESPACE.to_string());
1695 model.end_last_completion_stream();
1696
1697 edit_task.await
1698 };
1699 assert!(edit_result.is_ok());
1700
1701 // Wait for any async operations (e.g. formatting) to complete
1702 cx.executor().run_until_parked();
1703
1704 // Read the file to verify trailing whitespace was removed automatically
1705 assert_eq!(
1706 // Ignore carriage returns on Windows
1707 fs.load(path!("/root/src/main.rs").as_ref())
1708 .await
1709 .unwrap()
1710 .replace("\r\n", "\n"),
1711 "fn main() {\n println!(\"Hello!\");\n}\n",
1712 "Trailing whitespace should be removed when remove_trailing_whitespace_on_save is enabled"
1713 );
1714
1715 // Next, test with remove_trailing_whitespace_on_save disabled
1716 cx.update(|cx| {
1717 SettingsStore::update_global(cx, |store, cx| {
1718 store.update_user_settings::<language::language_settings::AllLanguageSettings>(
1719 cx,
1720 |settings| {
1721 settings.defaults.remove_trailing_whitespace_on_save = Some(false);
1722 },
1723 );
1724 });
1725 });
1726
1727 // Stream edits again with trailing whitespace
1728 let edit_result = {
1729 let edit_task = cx.update(|cx| {
1730 let input = serde_json::to_value(EditFileToolInput {
1731 display_description: "Update main function".into(),
1732 path: "root/src/main.rs".into(),
1733 mode: EditFileMode::Overwrite,
1734 })
1735 .unwrap();
1736 Arc::new(EditFileTool)
1737 .run(
1738 input,
1739 Arc::default(),
1740 project.clone(),
1741 action_log.clone(),
1742 model.clone(),
1743 None,
1744 cx,
1745 )
1746 .output
1747 });
1748
1749 // Stream the content with trailing whitespace
1750 cx.executor().run_until_parked();
1751 model.stream_last_completion_response(CONTENT_WITH_TRAILING_WHITESPACE.to_string());
1752 model.end_last_completion_stream();
1753
1754 edit_task.await
1755 };
1756 assert!(edit_result.is_ok());
1757
1758 // Wait for any async operations (e.g. formatting) to complete
1759 cx.executor().run_until_parked();
1760
1761 // Verify the file still has trailing whitespace
1762 // Read the file again - it should still have trailing whitespace
1763 let final_content = fs.load(path!("/root/src/main.rs").as_ref()).await.unwrap();
1764 assert_eq!(
1765 // Ignore carriage returns on Windows
1766 final_content.replace("\r\n", "\n"),
1767 CONTENT_WITH_TRAILING_WHITESPACE,
1768 "Trailing whitespace should remain when remove_trailing_whitespace_on_save is disabled"
1769 );
1770 }
1771
1772 #[gpui::test]
1773 async fn test_needs_confirmation(cx: &mut TestAppContext) {
1774 init_test(cx);
1775 let tool = Arc::new(EditFileTool);
1776 let fs = FakeFs::new(cx.executor());
1777 fs.insert_tree("/root", json!({})).await;
1778
1779 // Test 1: Path with .zed component should require confirmation
1780 let input_with_zed = json!({
1781 "display_description": "Edit settings",
1782 "path": ".zed/settings.json",
1783 "mode": "edit"
1784 });
1785 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
1786 cx.update(|cx| {
1787 assert!(
1788 tool.needs_confirmation(&input_with_zed, &project, cx),
1789 "Path with .zed component should require confirmation"
1790 );
1791 });
1792
1793 // Test 2: Absolute path should require confirmation
1794 let input_absolute = json!({
1795 "display_description": "Edit file",
1796 "path": "/etc/hosts",
1797 "mode": "edit"
1798 });
1799 cx.update(|cx| {
1800 assert!(
1801 tool.needs_confirmation(&input_absolute, &project, cx),
1802 "Absolute path should require confirmation"
1803 );
1804 });
1805
1806 // Test 3: Relative path without .zed should not require confirmation
1807 let input_relative = json!({
1808 "display_description": "Edit file",
1809 "path": "root/src/main.rs",
1810 "mode": "edit"
1811 });
1812 cx.update(|cx| {
1813 assert!(
1814 !tool.needs_confirmation(&input_relative, &project, cx),
1815 "Relative path without .zed should not require confirmation"
1816 );
1817 });
1818
1819 // Test 4: Path with .zed in the middle should require confirmation
1820 let input_zed_middle = json!({
1821 "display_description": "Edit settings",
1822 "path": "root/.zed/tasks.json",
1823 "mode": "edit"
1824 });
1825 cx.update(|cx| {
1826 assert!(
1827 tool.needs_confirmation(&input_zed_middle, &project, cx),
1828 "Path with .zed in any component should require confirmation"
1829 );
1830 });
1831
1832 // Test 5: When always_allow_tool_actions is enabled, no confirmation needed
1833 cx.update(|cx| {
1834 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
1835 settings.always_allow_tool_actions = true;
1836 agent_settings::AgentSettings::override_global(settings, cx);
1837
1838 assert!(
1839 !tool.needs_confirmation(&input_with_zed, &project, cx),
1840 "When always_allow_tool_actions is true, no confirmation should be needed"
1841 );
1842 assert!(
1843 !tool.needs_confirmation(&input_absolute, &project, cx),
1844 "When always_allow_tool_actions is true, no confirmation should be needed for absolute paths"
1845 );
1846 });
1847 }
1848
1849 #[gpui::test]
1850 fn test_ui_text_with_confirmation_context(cx: &mut TestAppContext) {
1851 init_test(cx);
1852 let tool = Arc::new(EditFileTool);
1853
1854 // Test ui_text shows context for .zed paths
1855 let input_zed = json!({
1856 "display_description": "Update settings",
1857 "path": ".zed/settings.json",
1858 "mode": "edit"
1859 });
1860 cx.update(|_cx| {
1861 let ui_text = tool.ui_text(&input_zed);
1862 assert_eq!(
1863 ui_text, "Update settings (Zed settings)",
1864 "UI text should indicate Zed settings file"
1865 );
1866 });
1867
1868 // Test ui_text for normal paths
1869 let input_normal = json!({
1870 "display_description": "Edit source file",
1871 "path": "src/main.rs",
1872 "mode": "edit"
1873 });
1874 cx.update(|_cx| {
1875 let ui_text = tool.ui_text(&input_normal);
1876 assert_eq!(
1877 ui_text, "Edit source file",
1878 "UI text should not have additional context for normal paths"
1879 );
1880 });
1881 }
1882
1883 #[gpui::test]
1884 async fn test_needs_confirmation_outside_project(cx: &mut TestAppContext) {
1885 init_test(cx);
1886 let tool = Arc::new(EditFileTool);
1887 let fs = FakeFs::new(cx.executor());
1888
1889 // Create a project in /project directory
1890 fs.insert_tree("/project", json!({})).await;
1891 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1892
1893 // Test file outside project requires confirmation
1894 let input_outside = json!({
1895 "display_description": "Edit file",
1896 "path": "/outside/file.txt",
1897 "mode": "edit"
1898 });
1899 cx.update(|cx| {
1900 assert!(
1901 tool.needs_confirmation(&input_outside, &project, cx),
1902 "File outside project should require confirmation"
1903 );
1904 });
1905
1906 // Test file inside project doesn't require confirmation
1907 let input_inside = json!({
1908 "display_description": "Edit file",
1909 "path": "project/file.txt",
1910 "mode": "edit"
1911 });
1912 cx.update(|cx| {
1913 assert!(
1914 !tool.needs_confirmation(&input_inside, &project, cx),
1915 "File inside project should not require confirmation"
1916 );
1917 });
1918 }
1919
1920 #[gpui::test]
1921 async fn test_needs_confirmation_zed_paths(cx: &mut TestAppContext) {
1922 init_test(cx);
1923 let tool = Arc::new(EditFileTool);
1924 let fs = FakeFs::new(cx.executor());
1925 fs.insert_tree("/home/user/myproject", json!({})).await;
1926 let project = Project::test(fs.clone(), [path!("/home/user/myproject").as_ref()], cx).await;
1927
1928 // Test various .zed path patterns
1929 let test_cases = vec![
1930 (".zed/settings.json", true, "Top-level .zed file"),
1931 ("myproject/.zed/settings.json", true, ".zed in project path"),
1932 ("src/.zed/config.toml", true, ".zed in subdirectory"),
1933 (
1934 ".zed.backup/file.txt",
1935 true,
1936 ".zed.backup is outside project (not a .zed component issue)",
1937 ),
1938 (
1939 "my.zed/file.txt",
1940 true,
1941 "my.zed is outside project (not a .zed component issue)",
1942 ),
1943 ("myproject/src/file.zed", false, ".zed as file extension"),
1944 (
1945 "myproject/normal/path/file.rs",
1946 false,
1947 "Normal file without .zed",
1948 ),
1949 ];
1950
1951 for (path, should_confirm, description) in test_cases {
1952 let input = json!({
1953 "display_description": "Edit file",
1954 "path": path,
1955 "mode": "edit"
1956 });
1957 cx.update(|cx| {
1958 assert_eq!(
1959 tool.needs_confirmation(&input, &project, cx),
1960 should_confirm,
1961 "Failed for case: {} - path: {}",
1962 description,
1963 path
1964 );
1965 });
1966 }
1967 }
1968
1969 #[gpui::test]
1970 async fn test_needs_confirmation_with_multiple_worktrees(cx: &mut TestAppContext) {
1971 init_test(cx);
1972 let tool = Arc::new(EditFileTool);
1973 let fs = FakeFs::new(cx.executor());
1974
1975 // Create multiple worktree directories
1976 fs.insert_tree(
1977 "/workspace/frontend",
1978 json!({
1979 "src": {
1980 "main.js": "console.log('frontend');"
1981 }
1982 }),
1983 )
1984 .await;
1985 fs.insert_tree(
1986 "/workspace/backend",
1987 json!({
1988 "src": {
1989 "main.rs": "fn main() {}"
1990 }
1991 }),
1992 )
1993 .await;
1994 fs.insert_tree(
1995 "/workspace/shared",
1996 json!({
1997 ".zed": {
1998 "settings.json": "{}"
1999 }
2000 }),
2001 )
2002 .await;
2003
2004 // Create project with multiple worktrees
2005 let project = Project::test(
2006 fs.clone(),
2007 [
2008 path!("/workspace/frontend").as_ref(),
2009 path!("/workspace/backend").as_ref(),
2010 path!("/workspace/shared").as_ref(),
2011 ],
2012 cx,
2013 )
2014 .await;
2015
2016 // Test files in different worktrees
2017 let test_cases = vec![
2018 ("frontend/src/main.js", false, "File in first worktree"),
2019 ("backend/src/main.rs", false, "File in second worktree"),
2020 (
2021 "shared/.zed/settings.json",
2022 true,
2023 ".zed file in third worktree",
2024 ),
2025 ("/etc/hosts", true, "Absolute path outside all worktrees"),
2026 (
2027 "../outside/file.txt",
2028 true,
2029 "Relative path outside worktrees",
2030 ),
2031 ];
2032
2033 for (path, should_confirm, description) in test_cases {
2034 let input = json!({
2035 "display_description": "Edit file",
2036 "path": path,
2037 "mode": "edit"
2038 });
2039 cx.update(|cx| {
2040 assert_eq!(
2041 tool.needs_confirmation(&input, &project, cx),
2042 should_confirm,
2043 "Failed for case: {} - path: {}",
2044 description,
2045 path
2046 );
2047 });
2048 }
2049 }
2050
2051 #[gpui::test]
2052 async fn test_needs_confirmation_edge_cases(cx: &mut TestAppContext) {
2053 init_test(cx);
2054 let tool = Arc::new(EditFileTool);
2055 let fs = FakeFs::new(cx.executor());
2056 fs.insert_tree(
2057 "/project",
2058 json!({
2059 ".zed": {
2060 "settings.json": "{}"
2061 },
2062 "src": {
2063 ".zed": {
2064 "local.json": "{}"
2065 }
2066 }
2067 }),
2068 )
2069 .await;
2070 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2071
2072 // Test edge cases
2073 let test_cases = vec![
2074 // Empty path - find_project_path returns Some for empty paths
2075 ("", false, "Empty path is treated as project root"),
2076 // Root directory
2077 ("/", true, "Root directory should be outside project"),
2078 // Parent directory references - find_project_path resolves these
2079 (
2080 "project/../other",
2081 false,
2082 "Path with .. is resolved by find_project_path",
2083 ),
2084 (
2085 "project/./src/file.rs",
2086 false,
2087 "Path with . should work normally",
2088 ),
2089 // Windows-style paths (if on Windows)
2090 #[cfg(target_os = "windows")]
2091 ("C:\\Windows\\System32\\hosts", true, "Windows system path"),
2092 #[cfg(target_os = "windows")]
2093 ("project\\src\\main.rs", false, "Windows-style project path"),
2094 ];
2095
2096 for (path, should_confirm, description) in test_cases {
2097 let input = json!({
2098 "display_description": "Edit file",
2099 "path": path,
2100 "mode": "edit"
2101 });
2102 cx.update(|cx| {
2103 assert_eq!(
2104 tool.needs_confirmation(&input, &project, cx),
2105 should_confirm,
2106 "Failed for case: {} - path: {}",
2107 description,
2108 path
2109 );
2110 });
2111 }
2112 }
2113
2114 #[gpui::test]
2115 async fn test_ui_text_shows_correct_context(cx: &mut TestAppContext) {
2116 init_test(cx);
2117 let tool = Arc::new(EditFileTool);
2118
2119 // Test UI text for various scenarios
2120 let test_cases = vec![
2121 (
2122 json!({
2123 "display_description": "Update config",
2124 "path": ".zed/settings.json",
2125 "mode": "edit"
2126 }),
2127 "Update config (Zed settings)",
2128 ".zed path should show Zed settings context",
2129 ),
2130 (
2131 json!({
2132 "display_description": "Fix bug",
2133 "path": "src/.zed/local.json",
2134 "mode": "edit"
2135 }),
2136 "Fix bug (Zed settings)",
2137 "Nested .zed path should show Zed settings context",
2138 ),
2139 (
2140 json!({
2141 "display_description": "Update readme",
2142 "path": "README.md",
2143 "mode": "edit"
2144 }),
2145 "Update readme",
2146 "Normal path should not show additional context",
2147 ),
2148 (
2149 json!({
2150 "display_description": "Edit config",
2151 "path": "config.zed",
2152 "mode": "edit"
2153 }),
2154 "Edit config",
2155 ".zed as extension should not show context",
2156 ),
2157 ];
2158
2159 for (input, expected_text, description) in test_cases {
2160 cx.update(|_cx| {
2161 let ui_text = tool.ui_text(&input);
2162 assert_eq!(ui_text, expected_text, "Failed for case: {}", description);
2163 });
2164 }
2165 }
2166
2167 #[gpui::test]
2168 async fn test_needs_confirmation_with_different_modes(cx: &mut TestAppContext) {
2169 init_test(cx);
2170 let tool = Arc::new(EditFileTool);
2171 let fs = FakeFs::new(cx.executor());
2172 fs.insert_tree(
2173 "/project",
2174 json!({
2175 "existing.txt": "content",
2176 ".zed": {
2177 "settings.json": "{}"
2178 }
2179 }),
2180 )
2181 .await;
2182 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2183
2184 // Test different EditFileMode values
2185 let modes = vec![
2186 EditFileMode::Edit,
2187 EditFileMode::Create,
2188 EditFileMode::Overwrite,
2189 ];
2190
2191 for mode in modes {
2192 // Test .zed path with different modes
2193 let input_zed = json!({
2194 "display_description": "Edit settings",
2195 "path": "project/.zed/settings.json",
2196 "mode": mode
2197 });
2198 cx.update(|cx| {
2199 assert!(
2200 tool.needs_confirmation(&input_zed, &project, cx),
2201 ".zed path should require confirmation regardless of mode: {:?}",
2202 mode
2203 );
2204 });
2205
2206 // Test outside path with different modes
2207 let input_outside = json!({
2208 "display_description": "Edit file",
2209 "path": "/outside/file.txt",
2210 "mode": mode
2211 });
2212 cx.update(|cx| {
2213 assert!(
2214 tool.needs_confirmation(&input_outside, &project, cx),
2215 "Outside path should require confirmation regardless of mode: {:?}",
2216 mode
2217 );
2218 });
2219
2220 // Test normal path with different modes
2221 let input_normal = json!({
2222 "display_description": "Edit file",
2223 "path": "project/normal.txt",
2224 "mode": mode
2225 });
2226 cx.update(|cx| {
2227 assert!(
2228 !tool.needs_confirmation(&input_normal, &project, cx),
2229 "Normal path should not require confirmation regardless of mode: {:?}",
2230 mode
2231 );
2232 });
2233 }
2234 }
2235
2236 #[gpui::test]
2237 async fn test_always_allow_tool_actions_bypasses_all_checks(cx: &mut TestAppContext) {
2238 init_test(cx);
2239 let tool = Arc::new(EditFileTool);
2240 let fs = FakeFs::new(cx.executor());
2241 fs.insert_tree("/project", json!({})).await;
2242 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
2243
2244 // Enable always_allow_tool_actions
2245 cx.update(|cx| {
2246 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
2247 settings.always_allow_tool_actions = true;
2248 agent_settings::AgentSettings::override_global(settings, cx);
2249 });
2250
2251 // Test that all paths that normally require confirmation are bypassed
2252 let test_cases = vec![
2253 ".zed/settings.json",
2254 "project/.zed/config.toml",
2255 "/etc/hosts",
2256 "/absolute/path/file.txt",
2257 "../outside/project.txt",
2258 ];
2259
2260 for path in test_cases {
2261 let input = json!({
2262 "display_description": "Edit file",
2263 "path": path,
2264 "mode": "edit"
2265 });
2266 cx.update(|cx| {
2267 assert!(
2268 !tool.needs_confirmation(&input, &project, cx),
2269 "Path {} should not require confirmation when always_allow_tool_actions is true",
2270 path
2271 );
2272 });
2273 }
2274
2275 // Disable always_allow_tool_actions and verify confirmation is required again
2276 cx.update(|cx| {
2277 let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
2278 settings.always_allow_tool_actions = false;
2279 agent_settings::AgentSettings::override_global(settings, cx);
2280 });
2281
2282 // Verify .zed path requires confirmation again
2283 let input = json!({
2284 "display_description": "Edit file",
2285 "path": ".zed/settings.json",
2286 "mode": "edit"
2287 });
2288 cx.update(|cx| {
2289 assert!(
2290 tool.needs_confirmation(&input, &project, cx),
2291 ".zed path should require confirmation when always_allow_tool_actions is false"
2292 );
2293 });
2294 }
2295}