codetether_agent/tui/app/
file_share.rs1use 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}