Skip to main content

codetether_agent/tui/app/
file_share.rs

1use std::path::Path;
2
3use anyhow::Result;
4
5use crate::tui::app::state::App;
6use crate::tui::chat::message::{ChatMessage, MessageType};
7use crate::tui::constants::FILE_SHARE_MAX_BYTES;
8use crate::tui::models::{InputMode, ViewMode};
9
10pub fn attach_file_to_input(app: &mut App, workspace_dir: &Path, path: &Path) {
11    let file_path = if path.is_absolute() {
12        path.to_path_buf()
13    } else {
14        workspace_dir.join(path)
15    };
16
17    if !file_path.exists() {
18        push_system_message(
19            app,
20            format!("File not found: {}", file_path.display()),
21            "File not found",
22        );
23        return;
24    }
25
26    if !file_path.is_file() {
27        push_system_message(
28            app,
29            format!("Not a file: {}", file_path.display()),
30            "Path is not a file",
31        );
32        return;
33    }
34
35    let display_path = display_path_for_workspace(&file_path, workspace_dir);
36    match build_file_share_snippet(&file_path, &display_path, FILE_SHARE_MAX_BYTES) {
37        Ok((snippet, truncated, binary)) => {
38            if !app.state.input.trim().is_empty() {
39                app.state.input.push_str("\n\n");
40            }
41            app.state.input.push_str(&snippet);
42            app.state.input_cursor = app.state.input.chars().count();
43            app.state.input_mode = InputMode::Editing;
44            app.state.history_index = None;
45            app.state.refresh_slash_suggestions();
46            app.state.set_view_mode(ViewMode::Chat);
47            app.state.scroll_to_bottom();
48
49            let suffix = if binary {
50                " (binary file metadata only)".to_string()
51            } else if truncated {
52                format!(" (truncated to {} bytes)", FILE_SHARE_MAX_BYTES)
53            } else {
54                String::new()
55            };
56
57            let message =
58                format!("Attached `{display_path}` to composer{suffix}. Press Enter to send.");
59            push_system_message(app, message, "Attached file to composer");
60        }
61        Err(err) => {
62            push_system_message(
63                app,
64                format!("Failed to attach file {}: {err}", file_path.display()),
65                "Failed to attach file",
66            );
67        }
68    }
69}
70
71fn push_system_message(app: &mut App, message: String, status: &str) {
72    app.state
73        .messages
74        .push(ChatMessage::new(MessageType::System, message));
75    app.state.status = status.to_string();
76    app.state.scroll_to_bottom();
77}
78
79fn display_path_for_workspace(path: &Path, workspace_dir: &Path) -> String {
80    path.strip_prefix(workspace_dir)
81        .unwrap_or(path)
82        .display()
83        .to_string()
84}
85
86fn build_file_share_snippet(
87    path: &Path,
88    display_path: &str,
89    max_bytes: usize,
90) -> Result<(String, bool, bool)> {
91    let bytes = std::fs::read(path)?;
92
93    if bytes.contains(&0) {
94        let snippet = format!(
95            "Shared file: {display_path}\n[binary file, size: {}]",
96            format_bytes(bytes.len() as u64)
97        );
98        return Ok((snippet, false, true));
99    }
100
101    let mut text = String::from_utf8_lossy(&bytes).to_string();
102    let mut truncated = false;
103    if text.len() > max_bytes {
104        let mut end = max_bytes;
105        while end > 0 && !text.is_char_boundary(end) {
106            end -= 1;
107        }
108        text.truncate(end);
109        truncated = true;
110    }
111
112    let language = path
113        .extension()
114        .and_then(|ext| ext.to_str())
115        .filter(|ext| !ext.is_empty())
116        .unwrap_or("text");
117
118    let mut snippet = format!("Shared file: {display_path}\n~~~{language}\n{text}\n~~~");
119    if truncated {
120        snippet.push_str(&format!(
121            "\n[truncated to first {} bytes; original size: {}]",
122            max_bytes,
123            format_bytes(bytes.len() as u64)
124        ));
125    }
126
127    Ok((snippet, truncated, false))
128}
129
130fn format_bytes(bytes: u64) -> String {
131    const KB: f64 = 1024.0;
132    const MB: f64 = KB * 1024.0;
133    const GB: f64 = MB * 1024.0;
134
135    if bytes < 1024 {
136        format!("{bytes}B")
137    } else if (bytes as f64) < MB {
138        format!("{:.1}KB", bytes as f64 / KB)
139    } else if (bytes as f64) < GB {
140        format!("{:.1}MB", bytes as f64 / MB)
141    } else {
142        format!("{:.2}GB", bytes as f64 / GB)
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn binary_file_share_uses_metadata_only() {
152        let dir = tempfile::tempdir().expect("tempdir should create");
153        let path = dir.path().join("blob.bin");
154        std::fs::write(&path, [0_u8, 1, 2, 3]).expect("binary fixture should write");
155
156        let (snippet, truncated, binary) =
157            build_file_share_snippet(&path, "blob.bin", FILE_SHARE_MAX_BYTES)
158                .expect("snippet should build");
159
160        assert!(binary);
161        assert!(!truncated);
162        assert!(snippet.contains("[binary file, size: 4B]"));
163    }
164}