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