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!("{}", "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 let _ = terminal::disable_raw_mode();
374 let _ = execute!(
375 std::io::stdout(),
376 cursor::Show,
377 terminal::Clear(terminal::ClearType::FromCursorDown)
378 );
379 print!("\r");
380 let _ = std::io::stdout().flush();
381
382 match ide_result {
383 Ok(DiffResult::Accepted { content: _ }) => {
384 println!("\n{} Changes accepted in IDE", "✓".green());
385 return ConfirmationResult::Proceed;
386 }
387 Ok(DiffResult::Rejected) => {
388 println!("\n{} Changes rejected in IDE", "✗".red());
389 return ConfirmationResult::Cancel;
390 }
391 Err(e) => {
392 println!("\n{} IDE error: {}", "!".yellow(), e);
393 return ConfirmationResult::Cancel;
396 }
397 }
398 }
399 terminal_result = terminal_rx => {
401 let _ = client.close_diff(&abs_path).await;
403
404 match terminal_result {
405 Ok(result) => {
406 match &result {
407 ConfirmationResult::Proceed => {
408 println!("{} Changes accepted", "✓".green());
409 }
410 ConfirmationResult::ProceedAlways(_) => {
411 println!("{} Changes accepted (always for this file type)", "✓".green());
412 }
413 ConfirmationResult::Cancel => {
414 println!("{} Changes cancelled", "✗".red());
415 }
416 ConfirmationResult::Modify(_) => {
417 println!("{} Feedback provided", "→".cyan());
418 }
419 }
420 return result;
421 }
422 Err(_) => {
423 return ConfirmationResult::Cancel;
424 }
425 }
426 }
427 }
428 }
429
430 confirm_file_write(path, old_content, new_content)
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn test_diff_render_doesnt_panic() {
440 let old = "line 1\nline 2\nline 3";
441 let new = "line 1\nmodified line 2\nline 3\nline 4";
442 render_diff(old, new, "test.txt");
444 }
445
446 #[test]
447 fn test_new_file_render_doesnt_panic() {
448 let content = "new content\nline 2";
449 render_new_file(content, "new_file.txt");
450 }
451}