1use colored::Colorize;
10use inquire::ui::{Color, IndexPrefix, RenderConfig, StyleSheet, Styled};
11use similar::{ChangeTag, TextDiff};
12use std::io::{self, Write};
13
14use crate::agent::ide::{DiffResult, IdeClient};
15
16fn get_file_confirmation_render_config() -> RenderConfig<'static> {
18 RenderConfig::default()
19 .with_highlighted_option_prefix(Styled::new("> ").with_fg(Color::LightCyan))
20 .with_option_index_prefix(IndexPrefix::Simple)
21 .with_selected_option(Some(StyleSheet::new().with_fg(Color::LightCyan)))
22}
23
24pub fn render_diff(old_content: &str, new_content: &str, filename: &str) {
26 let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
27 let box_width = term_width.min(80);
28 let inner_width = box_width - 4;
29
30 let header = format!(" {} ", filename);
32 let header_len = header.len();
33 let left_dashes = (inner_width.saturating_sub(header_len)) / 2;
34 let right_dashes = inner_width.saturating_sub(header_len).saturating_sub(left_dashes);
35
36 println!(
37 "{}{}{}{}{}",
38 "┌".dimmed(),
39 "─".repeat(left_dashes).dimmed(),
40 header.white().bold(),
41 "─".repeat(right_dashes).dimmed(),
42 "┐".dimmed()
43 );
44
45 let diff = TextDiff::from_lines(old_content, new_content);
46 let mut old_line = 1usize;
47 let mut new_line = 1usize;
48
49 for change in diff.iter_all_changes() {
50 let (line_num_display, prefix, content, style) = match change.tag() {
51 ChangeTag::Delete => {
52 let ln = format!("{:>4}", old_line);
53 old_line += 1;
54 (ln, "-", change.value().trim_end(), "red")
55 }
56 ChangeTag::Insert => {
57 let ln = format!("{:>4}", new_line);
58 new_line += 1;
59 (ln, "+", change.value().trim_end(), "green")
60 }
61 ChangeTag::Equal => {
62 let ln = format!("{:>4}", new_line);
63 old_line += 1;
64 new_line += 1;
65 (ln, " ", change.value().trim_end(), "normal")
66 }
67 };
68
69 let max_content_len = inner_width.saturating_sub(8); let truncated = if content.len() > max_content_len {
72 format!("{}...", &content[..max_content_len.saturating_sub(3)])
73 } else {
74 content.to_string()
75 };
76
77 match style {
78 "red" => println!(
79 "{} {} {} {}",
80 "│".dimmed(),
81 line_num_display.dimmed(),
82 prefix.red().bold(),
83 truncated.red()
84 ),
85 "green" => println!(
86 "{} {} {} {}",
87 "│".dimmed(),
88 line_num_display.dimmed(),
89 prefix.green().bold(),
90 truncated.green()
91 ),
92 _ => println!(
93 "{} {} {} {}",
94 "│".dimmed(),
95 line_num_display.dimmed(),
96 prefix,
97 truncated
98 ),
99 }
100 }
101
102 println!(
104 "{}{}{}",
105 "└".dimmed(),
106 "─".repeat(box_width - 2).dimmed(),
107 "┘".dimmed()
108 );
109 println!();
110
111 let _ = io::stdout().flush();
112}
113
114pub fn render_new_file(content: &str, filename: &str) {
116 let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
117 let box_width = term_width.min(80);
118 let inner_width = box_width - 4;
119
120 let header = format!(" {} (new file) ", filename);
122 let header_len = header.len();
123 let left_dashes = (inner_width.saturating_sub(header_len)) / 2;
124 let right_dashes = inner_width.saturating_sub(header_len).saturating_sub(left_dashes);
125
126 println!(
127 "{}{}{}{}{}",
128 "┌".dimmed(),
129 "─".repeat(left_dashes).dimmed(),
130 header.green().bold(),
131 "─".repeat(right_dashes).dimmed(),
132 "┐".dimmed()
133 );
134
135 const MAX_PREVIEW_LINES: usize = 20;
137 let lines: Vec<&str> = content.lines().collect();
138 let show_truncation = lines.len() > MAX_PREVIEW_LINES;
139
140 for (i, line) in lines.iter().take(MAX_PREVIEW_LINES).enumerate() {
141 let line_num = format!("{:>4}", i + 1);
142 let max_content_len = inner_width.saturating_sub(8);
143 let truncated = if line.len() > max_content_len {
144 format!("{}...", &line[..max_content_len.saturating_sub(3)])
145 } else {
146 line.to_string()
147 };
148
149 println!(
150 "{} {} {} {}",
151 "│".dimmed(),
152 line_num.dimmed(),
153 "+".green().bold(),
154 truncated.green()
155 );
156 }
157
158 if show_truncation {
159 let remaining = lines.len() - MAX_PREVIEW_LINES;
160 println!(
161 "{} {} {} {}",
162 "│".dimmed(),
163 " ".dimmed(),
164 "...".dimmed(),
165 format!("({} more lines)", remaining).dimmed()
166 );
167 }
168
169 println!(
171 "{}{}{}",
172 "└".dimmed(),
173 "─".repeat(box_width - 2).dimmed(),
174 "┘".dimmed()
175 );
176 println!();
177
178 let _ = io::stdout().flush();
179}
180
181pub fn confirm_file_write(
183 path: &str,
184 old_content: Option<&str>,
185 new_content: &str,
186) -> crate::agent::ui::confirmation::ConfirmationResult {
187 use crate::agent::ui::confirmation::ConfirmationResult;
188 use inquire::{InquireError, Select, Text};
189
190 match old_content {
192 Some(old) => render_diff(old, new_content, path),
193 None => render_new_file(new_content, path),
194 };
195
196 let options = vec![
197 "Yes, allow once".to_string(),
198 "Yes, allow always".to_string(),
199 "Type here to suggest changes".to_string(),
200 ];
201
202 println!("{}", "Apply this change?".white());
203
204 let selection = Select::new("", options.clone())
205 .with_render_config(get_file_confirmation_render_config())
206 .with_page_size(3) .with_help_message("↑↓ to move, Enter to select, Esc to cancel")
208 .prompt();
209
210 match selection {
211 Ok(answer) => {
212 if answer == options[0] {
213 ConfirmationResult::Proceed
214 } else if answer == options[1] {
215 let filename = std::path::Path::new(path)
217 .file_name()
218 .map(|n| n.to_string_lossy().to_string())
219 .unwrap_or_else(|| path.to_string());
220 ConfirmationResult::ProceedAlways(filename)
221 } else {
222 println!();
224 match Text::new("What changes would you like?")
225 .with_help_message("Press Enter to submit, Esc to cancel")
226 .prompt()
227 {
228 Ok(feedback) if !feedback.trim().is_empty() => {
229 ConfirmationResult::Modify(feedback)
230 }
231 _ => ConfirmationResult::Cancel,
232 }
233 }
234 }
235 Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
236 ConfirmationResult::Cancel
237 }
238 Err(_) => ConfirmationResult::Cancel,
239 }
240}
241
242pub async fn confirm_file_write_with_ide(
256 path: &str,
257 old_content: Option<&str>,
258 new_content: &str,
259 ide_client: Option<&IdeClient>,
260) -> crate::agent::ui::confirmation::ConfirmationResult {
261 use crate::agent::ui::confirmation::ConfirmationResult;
262
263 if let Some(client) = ide_client {
265 if client.is_connected() {
266 let abs_path = std::path::Path::new(path)
268 .canonicalize()
269 .map(|p| p.to_string_lossy().to_string())
270 .unwrap_or_else(|_| path.to_string());
271
272 println!(
273 "{} Opening diff in {}...",
274 "→".cyan(),
275 client.ide_name().unwrap_or("IDE")
276 );
277
278 match client.open_diff(&abs_path, new_content).await {
279 Ok(DiffResult::Accepted { content: _ }) => {
280 println!("{} Changes accepted in IDE", "✓".green());
281 return ConfirmationResult::Proceed;
282 }
283 Ok(DiffResult::Rejected) => {
284 println!("{} Changes rejected in IDE", "✗".red());
285 return ConfirmationResult::Cancel;
286 }
287 Err(e) => {
288 println!(
290 "{} IDE diff failed ({}), showing terminal diff...",
291 "!".yellow(),
292 e
293 );
294 }
295 }
296 }
297 }
298
299 confirm_file_write(path, old_content, new_content)
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn test_diff_render_doesnt_panic() {
309 let old = "line 1\nline 2\nline 3";
310 let new = "line 1\nmodified line 2\nline 3\nline 4";
311 render_diff(old, new, "test.txt");
313 }
314
315 #[test]
316 fn test_new_file_render_doesnt_panic() {
317 let content = "new content\nline 2";
318 render_new_file(content, "new_file.txt");
319 }
320}