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 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
203const 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
210struct 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 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#[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
498struct 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 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 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
739struct 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
857fn 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
926struct ListRenderer {
931 tool_name: &'static str,
932 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}