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(
259 path: &str,
260 old_content: Option<&str>,
261 new_content: &str,
262 ide_client: Option<&IdeClient>,
263) -> crate::agent::ui::confirmation::ConfirmationResult {
264 use crate::agent::ui::confirmation::ConfirmationResult;
265 use inquire::{InquireError, Select, Text};
266 use tokio::sync::oneshot;
267
268 match old_content {
270 Some(old) => render_diff(old, new_content, path),
271 None => render_new_file(new_content, path),
272 };
273
274 let ide_connected = ide_client.map(|c| c.is_connected()).unwrap_or(false);
276
277 if ide_connected {
278 let client = ide_client.unwrap();
279
280 let abs_path = std::path::Path::new(path)
282 .canonicalize()
283 .map(|p| p.to_string_lossy().to_string())
284 .unwrap_or_else(|_| path.to_string());
285
286 let (terminal_tx, terminal_rx) = oneshot::channel::<ConfirmationResult>();
288 let (cancel_tx, _cancel_rx) = oneshot::channel::<()>();
289 let (menu_ready_tx, menu_ready_rx) = oneshot::channel::<()>();
290
291 let path_owned = path.to_string();
293 let ide_name = client.ide_name().unwrap_or("IDE").to_string();
294 let terminal_handle = tokio::task::spawn_blocking(move || {
295 let options = vec![
296 "Yes, allow once".to_string(),
297 "Yes, allow always".to_string(),
298 "Type here to suggest changes".to_string(),
299 ];
300
301 println!(
302 "{} Diff opened in {} - respond here or in the IDE",
303 "→".cyan(),
304 ide_name
305 );
306 println!("{}", "Apply this change?".white());
307
308 let _ = menu_ready_tx.send(());
310
311 let selection = Select::new("", options.clone())
312 .with_render_config(get_file_confirmation_render_config())
313 .with_page_size(3)
314 .with_help_message("↑↓ to move, Enter to select, Esc to cancel (or use IDE)")
315 .prompt();
316
317 let result = match selection {
318 Ok(answer) => {
319 if answer == options[0] {
320 ConfirmationResult::Proceed
321 } else if answer == options[1] {
322 let filename = std::path::Path::new(&path_owned)
323 .file_name()
324 .map(|n| n.to_string_lossy().to_string())
325 .unwrap_or_else(|| path_owned.clone());
326 ConfirmationResult::ProceedAlways(filename)
327 } else {
328 println!();
330 match Text::new("What changes would you like?")
331 .with_help_message("Press Enter to submit, Esc to cancel")
332 .prompt()
333 {
334 Ok(feedback) if !feedback.trim().is_empty() => {
335 ConfirmationResult::Modify(feedback)
336 }
337 _ => ConfirmationResult::Cancel,
338 }
339 }
340 }
341 Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
342 ConfirmationResult::Cancel
343 }
344 Err(_) => ConfirmationResult::Cancel,
345 };
346
347 let _ = terminal_tx.send(result);
348 });
349
350 let _ = menu_ready_rx.await;
352
353 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
355
356 let ide_future = client.open_diff(&abs_path, new_content);
358
359 tokio::select! {
361 ide_result = ide_future => {
363 let _ = cancel_tx.send(());
366 terminal_handle.abort();
367
368 match ide_result {
369 Ok(DiffResult::Accepted { content: _ }) => {
370 println!("\n{} Changes accepted in IDE", "✓".green());
371 return ConfirmationResult::Proceed;
372 }
373 Ok(DiffResult::Rejected) => {
374 println!("\n{} Changes rejected in IDE", "✗".red());
375 return ConfirmationResult::Cancel;
376 }
377 Err(e) => {
378 println!("\n{} IDE error: {}", "!".yellow(), e);
379 return ConfirmationResult::Cancel;
382 }
383 }
384 }
385 terminal_result = terminal_rx => {
387 let _ = client.close_diff(&abs_path).await;
389
390 match terminal_result {
391 Ok(result) => {
392 match &result {
393 ConfirmationResult::Proceed => {
394 println!("{} Changes accepted", "✓".green());
395 }
396 ConfirmationResult::ProceedAlways(_) => {
397 println!("{} Changes accepted (always for this file type)", "✓".green());
398 }
399 ConfirmationResult::Cancel => {
400 println!("{} Changes cancelled", "✗".red());
401 }
402 ConfirmationResult::Modify(_) => {
403 println!("{} Feedback provided", "→".cyan());
404 }
405 }
406 return result;
407 }
408 Err(_) => {
409 return ConfirmationResult::Cancel;
410 }
411 }
412 }
413 }
414 }
415
416 confirm_file_write(path, old_content, new_content)
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn test_diff_render_doesnt_panic() {
426 let old = "line 1\nline 2\nline 3";
427 let new = "line 1\nmodified line 2\nline 3\nline 4";
428 render_diff(old, new, "test.txt");
430 }
431
432 #[test]
433 fn test_new_file_render_doesnt_panic() {
434 let content = "new content\nline 2";
435 render_new_file(content, "new_file.txt");
436 }
437}