Skip to main content

rab/builtin/
read.rs

1use crate::agent::extension::{Extension, ToolDefinition};
2use crate::agent::extension::{ToolRenderContext, ToolRenderer};
3use crate::tui::Theme;
4use crate::tui::ThemeKey;
5
6use base64::Engine as _;
7use std::borrow::Cow;
8use std::path::Path;
9use std::sync::Arc;
10use unicode_normalization::UnicodeNormalization;
11
12// ── ReadOperations (pluggable) ───────────────────────────────────
13
14/// Pluggable operations for the read tool (matching pi's ReadOperations).
15/// Override these to delegate file reading to remote systems (for example SSH).
16pub trait ReadOperations: Send + Sync {
17    /// Read entire file as raw bytes.
18    fn read_file(&self, absolute_path: &Path) -> anyhow::Result<Vec<u8>>;
19    /// Get file size in bytes.
20    fn file_size(&self, absolute_path: &Path) -> anyhow::Result<u64>;
21    /// Detect image MIME type from magic bytes. Returns `None` for non-images.
22    fn detect_image_mime(&self, absolute_path: &Path) -> anyhow::Result<Option<&'static str>>;
23    /// Read entire file as a UTF-8 string.
24    fn read_text_file(&self, absolute_path: &Path) -> anyhow::Result<String>;
25}
26
27struct DefaultReadOperations;
28
29impl ReadOperations for DefaultReadOperations {
30    fn read_file(&self, absolute_path: &Path) -> anyhow::Result<Vec<u8>> {
31        Ok(std::fs::read(absolute_path)?)
32    }
33    fn file_size(&self, absolute_path: &Path) -> anyhow::Result<u64> {
34        Ok(std::fs::metadata(absolute_path)?.len())
35    }
36    fn detect_image_mime(&self, absolute_path: &Path) -> anyhow::Result<Option<&'static str>> {
37        detect_image_mime(absolute_path).map_err(anyhow::Error::from)
38    }
39    fn read_text_file(&self, absolute_path: &Path) -> anyhow::Result<String> {
40        Ok(std::fs::read_to_string(absolute_path)?)
41    }
42}
43
44pub struct ReadExtension {
45    cwd: std::path::PathBuf,
46    operations: Arc<dyn ReadOperations>,
47}
48
49impl ReadExtension {
50    pub fn new(cwd: std::path::PathBuf) -> Self {
51        Self {
52            cwd,
53            operations: Arc::new(DefaultReadOperations),
54        }
55    }
56
57    /// Set custom read operations (e.g. for SSH targets).
58    pub fn with_operations(mut self, operations: Arc<dyn ReadOperations>) -> Self {
59        self.operations = operations;
60        self
61    }
62}
63
64impl Extension for ReadExtension {
65    fn name(&self) -> Cow<'static, str> {
66        "read".into()
67    }
68
69    fn as_any(&self) -> &dyn std::any::Any {
70        self
71    }
72
73    fn tools(&self) -> Vec<ToolDefinition> {
74        vec![ToolDefinition {
75            tool: Box::new(ReadTool {
76                cwd: self.cwd.clone(),
77                operations: self.operations.clone(),
78            }),
79            snippet: "Read file contents",
80            guidelines: &["Use read to examine files instead of cat or sed."],
81            prepare_arguments: None,
82            before_tool_call: None,
83            after_tool_call: None,
84            renderer: Some(std::sync::Arc::new(ReadRenderer {
85                cwd: self.cwd.clone(),
86            })),
87        }]
88    }
89}
90
91struct ReadTool {
92    cwd: std::path::PathBuf,
93    operations: Arc<dyn ReadOperations>,
94}
95
96// ── Constants ────────────────────────────────────────────────────
97
98const DEFAULT_MAX_LINES: usize = 2000;
99const DEFAULT_MAX_BYTES: usize = 50 * 1024; // 50KB
100
101// ── Helpers ──────────────────────────────────────────────────────
102
103/// Format bytes as a human-readable size string, matching pi's format.
104fn format_size(bytes: usize) -> String {
105    if bytes < 1024 {
106        format!("{}B", bytes)
107    } else if bytes < 1024 * 1024 {
108        format!("{:.1}KB", bytes as f64 / 1024.0)
109    } else {
110        format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
111    }
112}
113
114/// Trim trailing empty lines from a slice of lines.
115fn trim_trailing_empty_lines<'a>(lines: &'a [&'a str]) -> &'a [&'a str] {
116    let mut end = lines.len();
117    while end > 0 && lines[end - 1].is_empty() {
118        end -= 1;
119    }
120    &lines[..end]
121}
122
123// ── macOS path variant resolution (matching pi's resolveReadPathAsync) ──
124
125/// Try macOS filename variant: narrow no-break space before AM/PM.
126fn try_macos_am_pm_variant(path: &str) -> Option<String> {
127    // macOS screenshot names: "Screen Shot 2023-01-01 at 10.30.00 AM.png"
128    // Users type "10.30.00 AM" with regular space, macOS stores with narrow no-break space.
129    let narrow_nbsp = "\u{202F}";
130    if path.contains(" AM") || path.contains(" PM") {
131        let variant = path
132            .replace(" AM", &format!("{}AM", narrow_nbsp))
133            .replace(" PM", &format!("{}PM", narrow_nbsp));
134        if variant != path && std::path::Path::new(&variant).exists() {
135            return Some(variant);
136        }
137    }
138    None
139}
140
141/// Try macOS NFD filename variant.
142fn try_nfd_variant(path: &str) -> Option<String> {
143    // macOS stores filenames in NFD (decomposed) form.
144    // Try converting the user input to NFD.
145    let nfd: String = path.nfkd().collect();
146    if nfd != path && std::path::Path::new(&nfd).exists() {
147        return Some(nfd);
148    }
149    None
150}
151
152/// Try curly quote variant for macOS screenshot names.
153fn try_curly_quote_variant(path: &str) -> Option<String> {
154    // macOS uses U+2019 (right single quotation mark) in screenshot names like "Capture d'écran"
155    // Users typically type U+0027 (straight apostrophe)
156    let variant = path.replace('\'', "\u{2019}");
157    if variant != path && std::path::Path::new(&variant).exists() {
158        return Some(variant);
159    }
160    None
161}
162
163/// Resolve a read path trying macOS filename variants if the direct path doesn't exist.
164/// Matching pi's `resolveReadPath` (sync version).
165fn resolve_read_path(path: &str, cwd: &Path) -> std::path::PathBuf {
166    let resolved = {
167        let p = std::path::Path::new(path);
168        if p.is_absolute() {
169            p.to_path_buf()
170        } else {
171            cwd.join(p)
172        }
173    };
174
175    if resolved.exists() {
176        return resolved;
177    }
178
179    let resolved_str = resolved.to_string_lossy();
180
181    // Try macOS AM/PM variant
182    if let Some(variant) = try_macos_am_pm_variant(&resolved_str) {
183        return std::path::PathBuf::from(variant);
184    }
185
186    // Try NFD variant
187    if let Some(variant) = try_nfd_variant(&resolved_str) {
188        return std::path::PathBuf::from(variant);
189    }
190
191    // Try curly quote variant
192    if let Some(variant) = try_curly_quote_variant(&resolved_str) {
193        return std::path::PathBuf::from(variant);
194    }
195
196    resolved
197}
198
199// ── Image detection (magic bytes) ─────────────────────────────────────
200
201/// Detect image MIME type from file magic bytes (matching pi's
202/// `detectSupportedImageMimeTypeFromFile`). Uses the first 12 bytes
203/// of the file to identify the format.
204#[allow(clippy::redundant_guards)]
205fn detect_image_mime(path: &Path) -> std::io::Result<Option<&'static str>> {
206    use std::io::Read;
207    let mut file = std::fs::File::open(path)?;
208    let mut buf = [0u8; 12];
209    let n = file.read(&mut buf)?;
210    if n < 4 {
211        return Ok(None);
212    }
213    Ok(match &buf[..n] {
214        b if b.starts_with(b"\x89PNG\r\n\x1a\n") && n >= 8 => Some("image/png"),
215        b if b.starts_with(&[0xFF, 0xD8, 0xFF]) => Some("image/jpeg"),
216        b if b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a") => Some("image/gif"),
217        b if n >= 12 && b.starts_with(b"RIFF") && &b[8..12] == b"WEBP" => Some("image/webp"),
218        b if b.starts_with(b"\x89\x50\x4E\x47\x0D\x0A\x1A\x0A") => Some("image/png"),
219        _ => None,
220    })
221}
222
223/// Compact read classification matching pi's `CompactReadClassification`.
224#[derive(Debug, PartialEq)]
225enum CompactReadKind {
226    Resource,
227    Skill,
228}
229
230/// Build a compact classification for the read tool output, matching pi's `getCompactReadClassification`.
231/// Returns `None` for regular files.
232fn get_compact_read_classification(path: &str, cwd: &Path) -> Option<(CompactReadKind, String)> {
233    let abs_path = if Path::new(path).is_absolute() {
234        Path::new(path).to_path_buf()
235    } else {
236        cwd.join(path)
237    };
238
239    let file_name = abs_path.file_name()?.to_str()?;
240
241    // AGENTS.md / CLAUDE.md → resource with path relative to cwd
242    if file_name.eq_ignore_ascii_case("AGENTS.md") || file_name.eq_ignore_ascii_case("CLAUDE.md") {
243        let display = abs_path
244            .strip_prefix(cwd)
245            .unwrap_or(&abs_path)
246            .to_string_lossy()
247            .to_string();
248        return Some((CompactReadKind::Resource, display));
249    }
250
251    // SKILL.md → skill with parent directory name
252    if file_name == "SKILL.md"
253        && let Some(parent) = abs_path.parent()
254        && let Some(dir_name) = parent.file_name()
255    {
256        let dir_name = dir_name.to_str().unwrap_or("unknown");
257        return Some((CompactReadKind::Skill, dir_name.to_string()));
258    }
259
260    None
261}
262
263// ── Truncation ──────────────────────────────────────────────────
264
265/// Truncation result, mirroring pi's `TruncationResult`.
266struct TruncationResult {
267    content: String,
268    truncated: bool,
269    truncated_by: Option<&'static str>, // None | "lines" | "bytes"
270    output_lines: usize,
271    total_lines: usize,
272    first_line_exceeds_limit: bool,
273}
274
275/// Truncate content from the head, keeping complete lines that fit within limits.
276/// Never returns partial lines. If first line exceeds the byte limit,
277/// returns empty content with `first_line_exceeds_limit = true`.
278fn truncate_head(content: &str, max_lines: usize, max_bytes: usize) -> TruncationResult {
279    let total_bytes = content.len();
280    let lines: Vec<&str> = content.lines().collect();
281    let total_lines = lines.len();
282
283    // Check if no truncation needed
284    if total_lines <= max_lines && total_bytes <= max_bytes {
285        return TruncationResult {
286            content: content.to_string(),
287            truncated: false,
288            truncated_by: None,
289            output_lines: total_lines,
290            total_lines,
291            first_line_exceeds_limit: false,
292        };
293    }
294
295    // Check if first line alone exceeds the byte limit
296    if let Some(first) = lines.first()
297        && first.len() > max_bytes
298    {
299        return TruncationResult {
300            content: String::new(),
301            truncated: true,
302            truncated_by: Some("bytes"),
303            output_lines: 0,
304            total_lines,
305            first_line_exceeds_limit: true,
306        };
307    }
308
309    // Accumulate complete lines that fit within both limits
310    let mut output: Vec<&str> = Vec::new();
311    let mut byte_count: usize = 0;
312    let mut truncated_by = "lines";
313
314    for line in lines.iter().take(max_lines) {
315        let line_bytes = line.len();
316        let with_newline = if output.is_empty() {
317            line_bytes
318        } else {
319            line_bytes + 1 // +1 for the preceding newline
320        };
321
322        if byte_count + with_newline > max_bytes {
323            truncated_by = "bytes";
324            break;
325        }
326
327        output.push(line);
328        byte_count += with_newline;
329    }
330
331    if output.len() >= max_lines && byte_count <= max_bytes {
332        truncated_by = "lines";
333    }
334
335    TruncationResult {
336        content: output.join("\n"),
337        truncated: true,
338        truncated_by: Some(truncated_by),
339        output_lines: output.len(),
340        total_lines,
341        first_line_exceeds_limit: false,
342    }
343}
344
345#[async_trait::async_trait]
346impl yoagent::types::AgentTool for ReadTool {
347    fn name(&self) -> &str {
348        "read"
349    }
350    fn label(&self) -> &str {
351        "read"
352    }
353    fn description(&self) -> &str {
354        "Read the contents of a file. Supports text files and images (jpg, png, gif, webp). \
355         Images are sent as attachments. For text files, output is truncated to 2000 lines or \
356         50KB (whichever is hit first). Use offset/limit for large files. When you need the \
357         full file, continue with offset until complete."
358    }
359    fn parameters_schema(&self) -> serde_json::Value {
360        serde_json::json!({
361            "type": "object",
362            "required": ["path"],
363            "properties": {
364                "path": {
365                    "type": "string",
366                    "description": "Path to the file to read (relative or absolute)"
367                },
368                "offset": {
369                    "type": "number",
370                    "description": "Line number to start reading from (1-indexed)"
371                },
372                "limit": {
373                    "type": "number",
374                    "description": "Maximum number of lines to read"
375                }
376            }
377        })
378    }
379    async fn execute(
380        &self,
381        params: serde_json::Value,
382        ctx: yoagent::types::ToolContext,
383    ) -> std::result::Result<yoagent::types::ToolResult, yoagent::types::ToolError> {
384        let path = params["path"].as_str().ok_or_else(|| {
385            yoagent::types::ToolError::InvalidArgs("Missing 'path' argument".into())
386        })?;
387        let offset = params["offset"].as_u64().map(|o| o as usize).unwrap_or(0);
388        let limit = params["limit"].as_u64().map(|l| l as usize);
389
390        let abs_path = resolve_read_path(path, &self.cwd);
391
392        if ctx.cancel.is_cancelled() {
393            return Err(yoagent::types::ToolError::Cancelled);
394        }
395
396        // Check if the file is an image (magic byte detection, matching pi)
397        if let Ok(Some(mime)) = self.operations.detect_image_mime(&abs_path) {
398            let file_name = abs_path
399                .file_name()
400                .map(|n| n.to_string_lossy())
401                .unwrap_or_default()
402                .to_string();
403            let file_len = self.operations.file_size(&abs_path).unwrap_or(0) as usize;
404            let binary = self.operations.read_file(&abs_path).map_err(|e| {
405                yoagent::types::ToolError::Failed(format!(
406                    "Failed to read image {}: {}",
407                    abs_path.display(),
408                    e
409                ))
410            })?;
411            let b64 = base64::engine::general_purpose::STANDARD.encode(&binary);
412            let msg = format!(
413                "Read image file [{}] - {} ({})\n{}:{};base64,{}",
414                mime,
415                file_name,
416                format_size(file_len),
417                mime,
418                file_name,
419                b64,
420            );
421            return Ok(yoagent::types::ToolResult {
422                content: vec![yoagent::types::Content::Text { text: msg }],
423                details: serde_json::json!({
424                    "mimeType": mime,
425                    "fileName": file_name,
426                    "fileSize": file_len,
427                    "imageData": b64,
428                }),
429            });
430        }
431
432        let content = self.operations.read_text_file(&abs_path).map_err(|e| {
433            yoagent::types::ToolError::Failed(format!(
434                "Failed to read {}: {}",
435                abs_path.display(),
436                e
437            ))
438        })?;
439
440        let all_lines: Vec<&str> = content.split('\n').collect();
441        let total_file_lines = if content.ends_with('\n') {
442            all_lines.len() - 1
443        } else {
444            all_lines.len()
445        };
446
447        let start_line = if offset > 0 { offset - 1 } else { 0 };
448        if start_line >= total_file_lines {
449            return Err(yoagent::types::ToolError::Failed(format!(
450                "Offset {} is beyond end of file ({} lines total)",
451                offset, total_file_lines
452            )));
453        }
454
455        if ctx.cancel.is_cancelled() {
456            return Err(yoagent::types::ToolError::Cancelled);
457        }
458
459        let selected_content: String;
460        let user_limited_lines: Option<usize>;
461
462        if let Some(lim) = limit {
463            let end_line = (start_line + lim).min(total_file_lines);
464            let selected_lines = &all_lines[start_line..end_line];
465            selected_content = selected_lines.join("\n");
466            user_limited_lines = Some(end_line - start_line);
467        } else {
468            let selected_lines = &all_lines[start_line..];
469            selected_content = selected_lines.join("\n");
470            user_limited_lines = None;
471        }
472
473        let trunc = truncate_head(&selected_content, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
474
475        if trunc.first_line_exceeds_limit {
476            let first_line_bytes = format_size(all_lines[start_line].len());
477            let msg = format!(
478                "[Line {} is {}, exceeds {} limit. Use bash: sed -n '{}p' {} | head -c {}]",
479                start_line + 1,
480                first_line_bytes,
481                format_size(DEFAULT_MAX_BYTES),
482                start_line + 1,
483                path,
484                DEFAULT_MAX_BYTES,
485            );
486            return Ok(yoagent::types::ToolResult {
487                content: vec![yoagent::types::Content::Text { text: msg }],
488                details: serde_json::json!({
489                    "truncation": {
490                        "truncated": true,
491                        "truncatedBy": "bytes",
492                        "totalLines": trunc.total_lines,
493                        "outputLines": 0,
494                        "firstLineExceedsLimit": true,
495                        "maxLines": DEFAULT_MAX_LINES,
496                        "maxBytes": DEFAULT_MAX_BYTES,
497                    }
498                }),
499            });
500        }
501
502        let output: String;
503        let mut details: Option<serde_json::Value> = None;
504
505        if trunc.truncated {
506            let start_display = start_line + 1;
507            let end_display = start_display + trunc.output_lines - 1;
508            let next_offset = end_display + 1;
509
510            if trunc.truncated_by == Some("lines") {
511                output = format!(
512                    "{}\n\n[Showing lines {}-{} of {}. Use offset={} to continue.]",
513                    trunc.content, start_display, end_display, total_file_lines, next_offset,
514                );
515            } else {
516                output = format!(
517                    "{}\n\n[Showing lines {}-{} of {} ({} limit). Use offset={} to continue.]",
518                    trunc.content,
519                    start_display,
520                    end_display,
521                    total_file_lines,
522                    format_size(DEFAULT_MAX_BYTES),
523                    next_offset,
524                );
525            }
526            details = Some(serde_json::json!({
527                "truncation": {
528                    "truncated": true,
529                    "truncatedBy": trunc.truncated_by,
530                    "totalLines": trunc.total_lines,
531                    "outputLines": trunc.output_lines,
532                    "firstLineExceedsLimit": false,
533                    "maxLines": DEFAULT_MAX_LINES,
534                    "maxBytes": DEFAULT_MAX_BYTES,
535                }
536            }));
537        } else if let Some(ul) = user_limited_lines {
538            if start_line + ul < total_file_lines {
539                let remaining = total_file_lines - (start_line + ul);
540                let next_offset = start_line + ul + 1;
541                output = format!(
542                    "{}\n\n[{} more lines in file. Use offset={} to continue.]",
543                    trunc.content, remaining, next_offset,
544                );
545            } else {
546                let lines: Vec<&str> = trunc.content.lines().collect();
547                let trimmed = trim_trailing_empty_lines(&lines);
548                output = trimmed.join("\n");
549            }
550        } else {
551            let lines: Vec<&str> = trunc.content.lines().collect();
552            let trimmed = trim_trailing_empty_lines(&lines);
553            output = trimmed.join("\n");
554        }
555
556        Ok(yoagent::types::ToolResult {
557            content: vec![yoagent::types::Content::Text { text: output }],
558            details: details.unwrap_or(serde_json::Value::Null),
559        })
560    }
561}
562
563/// Tool renderer for the `read` tool.
564/// Formats call headers with compact labels and result content with syntax highlighting.
565struct ReadRenderer {
566    cwd: std::path::PathBuf,
567}
568
569impl ToolRenderer for ReadRenderer {
570    fn render_call(
571        &self,
572        args: &serde_json::Value,
573        _width: usize,
574        theme: &dyn Theme,
575        ctx: &ToolRenderContext,
576    ) -> Vec<String> {
577        use std::path::Path;
578        let path = args
579            .get("file_path")
580            .or_else(|| args.get("path"))
581            .and_then(|v| v.as_str())
582            .unwrap_or("");
583        let offset = args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0);
584        let limit = args.get("limit").and_then(|v| v.as_u64());
585
586        // Compute compact classification
587        let classification = if !ctx.expanded {
588            get_compact_read_classification(path, Path::new(&self.cwd))
589        } else {
590            None
591        };
592
593        // Format line range (matching pi's formatReadLineRange)
594        let range = if offset > 0 || limit.is_some() {
595            let start = if offset > 0 { offset } else { 1 };
596            let range_str = match limit {
597                Some(l) => format!(":{}-{}", start, start + l - 1),
598                None => format!(":{}", start),
599            };
600            theme.fg_key(ThemeKey::Warning, &range_str)
601        } else {
602            String::new()
603        };
604
605        // Expand hint (matching pi's compact read: `theme.fg("dim", " (${keyText} to expand)")`)
606        let expand_hint = if !ctx.expanded && !ctx.expand_key.is_empty() {
607            theme.fg_key(ThemeKey::Dim, &format!(" ({} to expand)", ctx.expand_key))
608        } else {
609            String::new()
610        };
611
612        if let Some((kind, label)) = classification {
613            match kind {
614                CompactReadKind::Skill => {
615                    // Pi: `[skill] name:range (Ctrl+O to expand)`
616                    // [skill] in customMessageLabel bold, name in customMessageText
617                    let prefix =
618                        theme.fg_key(ThemeKey::CustomMessageLabel, "\x1b[1m[skill]\x1b[22m ");
619                    let name = theme.fg_key(ThemeKey::CustomMessageText, &label);
620                    vec![format!("{}{}{}{}", prefix, name, range, expand_hint)]
621                }
622                CompactReadKind::Resource => {
623                    // Pi: `read resource  path:range (Ctrl+O to expand)`
624                    // "read resource" in bold toolTitle, path in accent
625                    let title_styled =
626                        theme.fg_key(ThemeKey::ToolTitle, &theme.bold("read resource"));
627                    let path_styled = theme.fg_key(ThemeKey::Accent, &label);
628                    vec![format!(
629                        "{} {}{}{}",
630                        title_styled, path_styled, range, expand_hint
631                    )]
632                }
633            }
634        } else {
635            // Regular call: `read  path:range`
636            let short = if let Ok(home) = std::env::var("HOME") {
637                path.replacen(&home, "~", 1)
638            } else {
639                path.to_string()
640            };
641            let path_disp = if short.is_empty() {
642                String::new()
643            } else {
644                theme.fg_key(ThemeKey::Accent, &short)
645            };
646            vec![format!(
647                "{} {}{}",
648                theme.fg_key(ThemeKey::ToolTitle, &theme.bold("read")),
649                path_disp,
650                range,
651            )]
652        }
653    }
654
655    fn render_result(
656        &self,
657        content: &str,
658        _width: usize,
659        theme: &dyn Theme,
660        ctx: &ToolRenderContext,
661    ) -> Vec<String> {
662        if content.is_empty() {
663            return vec![];
664        }
665
666        // Pi: return empty when collapsed and not error (result is hidden until expanded)
667        if !ctx.expanded && !ctx.is_error {
668            return vec![];
669        }
670
671        // If this is an image read, show image inline (Kitty protocol) or text fallback
672        if let Some(ref details) = ctx.details
673            && let Some(mime) = details.get("mimeType").and_then(|v| v.as_str())
674        {
675            let file_name = details
676                .get("fileName")
677                .and_then(|v| v.as_str())
678                .unwrap_or("");
679            let file_size = details
680                .get("fileSize")
681                .and_then(|v| v.as_u64())
682                .unwrap_or(0);
683            let size_str = format_size(file_size as usize);
684
685            // Try Kitty protocol image display (inline)
686            if crate::tui::components::markdown::kitty_images_supported()
687                && let Some(b64) = details.get("imageData").and_then(|v| v.as_str())
688                && let Ok(binary) = crate::builtin::base64_decode(b64)
689            {
690                let kitty_seq =
691                    crate::tui::components::markdown::kitty_image_sequence(&binary, mime);
692                return vec![
693                    String::new(),
694                    kitty_seq,
695                    theme.fg_key(
696                        ThemeKey::ToolOutput,
697                        &format!("Read image file [{}] - {} ({})", mime, file_name, size_str),
698                    ),
699                ];
700            }
701
702            // Fallback: text summary
703            return vec![
704                String::new(),
705                theme.fg_key(ThemeKey::ToolOutput, &format!("Read image file [{}]", mime)),
706                theme.fg_key(ThemeKey::ToolOutput, &format!("  File: {}", file_name)),
707                theme.fg_key(ThemeKey::ToolOutput, &format!("  Size: {}", size_str)),
708            ];
709        }
710
711        let path = ctx.file_path.as_deref().unwrap_or("");
712        let lang = if !path.is_empty() {
713            crate::tui::components::path_to_language(path)
714        } else {
715            None
716        };
717
718        // Pi: trim trailing empty lines from the full content
719        let all_lines: Vec<&str> = content.lines().collect();
720        let mut end = all_lines.len();
721        while end > 0 && all_lines[end - 1].is_empty() {
722            end -= 1;
723        }
724        let trimmed_lines = &all_lines[..end];
725
726        // Pi: show up to 10 lines when collapsed, full when expanded
727        let max_lines = if ctx.expanded { usize::MAX } else { 10 };
728        let display_lines: Vec<&str> = trimmed_lines.iter().copied().take(max_lines).collect();
729        let remaining = trimmed_lines.len().saturating_sub(display_lines.len());
730
731        // Pi: start with blank line (`\n` before content)
732        let mut result = vec![String::new()];
733
734        // Pi: apply syntax highlighting when a language is detected (syntect feature)
735        #[cfg(feature = "syntect")]
736        {
737            if let Some(lang) = lang {
738                let combined = display_lines.join("\n");
739                let highlighted = crate::tui::components::highlight_code(&combined, Some(lang));
740                for line in highlighted {
741                    result.push(line.replace('\t', "   "));
742                }
743            } else {
744                for line in &display_lines {
745                    let processed = line.replace('\t', "   ");
746                    result.push(theme.fg_key(ThemeKey::ToolOutput, &processed));
747                }
748            }
749        }
750
751        #[cfg(not(feature = "syntect"))]
752        for line in &display_lines {
753            let processed = line.replace('\t', "   ");
754            result.push(theme.fg_key(ThemeKey::ToolOutput, &processed));
755        }
756
757        // Pi: remaining lines hint
758        if remaining > 0 {
759            let hint = if !ctx.expand_key.is_empty() {
760                format!(
761                    "... ({} more lines, {} to expand)",
762                    remaining, ctx.expand_key
763                )
764            } else {
765                format!("... ({} more lines)", remaining)
766            };
767            result.push(theme.fg_key(ThemeKey::Muted, &hint));
768        }
769
770        // Pi: truncation warnings from details (matching pi's formatReadResult)
771        if let Some(ref details) = ctx.details
772            && let Some(truncation) = details.get("truncation")
773        {
774            let truncated = truncation
775                .get("truncated")
776                .and_then(|v| v.as_bool())
777                .unwrap_or(false);
778            if truncated {
779                let warning_color = |s: String| theme.fg_key(ThemeKey::Warning, &s);
780                let first_line_exceeds = truncation
781                    .get("firstLineExceedsLimit")
782                    .and_then(|v| v.as_bool())
783                    .unwrap_or(false);
784                if first_line_exceeds {
785                    let max_bytes = truncation
786                        .get("maxBytes")
787                        .and_then(|v| v.as_u64())
788                        .unwrap_or(DEFAULT_MAX_BYTES as u64)
789                        as usize;
790                    result.push(warning_color(format!(
791                        "[First line exceeds {} limit]",
792                        format_size(max_bytes),
793                    )));
794                } else if let Some(truncated_by) =
795                    truncation.get("truncatedBy").and_then(|v| v.as_str())
796                {
797                    let output_lines = truncation
798                        .get("outputLines")
799                        .and_then(|v| v.as_u64())
800                        .unwrap_or(0) as usize;
801                    let total_lines = truncation
802                        .get("totalLines")
803                        .and_then(|v| v.as_u64())
804                        .unwrap_or(0) as usize;
805                    if truncated_by == "lines" {
806                        let max_lines = truncation
807                            .get("maxLines")
808                            .and_then(|v| v.as_u64())
809                            .unwrap_or(DEFAULT_MAX_LINES as u64)
810                            as usize;
811                        result.push(warning_color(format!(
812                            "[Truncated: showing {} of {} lines ({} line limit)]",
813                            output_lines, total_lines, max_lines,
814                        )));
815                    } else {
816                        let max_bytes = truncation
817                            .get("maxBytes")
818                            .and_then(|v| v.as_u64())
819                            .unwrap_or(DEFAULT_MAX_BYTES as u64)
820                            as usize;
821                        result.push(warning_color(format!(
822                            "[Truncated: {} lines shown ({} limit)]",
823                            output_lines,
824                            format_size(max_bytes),
825                        )));
826                    }
827                }
828            }
829        }
830
831        result
832    }
833}
834
835// ── Tests ────────────────────────────────────────────────────────
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840    use yoagent::AgentTool;
841
842    fn tmp_dir() -> std::path::PathBuf {
843        let d = std::env::temp_dir().join(format!("rab-read-test-{}", uuid::Uuid::new_v4()));
844        std::fs::create_dir_all(&d).unwrap();
845        d
846    }
847
848    fn make_tool() -> (ReadTool, std::path::PathBuf) {
849        let tmp = tmp_dir();
850        (
851            ReadTool {
852                cwd: tmp.clone(),
853                operations: Arc::new(DefaultReadOperations),
854            },
855            tmp,
856        )
857    }
858
859    fn tool_ctx() -> yoagent::types::ToolContext {
860        yoagent::types::ToolContext {
861            tool_call_id: "id".into(),
862            tool_name: "read".into(),
863            cancel: tokio_util::sync::CancellationToken::new(),
864            on_update: None,
865            on_progress: None,
866        }
867    }
868
869    fn yo_msg_text(content: &[yoagent::types::Content]) -> String {
870        content
871            .iter()
872            .filter_map(|c| {
873                if let yoagent::types::Content::Text { text } = c {
874                    Some(text.as_str())
875                } else {
876                    None
877                }
878            })
879            .collect::<Vec<_>>()
880            .join("")
881    }
882
883    async fn exec_ok(tool: &ReadTool, args: serde_json::Value) -> String {
884        let result = tool.execute(args, tool_ctx()).await.unwrap();
885        yo_msg_text(&result.content)
886    }
887
888    async fn exec_full(tool: &ReadTool, args: serde_json::Value) -> yoagent::types::ToolResult {
889        tool.execute(args, tool_ctx()).await.unwrap()
890    }
891
892    // ── Truncation unit tests ─────────────────────────────────
893
894    #[test]
895    fn test_no_truncation_needed() {
896        let result = truncate_head("hello\nworld\n", 2000, 50000);
897        assert!(!result.truncated);
898        assert!(!result.first_line_exceeds_limit);
899        assert_eq!(result.content, "hello\nworld\n");
900    }
901
902    #[test]
903    fn test_truncates_by_lines() {
904        let content: String = (1..=5000).map(|i| format!("line {}\n", i)).collect();
905        let result = truncate_head(&content, 2000, 50000);
906        assert!(result.truncated);
907        assert_eq!(result.truncated_by, Some("lines"));
908        assert_eq!(result.output_lines, 2000);
909        assert!(result.content.ends_with("line 2000"));
910    }
911
912    #[test]
913    fn test_truncates_by_bytes() {
914        let content: String = (1..=100)
915            .map(|i| format!("line {} {}\n", i, "x".repeat(1000)))
916            .collect();
917        let result = truncate_head(&content, 2000, 50000);
918        assert!(result.truncated);
919        assert_eq!(result.truncated_by, Some("bytes"));
920        assert!(result.output_lines < 100);
921    }
922
923    #[test]
924    fn test_first_line_exceeds_limit() {
925        let content = format!("{}\nshort\n", "x".repeat(60000));
926        let result = truncate_head(&content, 2000, 50000);
927        assert!(result.truncated);
928        assert!(result.first_line_exceeds_limit);
929        assert!(result.content.is_empty());
930    }
931
932    #[test]
933    fn test_empty_content() {
934        let result = truncate_head("", 2000, 50000);
935        assert!(!result.truncated);
936        assert_eq!(result.content, "");
937    }
938
939    #[test]
940    fn test_exact_fit() {
941        let line = "a".repeat(50000);
942        let result = truncate_head(&line, 2000, 50000);
943        assert!(!result.truncated);
944    }
945
946    #[test]
947    fn test_format_size() {
948        assert_eq!(format_size(500), "500B");
949        assert_eq!(format_size(1024), "1.0KB");
950        assert_eq!(format_size(50 * 1024), "50.0KB");
951        assert_eq!(format_size(1024 * 1024), "1.0MB");
952    }
953
954    #[test]
955    fn test_trim_trailing_empty_lines() {
956        let lines = vec!["a", "b", "", ""];
957        let trimmed = trim_trailing_empty_lines(&lines);
958        assert_eq!(trimmed, &["a", "b"]);
959    }
960
961    #[test]
962    fn test_trim_no_trailing_empty_lines() {
963        let lines = vec!["a", "b"];
964        let trimmed = trim_trailing_empty_lines(&lines);
965        assert_eq!(trimmed, &["a", "b"]);
966    }
967
968    #[test]
969    fn test_trim_all_empty() {
970        let lines: Vec<&str> = vec!["", "", ""];
971        let trimmed = trim_trailing_empty_lines(&lines);
972        assert!(trimmed.is_empty());
973    }
974
975    #[test]
976    fn test_trim_empty_input() {
977        let lines: Vec<&str> = vec![];
978        let trimmed = trim_trailing_empty_lines(&lines);
979        assert!(trimmed.is_empty());
980    }
981
982    // ── Compact classification tests ─────────────────────────
983
984    #[test]
985    fn test_compact_classification_agents_md() {
986        let result = get_compact_read_classification("path/to/AGENTS.md", Path::new("path"));
987        assert!(result.is_some());
988        let (kind, label) = result.unwrap();
989        assert_eq!(kind, CompactReadKind::Resource);
990        assert!(label.contains("to/AGENTS.md"));
991    }
992
993    #[test]
994    fn test_compact_classification_claude_md() {
995        let result = get_compact_read_classification("CLAUDE.md", Path::new("path"));
996        assert!(result.is_some());
997        let (kind, label) = result.unwrap();
998        assert_eq!(kind, CompactReadKind::Resource);
999        assert_eq!(label, "CLAUDE.md");
1000    }
1001
1002    #[test]
1003    fn test_compact_classification_skill() {
1004        let result = get_compact_read_classification("skills/my-skill/SKILL.md", Path::new("."));
1005        assert!(result.is_some());
1006        let (kind, label) = result.unwrap();
1007        assert_eq!(kind, CompactReadKind::Skill);
1008        assert_eq!(label, "my-skill");
1009    }
1010
1011    #[test]
1012    fn test_compact_classification_regular_file() {
1013        let result = get_compact_read_classification("src/main.rs", Path::new("."));
1014        assert!(result.is_none());
1015    }
1016
1017    // ── Integration tests ────────────────────────────────────
1018    #[tokio::test]
1019    async fn reads_file_content() {
1020        let (tool, tmp) = make_tool();
1021        let path = tmp.join("test.txt");
1022        std::fs::write(&path, "hello world\nline two\n").unwrap();
1023
1024        let result = exec_ok(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
1025
1026        assert!(result.contains("hello world"));
1027        assert!(result.contains("line two"));
1028    }
1029
1030    #[tokio::test]
1031    async fn read_respects_offset() {
1032        let (tool, tmp) = make_tool();
1033        let path = tmp.join("test.txt");
1034        let content: Vec<String> = (1..=10).map(|i| format!("line {}", i)).collect();
1035        std::fs::write(&path, content.join("\n")).unwrap();
1036
1037        let result = exec_ok(
1038            &tool,
1039            serde_json::json!({"path": path.to_str().unwrap(), "offset": 5}),
1040        )
1041        .await;
1042
1043        assert!(result.contains("line 5"), "should contain line 5: {result}");
1044        assert!(
1045            !result.lines().any(|l| l == "line 1"),
1046            "should not contain line 1: {result}"
1047        );
1048    }
1049
1050    #[tokio::test]
1051    async fn read_respects_limit() {
1052        let (tool, tmp) = make_tool();
1053        let path = tmp.join("test.txt");
1054        let content: Vec<String> = (1..=10).map(|i| format!("line {}", i)).collect();
1055        std::fs::write(&path, content.join("\n")).unwrap();
1056
1057        let result = exec_ok(
1058            &tool,
1059            serde_json::json!({"path": path.to_str().unwrap(), "offset": 1, "limit": 3}),
1060        )
1061        .await;
1062
1063        assert!(result.contains("line 1"));
1064        assert!(result.contains("line 3"));
1065        assert!(!result.contains("line 4"));
1066    }
1067
1068    #[tokio::test]
1069    async fn read_nonexistent_file_errors() {
1070        let (tool, _tmp) = make_tool();
1071
1072        let result = tool
1073            .execute(serde_json::json!({"path": "nonexistent.txt"}), tool_ctx())
1074            .await;
1075        assert!(result.is_err());
1076    }
1077
1078    #[tokio::test]
1079    async fn offset_beyond_end_errors() {
1080        let (tool, tmp) = make_tool();
1081        let path = tmp.join("short.txt");
1082        std::fs::write(&path, "only one line\n").unwrap();
1083
1084        let result = tool
1085            .execute(
1086                serde_json::json!({"path": path.to_str().unwrap(), "offset": 100}),
1087                tool_ctx(),
1088            )
1089            .await;
1090        assert!(result.is_err());
1091        let err = result.unwrap_err().to_string();
1092        assert!(err.contains("beyond end of file"));
1093    }
1094
1095    #[tokio::test]
1096    async fn large_file_truncation_by_lines() {
1097        let (tool, tmp) = make_tool();
1098        let path = tmp.join("large.txt");
1099        let content: String = (1..=5000).map(|i| format!("line {}\n", i)).collect();
1100        std::fs::write(&path, &content).unwrap();
1101
1102        let result = exec_ok(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
1103
1104        assert!(result.contains("Showing lines 1-"));
1105        assert!(result.contains("offset="));
1106        assert!(result.contains("of 5000."));
1107    }
1108
1109    #[tokio::test]
1110    async fn large_file_truncation_by_bytes() {
1111        let (tool, tmp) = make_tool();
1112        let path = tmp.join("wide.txt");
1113        let content: String = (1..=100)
1114            .map(|i| format!("line {} {}\n", i, "x".repeat(1190)))
1115            .collect();
1116        std::fs::write(&path, &content).unwrap();
1117
1118        let result = exec_ok(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
1119
1120        assert!(result.contains("KB limit"));
1121        assert!(result.contains("offset="));
1122    }
1123
1124    #[tokio::test]
1125    async fn first_line_exceeds_limit_shows_bash_hint() {
1126        let (tool, tmp) = make_tool();
1127        let path = tmp.join("huge_first_line.txt");
1128        let content = format!("{}\nshort line\n", "x".repeat(60000));
1129        std::fs::write(&path, &content).unwrap();
1130
1131        let result = exec_ok(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
1132
1133        assert!(result.contains("bash"));
1134        assert!(result.contains("sed"));
1135        assert!(result.contains("head -c"));
1136    }
1137
1138    #[tokio::test]
1139    async fn limit_honored_without_truncation() {
1140        let (tool, tmp) = make_tool();
1141        let path = tmp.join("limited.txt");
1142        let content: String = (1..=100).map(|i| format!("line {}\n", i)).collect();
1143        std::fs::write(&path, &content).unwrap();
1144
1145        let result = exec_ok(
1146            &tool,
1147            serde_json::json!({"path": path.to_str().unwrap(), "limit": 5}),
1148        )
1149        .await;
1150
1151        assert!(result.contains("line 1"));
1152        assert!(result.contains("line 5"));
1153        assert!(!result.contains("line 6"));
1154        assert!(result.contains("more lines"));
1155    }
1156
1157    #[tokio::test]
1158    async fn limit_exactly_covers_file() {
1159        let (tool, tmp) = make_tool();
1160        let path = tmp.join("exact.txt");
1161        let content: String = (1..=3).map(|i| format!("line {}\n", i)).collect();
1162        std::fs::write(&path, &content).unwrap();
1163
1164        let result = exec_ok(
1165            &tool,
1166            serde_json::json!({"path": path.to_str().unwrap(), "limit": 3}),
1167        )
1168        .await;
1169
1170        assert!(result.contains("line 1"));
1171        assert!(result.contains("line 2"));
1172        assert!(result.contains("line 3"));
1173        assert!(!result.contains("more lines"));
1174    }
1175
1176    #[tokio::test]
1177    async fn trims_trailing_empty_lines() {
1178        let (tool, tmp) = make_tool();
1179        let path = tmp.join("trailing_empties.txt");
1180        std::fs::write(&path, "hello\nworld\n\n\n").unwrap();
1181
1182        let result = exec_ok(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
1183
1184        assert!(result.contains("hello"));
1185        assert!(result.contains("world"));
1186        assert!(!result.ends_with("\n\n\n"));
1187    }
1188
1189    #[tokio::test]
1190    async fn relative_path_resolves_to_cwd() {
1191        let (tool, tmp) = make_tool();
1192        let path = tmp.join("relative.txt");
1193        std::fs::write(&path, "hello\n").unwrap();
1194
1195        let result = exec_ok(&tool, serde_json::json!({"path": "relative.txt"})).await;
1196
1197        assert!(result.contains("hello"));
1198    }
1199
1200    #[tokio::test]
1201    async fn reads_agents_md() {
1202        let (tool, tmp) = make_tool();
1203        let path = tmp.join("AGENTS.md");
1204        std::fs::write(&path, "some instructions\n").unwrap();
1205
1206        let output = exec_full(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
1207
1208        let text = yo_msg_text(&output.content);
1209        assert!(text.contains("some instructions"));
1210    }
1211
1212    #[tokio::test]
1213    async fn no_compact_label_for_regular_file() {
1214        let (tool, tmp) = make_tool();
1215        let path = tmp.join("main.rs");
1216        std::fs::write(&path, "fn main() {}\n").unwrap();
1217
1218        let output = exec_full(&tool, serde_json::json!({"path": path.to_str().unwrap()})).await;
1219
1220        let text = yo_msg_text(&output.content);
1221        assert!(text.contains("fn main() {}"));
1222    }
1223
1224    #[tokio::test]
1225    async fn cancel_aborts_read() {
1226        let (tool, tmp) = make_tool();
1227        let path = tmp.join("cancel_test.txt");
1228        std::fs::write(&path, "hello\n").unwrap();
1229
1230        let cancel = tokio_util::sync::CancellationToken::new();
1231        cancel.cancel();
1232
1233        let result = tool
1234            .execute(
1235                serde_json::json!({"path": path.to_str().unwrap()}),
1236                yoagent::types::ToolContext {
1237                    tool_call_id: "id".into(),
1238                    tool_name: "read".into(),
1239                    cancel,
1240                    on_update: None,
1241                    on_progress: None,
1242                },
1243            )
1244            .await;
1245        assert!(result.is_err());
1246    }
1247}