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