Escape markdown in tools' `ui_text` (#27502)

Richard Feldman created

Escape markdown in tools' `ui_text`

<img width="628" alt="Screenshot 2025-03-26 at 10 43 23 AM"
src="https://github.com/user-attachments/assets/bb694821-aae7-4ccf-a35a-a3317b0222d5"
/>


Release Notes:

- N/A

Change summary

crates/assistant_tools/src/bash_tool.rs              |  10 
crates/assistant_tools/src/copy_path_tool.rs         |   5 
crates/assistant_tools/src/create_file_tool.rs       |   3 
crates/assistant_tools/src/diagnostics_tool.rs       | 140 +++++++------
crates/assistant_tools/src/fetch_tool.rs             |   3 
crates/assistant_tools/src/find_replace_file_tool.rs |   4 
crates/assistant_tools/src/list_directory_tool.rs    |   6 
crates/assistant_tools/src/move_path_tool.rs         |  10 
crates/assistant_tools/src/read_file_tool.rs         |   6 
crates/assistant_tools/src/regex_search_tool.rs      |   9 
crates/util/src/markdown.rs                          |   2 
11 files changed, 110 insertions(+), 88 deletions(-)

Detailed changes

crates/assistant_tools/src/bash_tool.rs 🔗

@@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
 use std::sync::Arc;
 use ui::IconName;
 use util::command::new_smol_command;
