Skip to main content

rab/extensions/
file_search.rs

1use crate::agent::extension::{Extension, ToolDefinition, ToolRenderContext, ToolRenderer};
2use crate::tui::Theme;
3use crate::tui::ThemeKey;
4use async_trait::async_trait;
5use std::borrow::Cow;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9// ── Shared command execution ─────────────────────────────────────
10
11/// Output of a shell command execution.
12pub struct ExecOutput {
13    pub stdout: String,
14    pub stderr: String,
15    pub exit_code: Option<i32>,
16}
17
18/// Run a shell command in the given working directory (shared helper for default ops).
19async fn run_shell_command(command: &str, cwd: &Path) -> anyhow::Result<ExecOutput> {
20    let output = tokio::process::Command::new("sh")
21        .arg("-c")
22        .arg(command)
23        .current_dir(cwd)
24        .output()
25        .await?;
26    Ok(ExecOutput {
27        stdout: String::from_utf8_lossy(&output.stdout).to_string(),
28        stderr: String::from_utf8_lossy(&output.stderr).to_string(),
29        exit_code: output.status.code(),
30    })
31}
32
33// ── GrepOperations (pluggable) ──────────────────────────────────
34
35/// Pluggable operations for the grep tool (matching pi's GrepOperations).
36/// Override these to delegate command execution to remote systems.
37#[async_trait]
38pub trait GrepOperations: Send + Sync {
39    /// Execute a shell command in the given working directory.
40    /// Returns stdout, stderr, and exit code.
41    async fn exec(&self, command: &str, cwd: &Path) -> anyhow::Result<ExecOutput>;
42}
43
44struct DefaultGrepOperations;
45
46#[async_trait]
47impl GrepOperations for DefaultGrepOperations {
48    async fn exec(&self, command: &str, cwd: &Path) -> anyhow::Result<ExecOutput> {
49        run_shell_command(command, cwd).await
50    }
51}
52
53// ── FindOperations (pluggable) ───────────────────────────────────
54
55/// Pluggable operations for the find tool (matching pi's FindOperations).
56/// Override these to delegate command execution to remote systems.
57#[async_trait]
58pub trait FindOperations: Send + Sync {
59    /// Execute a shell command in the given working directory.
60    /// Returns stdout, stderr, and exit code.
61    async fn exec(&self, command: &str, cwd: &Path) -> anyhow::Result<ExecOutput>;
62}
63
64struct DefaultFindOperations;
65
66#[async_trait]
67impl FindOperations for DefaultFindOperations {
68    async fn exec(&self, command: &str, cwd: &Path) -> anyhow::Result<ExecOutput> {
69        run_shell_command(command, cwd).await
70    }
71}
72
73// ── LsOperations (pluggable) ─────────────────────────────────────
74
75/// A directory entry for the ls tool.
76pub struct DirEntry {
77    pub name: String,
78    pub is_dir: bool,
79}
80
81/// Pluggable operations for the ls tool (matching pi's LsOperations).
82/// Override these to delegate directory listing to remote systems.
83pub trait LsOperations: Send + Sync {
84    /// List entries in a directory, returning name and type.
85    fn read_dir(&self, path: &Path) -> anyhow::Result<Vec<DirEntry>>;
86    /// Check if path is a directory.
87    fn is_dir(&self, path: &Path) -> anyhow::Result<bool>;
88    /// Check if path exists.
89    fn path_exists(&self, path: &Path) -> anyhow::Result<bool>;
90}
91
92struct DefaultLsOperations;
93
94impl LsOperations for DefaultLsOperations {
95    fn read_dir(&self, path: &Path) -> anyhow::Result<Vec<DirEntry>> {
96        let rd = std::fs::read_dir(path)?;
97        let mut items: Vec<DirEntry> = rd
98            .flatten()
99            .map(|entry| DirEntry {
100                name: entry.file_name().to_string_lossy().to_string(),
101                is_dir: entry.file_type().map(|t| t.is_dir()).unwrap_or(false),
102            })
103            .collect();
104        items.sort_by_key(|e| e.name.to_lowercase());
105        Ok(items)
106    }
107    fn is_dir(&self, path: &Path) -> anyhow::Result<bool> {
108        Ok(std::fs::metadata(path).map(|m| m.is_dir()).unwrap_or(false))
109    }
110    fn path_exists(&self, path: &Path) -> anyhow::Result<bool> {
111        Ok(path.exists())
112    }
113}
114
115/// Extension providing grep, find, and ls tools.
116pub struct FileSearchExtension {
117    cwd: PathBuf,
118    grep_operations: Arc<dyn GrepOperations>,
119    find_operations: Arc<dyn FindOperations>,
120    ls_operations: Arc<dyn LsOperations>,
121}
122
123impl FileSearchExtension {
124    pub fn new(cwd: PathBuf) -> Self {
125        Self {
126            cwd,
127            grep_operations: Arc::new(DefaultGrepOperations),
128            find_operations: Arc::new(DefaultFindOperations),
129            ls_operations: Arc::new(DefaultLsOperations),
130        }
131    }
132
133    /// Set custom grep operations (e.g. for SSH targets).
134    pub fn with_grep_operations(mut self, ops: Arc<dyn GrepOperations>) -> Self {
135        self.grep_operations = ops;
136        self
137    }
138
139    /// Set custom find operations (e.g. for SSH targets).
140    pub fn with_find_operations(mut self, ops: Arc<dyn FindOperations>) -> Self {
141        self.find_operations = ops;
142        self
143    }
144
145    /// Set custom ls operations (e.g. for SSH targets).
146    pub fn with_ls_operations(mut self, ops: Arc<dyn LsOperations>) -> Self {
147        self.ls_operations = ops;
148        self
149    }
150}
151
152impl Extension for FileSearchExtension {
153    fn name(&self) -> Cow<'static, str> {
154        "file_search".into()
155    }
156
157    fn as_any(&self) -> &dyn std::any::Any {
158        self
159    }
160
161    fn tools(&self) -> Vec<ToolDefinition> {
162        vec![
163            ToolDefinition {
164                tool: Box::new(GrepTool {
165                    cwd: self.cwd.clone(),
166                    operations: self.grep_operations.clone(),
167                }),
168                snippet: "Search file contents for patterns (respects .gitignore)",
169                guidelines: &["Use grep for searching file contents with patterns"],
170                prepare_arguments: None,
171                before_tool_call: None,
172                after_tool_call: None,
173                renderer: Some(std::sync::Arc::new(ListRenderer::grep())),
174            },
175            ToolDefinition {
176                tool: Box::new(FindTool {
177                    cwd: self.cwd.clone(),
178                    operations: self.find_operations.clone(),
179                }),
180                snippet: "Find files by glob pattern (respects .gitignore)",
181                guidelines: &["Use find for locating files by pattern"],
182                prepare_arguments: None,
183                before_tool_call: None,
184                after_tool_call: None,
185                renderer: Some(std::sync::Arc::new(ListRenderer::find())),
186            },
187            ToolDefinition {
188                tool: Box::new(LsTool {
189                    cwd: self.cwd.clone(),
190                    operations: self.ls_operations.clone(),
191                }),
192                snippet: "List directory contents",
193                guidelines: &["Use ls for exploring directory structure"],
194                prepare_arguments: None,
195                before_tool_call: None,
196                after_tool_call: None,
197                renderer: Some(std::sync::Arc::new(ListRenderer::ls())),
198            },
199        ]
200    }
201}
202
203// ── Constants ────────────────────────────────────────────────────
204
205const GREP_DEFAULT_LIMIT: u64 = 100;
206const GREP_MAX_LINE_LENGTH: usize = 500;
207const FIND_DEFAULT_LIMIT: u64 = 1000;
208const LS_DEFAULT_LIMIT: u64 = 500;
209
210// =====================================================================
211// grep tool
212// =====================================================================
213
214struct GrepTool {
215    cwd: PathBuf,
216    operations: Arc<dyn GrepOperations>,
217}
218
219#[async_trait]
220impl yoagent::types::AgentTool for GrepTool {
221    fn name(&self) -> &str {
222        "grep"
223    }
224    fn label(&self) -> &str {
225        "grep"
226    }
227    fn description(&self) -> &str {
228        "Search file contents for a pattern. Returns matching lines with file paths and line numbers. \
229         Respects .gitignore. Output is truncated to 100 matches. \
230         Long lines are truncated to 500 chars."
231    }
232    fn parameters_schema(&self) -> serde_json::Value {
233        serde_json::json!({
234            "type": "object",
235            "required": ["pattern"],
236            "properties": {
237                "pattern": {
238                    "type": "string",
239                    "description": "Search pattern (regex or literal string)"
240                },
241                "path": {
242                    "type": "string",
243                    "description": "Directory or file to search (default: current directory)"
244                },
245                "glob": {
246                    "type": "string",
247                    "description": "Filter files by glob pattern, e.g. '*.rs' or '**/*.spec.rs'"
248                },
249                "ignoreCase": {
250                    "type": "boolean",
251                    "description": "Case-insensitive search (default: false)"
252                },
253                "literal": {
254                    "type": "boolean",
255                    "description": "Treat pattern as literal string instead of regex (default: false)"
256                },
257                "context": {
258                    "type": "number",
259                    "description": "Number of lines to show before and after each match (default: 0)"
260                },
261                "limit": {
262                    "type": "number",
263                    "description": "Maximum number of matches to return (default: 100)"
264                }
265            }
266        })
267    }
268
269    async fn execute(
270        &self,
271        params: serde_json::Value,
272        ctx: yoagent::types::ToolContext,
273    ) -> Result<yoagent::types::ToolResult, yoagent::types::ToolError> {
274        let pattern = params["pattern"].as_str().ok_or_else(|| {
275            yoagent::types::ToolError::InvalidArgs("Missing 'pattern' argument".into())
276        })?;
277        let search_path = params["path"].as_str().unwrap_or(".");
278        let search_owned = resolve_path(search_path, &self.cwd);
279        let abs_search = &search_owned;
280
281        let glob = params["glob"].as_str();
282        let ignore_case = params["ignoreCase"].as_bool().unwrap_or(false);
283        let literal = params["literal"].as_bool().unwrap_or(false);
284        let context = params["context"].as_u64().unwrap_or(0);
285        let limit = params["limit"].as_u64().unwrap_or(GREP_DEFAULT_LIMIT);
286
287        if !abs_search.exists() {
288            return Err(yoagent::types::ToolError::Failed(format!(
289                "Path not found: {}",
290                abs_search.display()
291            )));
292        }
293
294        if ctx.cancel.is_cancelled() {
295            return Err(yoagent::types::ToolError::Cancelled);
296        }
297
298        // Try ripgrep first, fall back to grep
299        let output = if let Some(rg) = which("rg") {
300            run_rg_with_ops(
301                self.operations.as_ref(),
302                &self.cwd,
303                &rg,
304                pattern,
305                abs_search,
306                glob,
307                ignore_case,
308                literal,
309                context,
310                limit,
311            )
312            .await?
313        } else {
314            run_grep_with_ops(
315                self.operations.as_ref(),
316                &self.cwd,
317                pattern,
318                abs_search,
319                ignore_case,
320                literal,
321                context,
322                limit,
323            )
324            .await?
325        };
326
327        if ctx.cancel.is_cancelled() {
328            return Err(yoagent::types::ToolError::Cancelled);
329        }
330
331        Ok(yoagent::types::ToolResult {
332            content: vec![yoagent::types::Content::Text { text: output }],
333            details: serde_json::Value::Null,
334        })
335    }
336}
337
338/// Build a ripgrep command string and execute via operations.
339#[allow(clippy::too_many_arguments)]
340async fn run_rg_with_ops(
341    ops: &dyn GrepOperations,
342    cwd: &Path,
343    rg: &Path,
344    pattern: &str,
345    search_path: &Path,
346    glob: Option<&str>,
347    ignore_case: bool,
348    literal: bool,
349    context: u64,
350    limit: u64,
351) -> Result<String, yoagent::types::ToolError> {
352    let mut cmd_parts: Vec<String> = vec![
353        rg.to_string_lossy().to_string(),
354        "--json".into(),
355        "--line-number".into(),
356        "--color=never".into(),
357        "--hidden".into(),
358    ];
359    if ignore_case {
360        cmd_parts.push("--ignore-case".into());
361    }
362    if literal {
363        cmd_parts.push("--fixed-strings".into());
364    }
365    if let Some(g) = glob {
366        cmd_parts.push("--glob".into());
367        cmd_parts.push(shell_escape(g));
368    }
369    if context > 0 {
370        cmd_parts.push("-C".into());
371        cmd_parts.push(context.to_string());
372    }
373    cmd_parts.push("--max-count".into());
374    cmd_parts.push(limit.to_string());
375    cmd_parts.push("--".into());
376    cmd_parts.push(shell_escape(pattern));
377    cmd_parts.push(shell_escape(&search_path.to_string_lossy()));
378
379    let command = cmd_parts.join(" ");
380    let exec_output = ops
381        .exec(&command, cwd)
382        .await
383        .map_err(|e| yoagent::types::ToolError::Failed(format!("Failed to run rg: {}", e)))?;
384
385    let exit_code = exec_output.exit_code.unwrap_or(-1);
386    if exit_code == 2 {
387        return Err(yoagent::types::ToolError::Failed(format!(
388            "ripgrep error: {}",
389            exec_output.stderr.trim()
390        )));
391    }
392
393    let stdout = &exec_output.stdout;
394    let mut results: Vec<String> = Vec::new();
395    let mut line_count = 0u64;
396
397    for line in stdout.lines() {
398        if line.trim().is_empty() {
399            continue;
400        }
401        if line_count >= limit {
402            break;
403        }
404
405        if let Ok(event) = serde_json::from_str::<serde_json::Value>(line)
406            && event["type"] == "match"
407            && let (Some(file_path), Some(line_number), Some(line_text)) = (
408                event["data"]["path"]["text"].as_str(),
409                event["data"]["line_number"].as_u64(),
410                event["data"]["lines"]["text"].as_str(),
411            )
412        {
413            let relative = relativize_path(file_path, search_path);
414            let sanitized = line_text
415                .replace('\r', "")
416                .trim_end_matches('\n')
417                .to_string();
418            results.push(format!(
419                "{}:{}: {}",
420                relative,
421                line_number,
422                truncate_line(&sanitized, GREP_MAX_LINE_LENGTH)
423            ));
424            line_count += 1;
425        }
426    }
427
428    if results.is_empty() {
429        return Ok("No matches found".to_string());
430    }
431
432    Ok(results.join("\n"))
433}
434
435#[allow(clippy::too_many_arguments)]
436async fn run_grep_with_ops(
437    ops: &dyn GrepOperations,
438    cwd: &Path,
439    pattern: &str,
440    search_path: &Path,
441    ignore_case: bool,
442    literal: bool,
443    context: u64,
444    limit: u64,
445) -> Result<String, yoagent::types::ToolError> {
446    let mut cmd_parts: Vec<String> = vec![
447        "grep".into(),
448        "--line-number".into(),
449        "--color=never".into(),
450        "--binary-files=without-match".into(),
451    ];
452    if ignore_case {
453        cmd_parts.push("-i".into());
454    }
455    if literal {
456        cmd_parts.push("-F".into());
457    }
458    if context > 0 {
459        cmd_parts.push("-C".into());
460        cmd_parts.push(context.to_string());
461    }
462    cmd_parts.push("--max-count".into());
463    cmd_parts.push(limit.to_string());
464    cmd_parts.push("-r".into());
465    cmd_parts.push("--".into());
466    cmd_parts.push(shell_escape(pattern));
467    cmd_parts.push(shell_escape(&search_path.to_string_lossy()));
468
469    let command = cmd_parts.join(" ");
470    let exec_output = ops
471        .exec(&command, cwd)
472        .await
473        .map_err(|e| yoagent::types::ToolError::Failed(format!("Failed to run grep: {}", e)))?;
474
475    let exit_code = exec_output.exit_code.unwrap_or(-1);
476    if exit_code == 2 {
477        return Err(yoagent::types::ToolError::Failed(format!(
478            "grep error: {}",
479            exec_output.stderr.trim()
480        )));
481    }
482
483    let trimmed = exec_output.stdout.trim();
484    if trimmed.is_empty() {
485        return Ok("No matches found".to_string());
486    }
487
488    let lines: Vec<&str> = trimmed.lines().collect();
489    let truncated: Vec<String> = lines
490        .iter()
491        .take(limit as usize)
492        .map(|l| truncate_line(l, GREP_MAX_LINE_LENGTH))
493        .collect();
494
495    Ok(truncated.join("\n"))
496}
497
498// =====================================================================
499// find tool
500// =====================================================================
501
502struct FindTool {
503    cwd: PathBuf,
504    operations: Arc<dyn FindOperations>,
505}
506
507#[async_trait]
508impl yoagent::types::AgentTool for FindTool {
509    fn name(&self) -> &str {
510        "find"
511    }
512    fn label(&self) -> &str {
513        "find"
514    }
515    fn description(&self) -> &str {
516        "Search for files by glob pattern. Returns matching file paths relative to the search directory. \
517         Respects .gitignore. Output is truncated to 1000 results."
518    }
519    fn parameters_schema(&self) -> serde_json::Value {
520        serde_json::json!({
521            "type": "object",
522            "required": ["pattern"],
523            "properties": {
524                "pattern": {
525                    "type": "string",
526                    "description": "Glob pattern to match files, e.g. '*.rs', '**/*.json', or 'src/**/*.spec.rs'"
527                },
528                "path": {
529                    "type": "string",
530                    "description": "Directory to search in (default: current directory)"
531                },
532                "limit": {
533                    "type": "number",
534                    "description": "Maximum number of results (default: 1000)"
535                }
536            }
537        })
538    }
539
540    async fn execute(
541        &self,
542        params: serde_json::Value,
543        ctx: yoagent::types::ToolContext,
544    ) -> Result<yoagent::types::ToolResult, yoagent::types::ToolError> {
545        let pattern = params["pattern"].as_str().ok_or_else(|| {
546            yoagent::types::ToolError::InvalidArgs("Missing 'pattern' argument".into())
547        })?;
548        let search_path = params["path"].as_str().unwrap_or(".");
549        let search_owned = resolve_path(search_path, &self.cwd);
550        let abs_search = &search_owned;
551        let limit = params["limit"].as_u64().unwrap_or(FIND_DEFAULT_LIMIT);
552
553        if !abs_search.exists() {
554            return Err(yoagent::types::ToolError::Failed(format!(
555                "Path not found: {}",
556                abs_search.display()
557            )));
558        }
559
560        if ctx.cancel.is_cancelled() {
561            return Err(yoagent::types::ToolError::Cancelled);
562        }
563
564        let output = if let Some(fd_path) = which("fd") {
565            run_fd_with_ops(
566                self.operations.as_ref(),
567                &self.cwd,
568                &fd_path,
569                pattern,
570                abs_search,
571                limit,
572            )
573            .await?
574        } else {
575            run_find_with_ops(
576                self.operations.as_ref(),
577                &self.cwd,
578                pattern,
579                abs_search,
580                limit,
581            )
582            .await?
583        };
584
585        if ctx.cancel.is_cancelled() {
586            return Err(yoagent::types::ToolError::Cancelled);
587        }
588
589        Ok(yoagent::types::ToolResult {
590            content: vec![yoagent::types::Content::Text { text: output }],
591            details: serde_json::Value::Null,
592        })
593    }
594}
595
596async fn run_fd_with_ops(
597    ops: &dyn FindOperations,
598    cwd: &Path,
599    fd: &Path,
600    pattern: &str,
601    search_path: &Path,
602    limit: u64,
603) -> Result<String, yoagent::types::ToolError> {
604    // Build effective pattern for fd
605    let effective_pattern = if pattern.contains('/') {
606        if !pattern.starts_with('/') && !pattern.starts_with("**/") && pattern != "**" {
607            format!("**/{}", pattern)
608        } else {
609            pattern.to_string()
610        }
611    } else {
612        pattern.to_string()
613    };
614
615    // Build fd command string
616    let mut cmd_parts: Vec<String> = vec![
617        fd.to_string_lossy().to_string(),
618        "--glob".into(),
619        "--color=never".into(),
620        "--hidden".into(),
621        "--no-require-git".into(),
622        "--max-results".into(),
623        limit.to_string(),
624    ];
625    if pattern.contains('/') {
626        cmd_parts.push("--full-path".into());
627    }
628    cmd_parts.push("--".into());
629    cmd_parts.push(shell_escape(&effective_pattern));
630    cmd_parts.push(shell_escape(&search_path.to_string_lossy()));
631
632    let command = cmd_parts.join(" ");
633    let exec_output = ops
634        .exec(&command, cwd)
635        .await
636        .map_err(|e| yoagent::types::ToolError::Failed(format!("Failed to run fd: {}", e)))?;
637
638    let exit_code = exec_output.exit_code.unwrap_or(-1);
639    if exit_code != 0 && exit_code != 1 && exec_output.stdout.trim().is_empty() {
640        return Err(yoagent::types::ToolError::Failed(format!(
641            "fd error: {}",
642            exec_output.stderr.trim()
643        )));
644    }
645
646    let results: Vec<String> = exec_output
647        .stdout
648        .lines()
649        .map(|l| l.trim().to_string())
650        .filter(|l| !l.is_empty())
651        .collect();
652
653    if results.is_empty() {
654        return Ok("No files found matching pattern".to_string());
655    }
656
657    let relativized: Vec<String> = results
658        .into_iter()
659        .map(|line| relativize_path(&line, search_path))
660        .collect();
661
662    let mut output = relativized.join("\n");
663    if relativized.len() >= limit as usize {
664        output.push_str(&format!(
665            "\n\n[{} results limit reached. Use limit={} for more, or refine pattern]",
666            limit,
667            limit * 2,
668        ));
669    }
670
671    Ok(output)
672}
673
674async fn run_find_with_ops(
675    ops: &dyn FindOperations,
676    cwd: &Path,
677    pattern: &str,
678    search_path: &Path,
679    limit: u64,
680) -> Result<String, yoagent::types::ToolError> {
681    let name_pattern = pattern.trim_start_matches("**/").trim_start_matches("*/");
682
683    let cmd_parts: Vec<String> = vec![
684        "find".into(),
685        shell_escape(&search_path.to_string_lossy()),
686        "-name".into(),
687        shell_escape(name_pattern),
688        "-not".into(),
689        "-path".into(),
690        "*/node_modules/*".into(),
691        "-not".into(),
692        "-path".into(),
693        "*/.git/*".into(),
694    ];
695
696    let command = cmd_parts.join(" ");
697    let exec_output = ops
698        .exec(&command, cwd)
699        .await
700        .map_err(|e| yoagent::types::ToolError::Failed(format!("Failed to run find: {}", e)))?;
701
702    let exit_code = exec_output.exit_code.unwrap_or(-1);
703    if exit_code != 0 && exit_code != 1 {
704        return Err(yoagent::types::ToolError::Failed(format!(
705            "find error: {}",
706            exec_output.stderr.trim()
707        )));
708    }
709
710    let lines: Vec<String> = exec_output
711        .stdout
712        .lines()
713        .map(|l| l.trim().to_string())
714        .filter(|l| !l.is_empty())
715        .collect();
716
717    if lines.is_empty() {
718        return Ok("No files found matching pattern".to_string());
719    }
720
721    let relativized: Vec<String> = lines
722        .into_iter()
723        .take(limit as usize)
724        .map(|line| relativize_path(&line, search_path))
725        .collect();
726
727    let mut output = relativized.join("\n");
728    if relativized.len() >= limit as usize {
729        output.push_str(&format!(
730            "\n\n[{} results limit reached. Use limit={} for more, or refine pattern]",
731            limit,
732            limit * 2,
733        ));
734    }
735
736    Ok(output)
737}
738
739// =====================================================================
740// ls tool
741// =====================================================================
742
743struct LsTool {
744    cwd: PathBuf,
745    operations: Arc<dyn LsOperations>,
746}
747
748#[async_trait]
749impl yoagent::types::AgentTool for LsTool {
750    fn name(&self) -> &str {
751        "ls"
752    }
753    fn label(&self) -> &str {
754        "ls"
755    }
756    fn description(&self) -> &str {
757        "List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. \
758         Includes dotfiles. Output is truncated to 500 entries."
759    }
760    fn parameters_schema(&self) -> serde_json::Value {
761        serde_json::json!({
762            "type": "object",
763            "properties": {
764                "path": {
765                    "type": "string",
766                    "description": "Directory to list (default: current directory)"
767                },
768                "limit": {
769                    "type": "number",
770                    "description": "Maximum number of entries to return (default: 500)"
771                }
772            }
773        })
774    }
775
776    async fn execute(
777        &self,
778        params: serde_json::Value,
779        ctx: yoagent::types::ToolContext,
780    ) -> Result<yoagent::types::ToolResult, yoagent::types::ToolError> {
781        let search_path = params["path"].as_str().unwrap_or(".");
782        let limit = params["limit"].as_u64().unwrap_or(LS_DEFAULT_LIMIT);
783
784        let abs_path = resolve_path(search_path, &self.cwd);
785
786        if !self.operations.path_exists(&abs_path).unwrap_or(false) {
787            return Err(yoagent::types::ToolError::Failed(format!(
788                "Path not found: {}",
789                abs_path.display()
790            )));
791        }
792        if !self.operations.is_dir(&abs_path).unwrap_or(false) {
793            return Err(yoagent::types::ToolError::Failed(format!(
794                "Not a directory: {}",
795                abs_path.display()
796            )));
797        }
798
799        if ctx.cancel.is_cancelled() {
800            return Err(yoagent::types::ToolError::Cancelled);
801        }
802
803        let entries: Vec<String> = match self.operations.read_dir(&abs_path) {
804            Ok(items) => {
805                let mut items: Vec<(String, bool)> = items
806                    .into_iter()
807                    .map(|entry| (entry.name, entry.is_dir))
808                    .collect();
809                items.sort_by_key(|(a, _)| a.to_lowercase());
810                items
811                    .into_iter()
812                    .take(limit as usize)
813                    .map(
814                        |(name, is_dir)| {
815                            if is_dir { format!("{}/", name) } else { name }
816                        },
817                    )
818                    .collect()
819            }
820            Err(e) => {
821                return Err(yoagent::types::ToolError::Failed(format!(
822                    "Cannot read directory: {}",
823                    e
824                )));
825            }
826        };
827
828        if ctx.cancel.is_cancelled() {
829            return Err(yoagent::types::ToolError::Cancelled);
830        }
831
832        if entries.is_empty() {
833            return Ok(yoagent::types::ToolResult {
834                content: vec![yoagent::types::Content::Text {
835                    text: "(empty directory)".to_string(),
836                }],
837                details: serde_json::Value::Null,
838            });
839        }
840
841        let mut output = entries.join("\n");
842        if entries.len() >= limit as usize {
843            output.push_str(&format!(
844                "\n\n[{} entries limit reached. Use limit={} for more]",
845                limit,
846                limit * 2,
847            ));
848        }
849
850        Ok(yoagent::types::ToolResult {
851            content: vec![yoagent::types::Content::Text { text: output }],
852            details: serde_json::Value::Null,
853        })
854    }
855}
856
857// =====================================================================
858// Helpers
859// =====================================================================
860
861/// Shell-escape a string for safe use in `sh -c`.
862/// Wraps in single quotes and handles embedded single quotes
863/// (POSIX shell: everything inside '' is literal except ',
864/// which is written as '\'' to end the quote, add an escaped
865/// quote, and restart the quote).
866fn shell_escape(s: &str) -> String {
867    let mut result = String::with_capacity(s.len() + 2);
868    result.push('\'');
869    for c in s.chars() {
870        if c == '\'' {
871            result.push_str("'\\''");
872        } else {
873            result.push(c);
874        }
875    }
876    result.push('\'');
877    result
878}
879
880fn which(name: &str) -> Option<PathBuf> {
881    std::process::Command::new("which")
882        .arg(name)
883        .output()
884        .ok()
885        .filter(|o| o.status.success())
886        .map(|_| PathBuf::from(name))
887}
888
889fn resolve_path(path: &str, cwd: &Path) -> PathBuf {
890    if Path::new(path).is_absolute() {
891        Path::new(path).to_path_buf()
892    } else {
893        cwd.join(path)
894    }
895}
896
897fn relativize_path(path: &str, search_root: &Path) -> String {
898    let p = Path::new(path);
899    if let Ok(rel) = p.strip_prefix(search_root) {
900        rel.to_string_lossy().replace('\\', "/")
901    } else {
902        p.file_name()
903            .map(|n| n.to_string_lossy().to_string())
904            .unwrap_or_else(|| path.to_string())
905    }
906}
907
908fn truncate_line(line: &str, max_chars: usize) -> String {
909    if line.len() <= max_chars {
910        line.to_string()
911    } else {
912        format!("{}... [truncated]", &line[..max_chars])
913    }
914}
915
916fn shorten_path_str(path: &str) -> String {
917    if let Ok(home) = std::env::var("HOME") {
918        path.replacen(&home, "~", 1)
919    } else if path == "." || path.is_empty() {
920        ".".to_string()
921    } else {
922        path.to_string()
923    }
924}
925
926// =====================================================================
927// Shared list renderer (used by grep, find, ls)
928// =====================================================================
929
930struct ListRenderer {
931    tool_name: &'static str,
932    /// Format: "{pattern}" or "/{pattern}/" or empty for no pattern.
933    pattern_format: &'static str,
934    no_results_text: &'static str,
935    collapsed_lines: usize,
936    show_glob: bool,
937}
938
939impl ListRenderer {
940    fn grep() -> Self {
941        Self {
942            tool_name: "grep",
943            pattern_format: "/{}/ ",
944            no_results_text: "No matches found",
945            collapsed_lines: 15,
946            show_glob: true,
947        }
948    }
949
950    fn find() -> Self {
951        Self {
952            tool_name: "find",
953            pattern_format: "{} in ",
954            no_results_text: "No files found matching pattern",
955            collapsed_lines: 20,
956            show_glob: false,
957        }
958    }
959
960    fn ls() -> Self {
961        Self {
962            tool_name: "ls",
963            pattern_format: "",
964            no_results_text: "(empty directory)",
965            collapsed_lines: 20,
966            show_glob: false,
967        }
968    }
969}
970
971impl ToolRenderer for ListRenderer {
972    fn render_call(
973        &self,
974        args: &serde_json::Value,
975        _width: usize,
976        theme: &dyn Theme,
977        _ctx: &ToolRenderContext,
978    ) -> Vec<String> {
979        let search_path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
980        let limit = args.get("limit").and_then(|v| v.as_u64());
981        let path_display = shorten_path_str(search_path);
982
983        let mut text = format!(
984            "{} {}{}",
985            theme.fg_key(ThemeKey::ToolTitle, &theme.bold(self.tool_name)),
986            if self.pattern_format.is_empty() {
987                String::new()
988            } else {
989                let p = args.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
990                theme.fg_key(ThemeKey::Accent, &self.pattern_format.replace("{}", p))
991            },
992            theme.fg_key(ThemeKey::ToolOutput, &path_display),
993        );
994
995        if self.show_glob
996            && let Some(g) = args.get("glob").and_then(|v| v.as_str())
997        {
998            text.push_str(&theme.fg_key(ThemeKey::ToolOutput, &format!(" ({})", g)));
999        }
1000        if let Some(l) = limit {
1001            text.push_str(&theme.fg_key(ThemeKey::ToolOutput, &format!(" limit {}", l)));
1002        }
1003
1004        vec![text]
1005    }
1006
1007    fn render_result(
1008        &self,
1009        content: &str,
1010        _width: usize,
1011        theme: &dyn Theme,
1012        ctx: &ToolRenderContext,
1013    ) -> Vec<String> {
1014        if content.is_empty() {
1015            return vec![];
1016        }
1017        if !ctx.expanded && !ctx.is_error {
1018            return vec![];
1019        }
1020
1021        let output = content.trim();
1022        if output.is_empty() || output == self.no_results_text {
1023            return vec![theme.fg_key(ThemeKey::ToolOutput, output)];
1024        }
1025
1026        let lines: Vec<&str> = output.lines().collect();
1027        let max_lines = if ctx.expanded {
1028            usize::MAX
1029        } else {
1030            self.collapsed_lines
1031        };
1032        let display: Vec<&str> = lines.iter().copied().take(max_lines).collect();
1033        let remaining = lines.len().saturating_sub(display.len());
1034
1035        let mut result = vec![String::new()];
1036        for line in &display {
1037            result.push(theme.fg_key(ThemeKey::ToolOutput, line));
1038        }
1039        if remaining > 0 {
1040            let hint = if !ctx.expand_key.is_empty() {
1041                format!(
1042                    "... ({} more lines, {} to expand)",
1043                    remaining, ctx.expand_key
1044                )
1045            } else {
1046                format!("... ({} more lines)", remaining)
1047            };
1048            result.push(theme.fg_key(ThemeKey::Muted, &hint));
1049        }
1050        result
1051    }
1052}