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