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 truncate_str(s: &str, max_chars: usize) -> String {
19 if s.chars().count() <= max_chars {
20 s.to_string()
21 } else {
22 let truncated: String = s.chars().take(max_chars.saturating_sub(3)).collect();
23 format!("{}...", truncated)
24 }
25}
26
27fn get_file_confirmation_render_config() -> RenderConfig<'static> {
29 RenderConfig::default()
30 .with_highlighted_option_prefix(Styled::new("> ").with_fg(Color::LightCyan))
31 .with_option_index_prefix(IndexPrefix::Simple)
32 .with_selected_option(Some(StyleSheet::new().with_fg(Color::LightCyan)))
33}
34
35pub fn render_diff(old_content: &str, new_content: &str, filename: &str) {
37 let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
38 let box_width = term_width.min(80);
39 let inner_width = box_width - 4;
40
41 let header = format!(" {} ", filename);
43 let header_len = header.len();
44 let left_dashes = (inner_width.saturating_sub(header_len)) / 2;
45 let right_dashes = inner_width.saturating_sub(header_len).saturating_sub(left_dashes);
46
47 println!(
48 "{}{}{}{}{}",
49 "┌".dimmed(),
50 "─".repeat(left_dashes).dimmed(),
51 header.white().bold(),
52 "─".repeat(right_dashes).dimmed(),
53 "┐".dimmed()
54 );
55
56 let diff = TextDiff::from_lines(old_content, new_content);
57 let mut old_line = 1usize;
58 let mut new_line = 1usize;
59
60 for change in diff.iter_all_changes() {
61 let (line_num_display, prefix, content, style) = match change.tag() {
62 ChangeTag::Delete => {
63 let ln = format!("{:>4}", old_line);
64 old_line += 1;
65 (ln, "-", change.value().trim_end(), "red")
66 }
67 ChangeTag::Insert => {
68 let ln = format!("{:>4}", new_line);
69 new_line += 1;
70 (ln, "+", change.value().trim_end(), "green")
71 }
72 ChangeTag::Equal => {
73 let ln = format!("{:>4}", new_line);
74 old_line += 1;
75 new_line += 1;
76 (ln, " ", change.value().trim_end(), "normal")
77 }
78 };
79
80 let max_content_len = inner_width.saturating_sub(8); let truncated = truncate_str(content, max_content_len);
83
84 match style {
85 "red" => println!(
86 "{} {} {} {}",
87 "│".dimmed(),
88 line_num_display.dimmed(),
89 prefix.red().bold(),
90 truncated.red()
91 ),
92 "green" => println!(
93 "{} {} {} {}",
94 "│".dimmed(),
95 line_num_display.dimmed(),
96 prefix.green().bold(),
97 truncated.green()
98 ),
99 _ => println!(
100 "{} {} {} {}",
101 "│".dimmed(),
102 line_num_display.dimmed(),
103 prefix,
104 truncated
105 ),
106 }
107 }
108
109 println!(
111 "{}{}{}",
112 "└".dimmed(),
113 "─".repeat(box_width - 2).dimmed(),
114 "┘".dimmed()
115 );
116 println!();
117
118 let _ = io::stdout().flush();
119}
120
121pub fn render_new_file(content: &str, filename: &str) {
123 let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
124 let box_width = term_width.min(80);
125 let inner_width = box_width - 4;
126
127 let header = format!(" {} (new file) ", filename);
129 let header_len = header.len();
130 let left_dashes = (inner_width.saturating_sub(header_len)) / 2;
131 let right_dashes = inner_width.saturating_sub(header_len).saturating_sub(left_dashes);
132
133 println!(
134 "{}{}{}{}{}",
135 "┌".dimmed(),
136 "─".repeat(left_dashes).dimmed(),
137 header.green().bold(),
138 "─".repeat(right_dashes).dimmed(),
139 "┐".dimmed()
140 );
141
142 const MAX_PREVIEW_LINES: usize = 20;
144 let lines: Vec<&str> = content.lines().collect();
145 let show_truncation = lines.len() > MAX_PREVIEW_LINES;
146
147 for (i, line) in lines.iter().take(MAX_PREVIEW_LINES).enumerate() {
148 let line_num = format!("{:>4}", i + 1);
149 let max_content_len = inner_width.saturating_sub(8);
150 let truncated = truncate_str(line, max_content_len);
151
152 println!(
153 "{} {} {} {}",
154 "│".dimmed(),
155 line_num.dimmed(),
156 "+".green().bold(),
157 truncated.green()
158 );
159 }
160
161 if show_truncation {
162 let remaining = lines.len() - MAX_PREVIEW_LINES;
163 println!(
164 "{} {} {} {}",
165 "│".dimmed(),
166 " ".dimmed(),
167 "...".dimmed(),
168 format!("({} more lines)", remaining).dimmed()
169 );
170 }
171
172 println!(
174 "{}{}{}",
175 "└".dimmed(),
176 "─".repeat(box_width - 2).dimmed(),
177 "┘".dimmed()
178 );
179 println!();
180
181 let _ = io::stdout().flush();
182}
183
184pub fn confirm_file_write(
186 path: &str,
187 old_content: Option<&str>,
188 new_content: &str,
189) -> crate::agent::ui::confirmation::ConfirmationResult {
190 use crate::agent::ui::confirmation::ConfirmationResult;
191 use inquire::{InquireError, Select, Text};
192
193 match old_content {
195 Some(old) => render_diff(old, new_content, path),
196 None => render_new_file(new_content, path),
197 };
198
199 let options = vec![
200 "Yes, allow once".to_string(),
201 "Yes, allow always".to_string(),
202 "Type here to suggest changes".to_string(),
203 ];
204
205 println!("{}", "Apply this change?".white());
206
207 let selection = Select::new("", options.clone())
208 .with_render_config(get_file_confirmation_render_config())
209 .with_page_size(3) .with_help_message("↑↓ to move, Enter to select, Esc to cancel")
211 .prompt();
212
213 match selection {
214 Ok(answer) => {
215 if answer == options[0] {
216 ConfirmationResult::Proceed
217 } else if answer == options[1] {
218 let filename = std::path::Path::new(path)
220 .file_name()
221 .map(|n| n.to_string_lossy().to_string())
222 .unwrap_or_else(|| path.to_string());
223 ConfirmationResult::ProceedAlways(filename)
224 } else {
225 println!();
227 match Text::new("What changes would you like?")
228 .with_help_message("Press Enter to submit, Esc to cancel")
229 .prompt()
230 {
231 Ok(feedback) if !feedback.trim().is_empty() => {
232 ConfirmationResult::Modify(feedback)
233 }
234 _ => ConfirmationResult::Cancel,
235 }
236 }
237 }
238 Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
239 ConfirmationResult::Cancel
240 }
241 Err(_) => ConfirmationResult::Cancel,
242 }
243}
244
245pub async fn confirm_file_write_with_ide(
262 path: &str,
263 old_content: Option<&str>,
264 new_content: &str,
265 ide_client: Option<&IdeClient>,
266) -> crate::agent::ui::confirmation::ConfirmationResult {
267 use crate::agent::ui::confirmation::ConfirmationResult;
268 use inquire::{InquireError, Select, Text};
269 use tokio::sync::oneshot;
270
271 match old_content {
273 Some(old) => render_diff(old, new_content, path),
274 None => render_new_file(new_content, path),
275 };
276
277 let ide_connected = ide_client.map(|c| c.is_connected()).unwrap_or(false);
279
280 if ide_connected {
281 let client = ide_client.unwrap();
282
283 let abs_path = std::path::Path::new(path)
285 .canonicalize()
286 .map(|p| p.to_string_lossy().to_string())
287 .unwrap_or_else(|_| path.to_string());
288
289 let (terminal_tx, terminal_rx) = oneshot::channel::<ConfirmationResult>();
291 let (cancel_tx, _cancel_rx) = oneshot::channel::<()>();
292 let (menu_ready_tx, menu_ready_rx) = oneshot::channel::<()>();
293
294 let path_owned = path.to_string();
296 let ide_name = client.ide_name().unwrap_or("IDE").to_string();
297 let terminal_handle = tokio::task::spawn_blocking(move || {
298 let options = vec![
299 "Yes, allow once".to_string(),
300 "Yes, allow always".to_string(),
301 "Type here to suggest changes".to_string(),
302 ];
303
304 println!(
305 "{} Diff opened in {} - respond here or in the IDE",
306 "→".cyan(),
307 ide_name
308 );
309 println!("{}", "Apply this change?".white());
310
311 let _ = menu_ready_tx.send(());
313
314 let selection = Select::new("", options.clone())
315 .with_render_config(get_file_confirmation_render_config())
316 .with_page_size(3)
317 .with_help_message("↑↓ to move, Enter to select, Esc to cancel (or use IDE)")
318 .prompt();
319
320 let result = match selection {
321 Ok(answer) => {
322 if answer == options[0] {
323 ConfirmationResult::Proceed
324 } else if answer == options[1] {
325 let filename = std::path::Path::new(&path_owned)
326 .file_name()
327 .map(|n| n.to_string_lossy().to_string())
328 .unwrap_or_else(|| path_owned.clone());
329 ConfirmationResult::ProceedAlways(filename)
330 } else {
331 println!();
333 match Text::new("What changes would you like?")
334 .with_help_message("Press Enter to submit, Esc to cancel")
335 .prompt()
336 {
337 Ok(feedback) if !feedback.trim().is_empty() => {
338 ConfirmationResult::Modify(feedback)
339 }
340 _ => ConfirmationResult::Cancel,
341 }
342 }
343 }
344 Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
345 ConfirmationResult::Cancel
346 }
347 Err(_) => ConfirmationResult::Cancel,
348 };
349
350 let _ = terminal_tx.send(result);
351 });
352
353 let _ = menu_ready_rx.await;
355
356 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
358
359 let ide_future = client.open_diff(&abs_path, new_content);
361
362 tokio::select! {
364 ide_result = ide_future => {
366 let _ = cancel_tx.send(());
369 terminal_handle.abort();
370
371 match ide_result {
372 Ok(DiffResult::Accepted { content: _ }) => {
373 println!("\n{} Changes accepted in IDE", "✓".green());
374 return ConfirmationResult::Proceed;
375 }
376 Ok(DiffResult::Rejected) => {
377 println!("\n{} Changes rejected in IDE", "✗".red());
378 return ConfirmationResult::Cancel;
379 }
380 Err(e) => {
381 println!("\n{} IDE error: {}", "!".yellow(), e);
382 return ConfirmationResult::Cancel;
385 }
386 }
387 }
388 terminal_result = terminal_rx => {
390 let _ = client.close_diff(&abs_path).await;
392
393 match terminal_result {
394 Ok(result) => {
395 match &result {
396 ConfirmationResult::Proceed => {
397 println!("{} Changes accepted", "✓".green());
398 }
399 ConfirmationResult::ProceedAlways(_) => {
400 println!("{} Changes accepted (always for this file type)", "✓".green());
401 }
402 ConfirmationResult::Cancel => {
403 println!("{} Changes cancelled", "✗".red());
404 }
405 ConfirmationResult::Modify(_) => {
406 println!("{} Feedback provided", "→".cyan());
407 }
408 }
409 return result;
410 }
411 Err(_) => {
412 return ConfirmationResult::Cancel;
413 }
414 }
415 }
416 }
417 }
418
419 confirm_file_write(path, old_content, new_content)
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 #[test]
428 fn test_diff_render_doesnt_panic() {
429 let old = "line 1\nline 2\nline 3";
430 let new = "line 1\nmodified line 2\nline 3\nline 4";
431 render_diff(old, new, "test.txt");
433 }
434
435 #[test]
436 fn test_new_file_render_doesnt_panic() {
437 let content = "new content\nline 2";
438 render_new_file(content, "new_file.txt");
439 }
440}