+use util::markdown::MarkdownString;
 
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
 pub struct BashToolInput {
@@ -43,7 +44,14 @@ impl Tool for BashTool {
 
     fn ui_text(&self, input: &serde_json::Value) -> String {
         match serde_json::from_value::<BashToolInput>(input.clone()) {
-            Ok(input) => format!("`{}`", input.command),
+            Ok(input) => {
+                let cmd = MarkdownString::escape(&input.command);
+                if input.command.contains('\n') {
+                    format!("```bash\n{cmd}\n```")
+                } else {
+                    format!("`{cmd}`")
+                }
+            }
             Err(_) => "Run bash command".to_string(),
         }
     }

crates/assistant_tools/src/copy_path_tool.rs 🔗

@@ -7,6 +7,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::sync::Arc;
 use ui::IconName;
+use util::markdown::MarkdownString;
 
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
 pub struct CopyPathToolInput {
@@ -60,8 +61,8 @@ impl Tool for CopyPathTool {
     fn ui_text(&self, input: &serde_json::Value) -> String {
         match serde_json::from_value::<CopyPathToolInput>(input.clone()) {
             Ok(input) => {
-                let src = input.source_path.as_str();
-                let dest = input.destination_path.as_str();
+                let src = MarkdownString::escape(&input.source_path);
+                let dest = MarkdownString::escape(&input.destination_path);
                 format!("Copy `{src}` to `{dest}`")
             }
             Err(_) => "Copy path".to_string(),

crates/assistant_tools/src/create_file_tool.rs 🔗

@@ -7,6 +7,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::sync::Arc;
 use ui::IconName;
+use util::markdown::MarkdownString;
 
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
 pub struct CreateFileToolInput {
@@ -57,7 +58,7 @@ impl Tool for CreateFileTool {
     fn ui_text(&self, input: &serde_json::Value) -> String {
         match serde_json::from_value::<CreateFileToolInput>(input.clone()) {
             Ok(input) => {
-                let path = input.path.as_str();
+                let path = MarkdownString::escape(&input.path);
                 format!("Create file `{path}`")
             }
             Err(_) => "Create file".to_string(),

crates/assistant_tools/src/diagnostics_tool.rs 🔗

@@ -6,12 +6,9 @@ use language_model::LanguageModelRequestMessage;
 use project::Project;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use std::{
-    fmt::Write,
-    path::{Path, PathBuf},
-    sync::Arc,
-};
+use std::{fmt::Write, path::Path, sync::Arc};
 use ui::IconName;
+use util::markdown::MarkdownString;
 
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
 pub struct DiagnosticsToolInput {
@@ -28,7 +25,7 @@ pub struct DiagnosticsToolInput {
     ///
     /// If you wanna access diagnostics for `dolor.txt` in `ipsum`, you should use the path `ipsum/dolor.txt`.
     /// </example>
-    pub path: Option<PathBuf>,
+    pub path: Option<String>,
 }
 
 pub struct DiagnosticsTool;
@@ -58,9 +55,12 @@ impl Tool for DiagnosticsTool {
     fn ui_text(&self, input: &serde_json::Value) -> String {
         if let Some(path) = serde_json::from_value::<DiagnosticsToolInput>(input.clone())
             .ok()
-            .and_then(|input| input.path)
+            .and_then(|input| match input.path {
+                Some(path) if !path.is_empty() => Some(MarkdownString::escape(&path)),
+                _ => None,
+            })
         {
-            format!("Check diagnostics for “`{}`”", path.display())
+            format!("Check diagnostics for `{path}`")
         } else {
             "Check project diagnostics".to_string()
         }
@@ -74,76 +74,78 @@ impl Tool for DiagnosticsTool {
         _action_log: Entity<ActionLog>,
         cx: &mut App,
     ) -> Task<Result<String>> {
-        if let Some(path) = serde_json::from_value::<DiagnosticsToolInput>(input)
+        match serde_json::from_value::<DiagnosticsToolInput>(input)
             .ok()
             .and_then(|input| input.path)
         {
-            let Some(project_path) = project.read(cx).find_project_path(&path, cx) else {
-                return Task::ready(Err(anyhow!(
-                    "Could not find path {} in project",
-                    path.display()
-                )));
-            };
-            let buffer = project.update(cx, |project, cx| project.open_buffer(project_path, cx));
-
-            cx.spawn(async move |cx| {
+            Some(path) if !path.is_empty() => {
+                let Some(project_path) = project.read(cx).find_project_path(&path, cx) else {
+                    return Task::ready(Err(anyhow!("Could not find path {path} in project",)));
+                };
+
+                let buffer =
+                    project.update(cx, |project, cx| project.open_buffer(project_path, cx));
+
+                cx.spawn(async move |cx| {
+                    let mut output = String::new();
+                    let buffer = buffer.await?;
+                    let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
+
+                    for (_, group) in snapshot.diagnostic_groups(None) {
+                        let entry = &group.entries[group.primary_ix];
+                        let range = entry.range.to_point(&snapshot);
+                        let severity = match entry.diagnostic.severity {
+                            DiagnosticSeverity::ERROR => "error",
+                            DiagnosticSeverity::WARNING => "warning",
+                            _ => continue,
+                        };
+
+                        writeln!(
+                            output,
+                            "{} at line {}: {}",
+                            severity,
+                            range.start.row + 1,
+                            entry.diagnostic.message
+                        )?;
+                    }
+
+                    if output.is_empty() {
+                        Ok("File doesn't have errors or warnings!".to_string())
+                    } else {
+                        Ok(output)
+                    }
+                })
+            }
+            _ => {
+                let project = project.read(cx);
                 let mut output = String::new();
-                let buffer = buffer.await?;
-                let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
-
-                for (_, group) in snapshot.diagnostic_groups(None) {
-                    let entry = &group.entries[group.primary_ix];
-                    let range = entry.range.to_point(&snapshot);
-                    let severity = match entry.diagnostic.severity {
-                        DiagnosticSeverity::ERROR => "error",
-                        DiagnosticSeverity::WARNING => "warning",
-                        _ => continue,
-                    };
-
-                    writeln!(
-                        output,
-                        "{} at line {}: {}",
-                        severity,
-                        range.start.row + 1,
-                        entry.diagnostic.message
-                    )?;
+                let mut has_diagnostics = false;
+
+                for (project_path, _, summary) in project.diagnostic_summaries(true, cx) {
+                    if summary.error_count > 0 || summary.warning_count > 0 {
+                        let Some(worktree) = project.worktree_for_id(project_path.worktree_id, cx)
+                        else {
+                            continue;
+                        };
+
+                        has_diagnostics = true;
+                        output.push_str(&format!(
+                            "{}: {} error(s), {} warning(s)\n",
+                            Path::new(worktree.read(cx).root_name())
+                                .join(project_path.path)
+                                .display(),
+                            summary.error_count,
+                            summary.warning_count
+                        ));
+                    }
                 }
 
-                if output.is_empty() {
-                    Ok("File doesn't have errors or warnings!".to_string())
+                if has_diagnostics {
+                    Task::ready(Ok(output))
                 } else {
-                    Ok(output)
-                }
-            })
-        } else {
-            let project = project.read(cx);
-            let mut output = String::new();
-            let mut has_diagnostics = false;
-
-            for (project_path, _, summary) in project.diagnostic_summaries(true, cx) {
-                if summary.error_count > 0 || summary.warning_count > 0 {
-                    let Some(worktree) = project.worktree_for_id(project_path.worktree_id, cx)
-                    else {
-                        continue;
-                    };
-
-                    has_diagnostics = true;
-                    output.push_str(&format!(
-                        "{}: {} error(s), {} warning(s)\n",
-                        Path::new(worktree.read(cx).root_name())
-                            .join(project_path.path)
-                            .display(),
-                        summary.error_count,
-                        summary.warning_count
-                    ));
+                    Task::ready(Ok("No errors or warnings found in the project.".to_string()))
                 }
             }
-
-            if has_diagnostics {
-                Task::ready(Ok(output))
-            } else {
-                Task::ready(Ok("No errors or warnings found in the project.".to_string()))
-            }
         }
     }
 }

crates/assistant_tools/src/fetch_tool.rs 🔗

@@ -13,6 +13,7 @@ use project::Project;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use ui::IconName;
+use util::markdown::MarkdownString;
 
 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
 enum ContentType {
@@ -133,7 +134,7 @@ impl Tool for FetchTool {
 
     fn ui_text(&self, input: &serde_json::Value) -> String {
         match serde_json::from_value::<FetchToolInput>(input.clone()) {
-            Ok(input) => format!("Fetch `{}`", input.url),
+            Ok(input) => format!("Fetch {}", MarkdownString::escape(&input.url)),
             Err(_) => "Fetch URL".to_string(),
         }
     }

crates/assistant_tools/src/find_replace_file_tool.rs 🔗

@@ -33,10 +33,10 @@ pub struct FindReplaceFileToolInput {
     /// </example>
     pub path: PathBuf,
 
-    /// A user-friendly description of what's being replaced. This will be shown in the UI.
+    /// A user-friendly markdown description of what's being replaced. This will be shown in the UI.
     ///
     /// <example>Fix API endpoint URLs</example>
-    /// <example>Update copyright year</example>
+    /// <example>Update copyright year in `page_footer`</example>
     pub display_description: String,
 
     /// The unique string to find in the file. This string cannot be empty;

crates/assistant_tools/src/list_directory_tool.rs 🔗

@@ -7,6 +7,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::{fmt::Write, path::Path, sync::Arc};
 use ui::IconName;
+use util::markdown::MarkdownString;
 
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
 pub struct ListDirectoryToolInput {
@@ -61,7 +62,10 @@ impl Tool for ListDirectoryTool {
 
     fn ui_text(&self, input: &serde_json::Value) -> String {
         match serde_json::from_value::<ListDirectoryToolInput>(input.clone()) {
-            Ok(input) => format!("List the `{}` directory's contents", input.path),
+            Ok(input) => {
+                let path = MarkdownString::escape(&input.path);
+                format!("List the `{path}` directory's contents")
+            }
             Err(_) => "List directory".to_string(),
         }
     }

crates/assistant_tools/src/move_path_tool.rs 🔗

@@ -7,6 +7,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::{path::Path, sync::Arc};
 use ui::IconName;
+use util::markdown::MarkdownString;
 
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
 pub struct MovePathToolInput {
@@ -60,16 +61,17 @@ impl Tool for MovePathTool {
     fn ui_text(&self, input: &serde_json::Value) -> String {
         match serde_json::from_value::<MovePathToolInput>(input.clone()) {
             Ok(input) => {
-                let src = input.source_path.as_str();
-                let dest = input.destination_path.as_str();
-                let src_path = Path::new(src);
-                let dest_path = Path::new(dest);
+                let src = MarkdownString::escape(&input.source_path);
+                let dest = MarkdownString::escape(&input.destination_path);
+                let src_path = Path::new(&input.source_path);
+                let dest_path = Path::new(&input.destination_path);
 
                 match dest_path
                     .file_name()
                     .and_then(|os_str| os_str.to_os_string().into_string().ok())
                 {
                     Some(filename) if src_path.parent() == dest_path.parent() => {
+                        let filename = MarkdownString::escape(&filename);
                         format!("Rename `{src}` to `{filename}`")
                     }
                     _ => {

crates/assistant_tools/src/read_file_tool.rs 🔗

@@ -10,6 +10,7 @@ use project::Project;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use ui::IconName;
+use util::markdown::MarkdownString;
 
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
 pub struct ReadFileToolInput {
@@ -64,7 +65,10 @@ impl Tool for ReadFileTool {
 
     fn ui_text(&self, input: &serde_json::Value) -> String {
         match serde_json::from_value::<ReadFileToolInput>(input.clone()) {
-            Ok(input) => format!("Read file `{}`", input.path.display()),
+            Ok(input) => {
+                let path = MarkdownString::escape(&input.path.display().to_string());
+                format!("Read file `{path}`")
+            }
             Err(_) => "Read file".to_string(),
         }
     }

crates/assistant_tools/src/regex_search_tool.rs 🔗

@@ -12,6 +12,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use std::{cmp, fmt::Write, sync::Arc};
 use ui::IconName;
+use util::markdown::MarkdownString;
 use util::paths::PathMatcher;
 
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
@@ -63,14 +64,12 @@ impl Tool for RegexSearchTool {
         match serde_json::from_value::<RegexSearchToolInput>(input.clone()) {
             Ok(input) => {
                 let page = input.page();
+                let regex = MarkdownString::escape(&input.regex);
 
                 if page > 1 {
-                    format!(
-                        "Get page {page} of search results for regex “`{}`”",
-                        input.regex
-                    )
+                    format!("Get page {page} of search results for regex “`{regex}`”")
                 } else {
-                    format!("Search files for regex “`{}`”", input.regex)
+                    format!("Search files for regex “`{regex}`”")
                 }
             }
             Err(_) => "Search with regex".to_string(),

crates/util/src/markdown.rs 🔗

@@ -19,7 +19,7 @@ impl MarkdownString {
     /// * `$` for inline math
     /// * `~` for strikethrough
     ///
-    /// Escape of some character is unnecessary because while they are involved in markdown syntax,
+    /// Escape of some characters is unnecessary, because while they are involved in markdown syntax,
     /// the other characters involved are escaped:
     ///
     /// * `!`, `]`, `(`, and `)` are used in link syntax, but `[` is escaped so these are parsed as