use colored::Colorize;
use crossterm::{cursor, execute, terminal};
use inquire::ui::{Color, IndexPrefix, RenderConfig, StyleSheet, Styled};
use similar::{ChangeTag, TextDiff};
use std::io::{self, Write};
use crate::agent::ide::{DiffResult, IdeClient};
fn truncate_str(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else {
let truncated: String = s.chars().take(max_chars.saturating_sub(3)).collect();
format!("{}...", truncated)
}
}
fn get_file_confirmation_render_config() -> RenderConfig<'static> {
RenderConfig::default()
.with_highlighted_option_prefix(Styled::new("> ").with_fg(Color::LightCyan))
.with_option_index_prefix(IndexPrefix::Simple)
.with_selected_option(Some(StyleSheet::new().with_fg(Color::LightCyan)))
}
pub fn render_diff(old_content: &str, new_content: &str, filename: &str) {
let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
let box_width = term_width.min(80);
let inner_width = box_width - 4;
let header = format!(" {} ", filename);
let header_len = header.len();
let left_dashes = (inner_width.saturating_sub(header_len)) / 2;
let right_dashes = inner_width
.saturating_sub(header_len)
.saturating_sub(left_dashes);
println!(
"{}{}{}{}{}",
"┌".dimmed(),
"─".repeat(left_dashes).dimmed(),
header.white().bold(),
"─".repeat(right_dashes).dimmed(),
"┐".dimmed()
);
let diff = TextDiff::from_lines(old_content, new_content);
let mut old_line = 1usize;
let mut new_line = 1usize;
for change in diff.iter_all_changes() {
let (line_num_display, prefix, content, style) = match change.tag() {
ChangeTag::Delete => {
let ln = format!("{:>4}", old_line);
old_line += 1;
(ln, "-", change.value().trim_end(), "red")
}
ChangeTag::Insert => {
let ln = format!("{:>4}", new_line);
new_line += 1;
(ln, "+", change.value().trim_end(), "green")
}
ChangeTag::Equal => {
let ln = format!("{:>4}", new_line);
old_line += 1;
new_line += 1;
(ln, " ", change.value().trim_end(), "normal")
}
};
let max_content_len = inner_width.saturating_sub(8); let truncated = truncate_str(content, max_content_len);
match style {
"red" => println!(
"{} {} {} {}",
"│".dimmed(),
line_num_display.dimmed(),
prefix.red().bold(),
truncated.red()
),
"green" => println!(
"{} {} {} {}",
"│".dimmed(),
line_num_display.dimmed(),
prefix.green().bold(),
truncated.green()
),
_ => println!(
"{} {} {} {}",
"│".dimmed(),
line_num_display.dimmed(),
prefix,
truncated
),
}
}
println!(
"{}{}{}",
"└".dimmed(),
"─".repeat(box_width - 2).dimmed(),
"┘".dimmed()
);
println!();
let _ = io::stdout().flush();
}
pub fn render_new_file(content: &str, filename: &str) {
let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
let box_width = term_width.min(80);
let inner_width = box_width - 4;
let header = format!(" {} (new file) ", filename);
let header_len = header.len();
let left_dashes = (inner_width.saturating_sub(header_len)) / 2;
let right_dashes = inner_width
.saturating_sub(header_len)
.saturating_sub(left_dashes);
println!(
"{}{}{}{}{}",
"┌".dimmed(),
"─".repeat(left_dashes).dimmed(),
header.green().bold(),
"─".repeat(right_dashes).dimmed(),
"┐".dimmed()
);
const MAX_PREVIEW_LINES: usize = 20;
let lines: Vec<&str> = content.lines().collect();
let show_truncation = lines.len() > MAX_PREVIEW_LINES;
for (i, line) in lines.iter().take(MAX_PREVIEW_LINES).enumerate() {
let line_num = format!("{:>4}", i + 1);
let max_content_len = inner_width.saturating_sub(8);
let truncated = truncate_str(line, max_content_len);
println!(
"{} {} {} {}",
"│".dimmed(),
line_num.dimmed(),
"+".green().bold(),
truncated.green()
);
}
if show_truncation {
let remaining = lines.len() - MAX_PREVIEW_LINES;
println!(
"{} {} {} {}",
"│".dimmed(),
" ".dimmed(),
"...".dimmed(),
format!("({} more lines)", remaining).dimmed()
);
}
println!(
"{}{}{}",
"└".dimmed(),
"─".repeat(box_width - 2).dimmed(),
"┘".dimmed()
);
println!();
let _ = io::stdout().flush();
}
pub fn confirm_file_write(
path: &str,
old_content: Option<&str>,
new_content: &str,
) -> crate::agent::ui::confirmation::ConfirmationResult {
use crate::agent::ui::confirmation::ConfirmationResult;
use inquire::{InquireError, Select, Text};
match old_content {
Some(old) => render_diff(old, new_content, path),
None => render_new_file(new_content, path),
};
let options = vec![
"Yes, allow once".to_string(),
"Yes, allow always".to_string(),
"Type here to suggest changes".to_string(),
];
println!("{}", "Apply this change?".white());
let selection = Select::new("", options.clone())
.with_render_config(get_file_confirmation_render_config())
.with_page_size(3) .with_help_message("↑↓ to move, Enter to select, Esc to cancel")
.prompt();
match selection {
Ok(answer) => {
if answer == options[0] {
ConfirmationResult::Proceed
} else if answer == options[1] {
let filename = std::path::Path::new(path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string());
ConfirmationResult::ProceedAlways(filename)
} else {
println!();
match Text::new("What changes would you like?")
.with_help_message("Press Enter to submit, Esc to cancel")
.prompt()
{
Ok(feedback) if !feedback.trim().is_empty() => {
ConfirmationResult::Modify(feedback)
}
_ => ConfirmationResult::Cancel,
}
}
}
Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
ConfirmationResult::Cancel
}
Err(_) => ConfirmationResult::Cancel,
}
}
pub async fn confirm_file_write_with_ide(
path: &str,
old_content: Option<&str>,
new_content: &str,
ide_client: Option<&IdeClient>,
) -> crate::agent::ui::confirmation::ConfirmationResult {
use crate::agent::ui::confirmation::ConfirmationResult;
use inquire::{InquireError, Select, Text};
use tokio::sync::oneshot;
match old_content {
Some(old) => render_diff(old, new_content, path),
None => render_new_file(new_content, path),
};
let ide_connected = ide_client.map(|c| c.is_connected()).unwrap_or(false);
if ide_connected {
let client = ide_client.unwrap();
let abs_path = std::path::Path::new(path)
.canonicalize()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| path.to_string());
let (terminal_tx, terminal_rx) = oneshot::channel::<ConfirmationResult>();
let (cancel_tx, _cancel_rx) = oneshot::channel::<()>();
let (menu_ready_tx, menu_ready_rx) = oneshot::channel::<()>();
let path_owned = path.to_string();
let _ide_name = client.ide_name().unwrap_or("IDE").to_string();
let terminal_handle = tokio::task::spawn_blocking(move || {
let options = vec![
"Yes, allow once".to_string(),
"Yes, allow always".to_string(),
"Type here to suggest changes".to_string(),
];
println!("{}", "Apply this change?".white());
let _ = menu_ready_tx.send(());
let selection = Select::new("", options.clone())
.with_render_config(get_file_confirmation_render_config())
.with_page_size(3)
.with_help_message("↑↓ to move, Enter to select, Esc to cancel (or use IDE)")
.prompt();
let result = match selection {
Ok(answer) => {
if answer == options[0] {
ConfirmationResult::Proceed
} else if answer == options[1] {
let filename = std::path::Path::new(&path_owned)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path_owned.clone());
ConfirmationResult::ProceedAlways(filename)
} else {
println!();
match Text::new("What changes would you like?")
.with_help_message("Press Enter to submit, Esc to cancel")
.prompt()
{
Ok(feedback) if !feedback.trim().is_empty() => {
ConfirmationResult::Modify(feedback)
}
_ => ConfirmationResult::Cancel,
}
}
}
Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
ConfirmationResult::Cancel
}
Err(_) => ConfirmationResult::Cancel,
};
let _ = terminal_tx.send(result);
});
let _ = menu_ready_rx.await;
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let ide_future = client.open_diff(&abs_path, new_content);
tokio::select! {
ide_result = ide_future => {
let _ = cancel_tx.send(());
terminal_handle.abort();
let _ = terminal::disable_raw_mode();
let _ = execute!(
std::io::stdout(),
cursor::Show,
terminal::Clear(terminal::ClearType::FromCursorDown)
);
print!("\r");
let _ = std::io::stdout().flush();
match ide_result {
Ok(DiffResult::Accepted { content: _ }) => {
println!("\n{} Changes accepted in IDE", "✓".green());
return ConfirmationResult::Proceed;
}
Ok(DiffResult::Rejected) => {
println!("\n{} Changes rejected in IDE", "✗".red());
return ConfirmationResult::Cancel;
}
Err(e) => {
println!("\n{} IDE error: {}", "!".yellow(), e);
return ConfirmationResult::Cancel;
}
}
}
terminal_result = terminal_rx => {
let _ = client.close_diff(&abs_path).await;
match terminal_result {
Ok(result) => {
match &result {
ConfirmationResult::Proceed => {
println!("{} Changes accepted", "✓".green());
}
ConfirmationResult::ProceedAlways(_) => {
println!("{} Changes accepted (always for this file type)", "✓".green());
}
ConfirmationResult::Cancel => {
println!("{} Changes cancelled", "✗".red());
}
ConfirmationResult::Modify(_) => {
println!("{} Feedback provided", "→".cyan());
}
}
return result;
}
Err(_) => {
return ConfirmationResult::Cancel;
}
}
}
}
}
confirm_file_write(path, old_content, new_content)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_diff_render_doesnt_panic() {
let old = "line 1\nline 2\nline 3";
let new = "line 1\nmodified line 2\nline 3\nline 4";
render_diff(old, new, "test.txt");
}
#[test]
fn test_new_file_render_doesnt_panic() {
let content = "new content\nline 2";
render_new_file(content, "new_file.txt");
}
}