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