use crate::ai::AiUsage;
use crate::commands::commit::CommitPlan;
use anyhow::Result;
use crossterm::style::Stylize;
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
use std::collections::HashMap;
use std::io::{self, IsTerminal, Write};
use std::time::Duration;
pub fn spinner(message: &str) -> ProgressBar {
let pb = ProgressBar::new_spinner();
pb.set_draw_target(ProgressDrawTarget::stdout());
pb.set_style(
ProgressStyle::default_spinner()
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
.template(" {spinner:.cyan} {msg}")
.unwrap(),
);
pb.set_message(message.to_string());
pb.enable_steady_tick(Duration::from_millis(80));
pb
}
pub fn spinner_done(pb: &ProgressBar, detail: Option<&str>) {
let msg = pb.message();
pb.finish_and_clear();
phase_ok(&msg, detail);
}
pub fn header(cmd: &str) {
println!();
println!(" {}", cmd.cyan().bold());
println!(" {}", "─".repeat(40).dim());
println!();
}
pub fn phase_ok(msg: &str, detail: Option<&str>) {
let suffix = detail
.map(|d| format!(" · {}", d.dim()))
.unwrap_or_default();
println!(" {} {msg}{suffix}", "✓".green().bold());
}
pub fn warn(msg: &str) {
println!(" {} {}", "⚠".yellow().bold(), msg.yellow());
}
pub fn info(msg: &str) {
println!(" {} {}", "ℹ".cyan(), msg.dim());
}
pub fn display_plan(
plan: &CommitPlan,
statuses: &HashMap<String, char>,
cache_label: Option<&str>,
) {
let count = plan.commits.len();
let count_str = format!("{count} commit{}", if count == 1 { "" } else { "s" });
let label = match cache_label {
Some(l) => format!("{count_str} · {l}"),
None => count_str,
};
println!();
println!(" {} {}", "COMMIT PLAN".bold(), format!("· {label}").dim());
let rule = "─".repeat(50);
println!(" {}", rule.as_str().dim());
for (i, commit) in plan.commits.iter().enumerate() {
let order = commit.order.unwrap_or(i as u32 + 1);
let idx = format!("[{order}]");
println!();
println!(
" {} {}",
idx.as_str().cyan().bold(),
commit.message.as_str().bold()
);
if let Some(body) = &commit.body
&& !body.is_empty()
{
for line in body.lines() {
println!(" {} {}", "│".dim(), line.dim());
}
}
if let Some(footer) = &commit.footer
&& !footer.is_empty()
{
println!(" {}", "│".dim());
for line in footer.lines() {
println!(" {} {}", "│".dim(), line.yellow());
}
}
println!(" {}", "│".dim());
let fc = commit.files.len();
if fc == 0 {
println!(" {} {}", "└─".dim(), "(no files)".dim());
} else {
for (j, file) in commit.files.iter().enumerate() {
let is_last = j == fc - 1;
let connector = if is_last { "└─" } else { "├─" };
let status_char = statuses.get(file).copied().unwrap_or('~');
let status_styled = match status_char {
'A' => format!("{}", "A".green()),
'D' => format!("{}", "D".red()),
'M' => format!("{}", "M".yellow()),
'R' => format!("{}", "R".blue()),
_ => format!("{}", "·".dim()),
};
println!(" {} {} {}", connector.dim(), status_styled, file);
}
}
}
println!();
println!(" {}", rule.as_str().dim());
}
pub fn commit_start(index: usize, total: usize, message: &str) {
println!();
println!(
" {} {}",
format!("[{index}/{total}]").as_str().cyan().bold(),
message.bold()
);
}
pub fn file_staged(file: &str, success: bool) {
if success {
println!(" {} {}", "✓".green(), file.dim());
} else {
println!(" {} {} {}", "⚠".yellow(), file, "(not found)".dim());
}
}
pub fn commit_created(sha: &str) {
println!(" {} {}", "→".green().bold(), sha.green());
}
pub fn commit_skipped() {
println!(" {} {}", "−".yellow(), "skipped (no staged files)".dim());
}
pub fn commit_failed(reason: &str) {
println!(
" {} {} {}",
"✗".red().bold(),
"failed:".red(),
reason.dim()
);
}
pub fn summary(commits: &[(String, String)]) {
let count = commits.len();
println!();
println!(
" {} {} commit{} created",
"✓".green().bold(),
count.to_string().as_str().bold(),
if count == 1 { "" } else { "s" }
);
println!();
for (sha, msg) in commits {
println!(" {} {}", sha.as_str().dim(), msg);
}
println!();
}
pub fn invalid_messages(invalid: &[(usize, String, String)]) {
println!();
println!(
" {} {}",
"⚠".yellow().bold(),
format!(
"{} commit message{} failed validation:",
invalid.len(),
if invalid.len() == 1 { "" } else { "s" }
)
.yellow()
);
for (idx, msg, reason) in invalid {
println!(
" {} {} — {}",
format!("[{idx}]").cyan(),
msg,
reason.as_str().dim()
);
}
println!();
}
pub fn failed_commits(failed: &[(usize, String, String)]) {
println!(
" {} {}",
"⚠".yellow().bold(),
format!(
"{} commit{} failed:",
failed.len(),
if failed.len() == 1 { "" } else { "s" }
)
.yellow()
);
for (idx, msg, reason) in failed {
println!(
" {} {} — {}",
format!("[{idx}]").cyan(),
msg,
reason.as_str().dim()
);
}
println!();
}
pub fn tool_call(pb: &ProgressBar, cmd: &str) {
pb.println(format!(" {} {}", "▸".cyan(), cmd.dim()));
}
pub fn usage(usage: &AiUsage) {
let cost = usage
.cost_usd
.map(|c| format!(" · ${c:.4}"))
.unwrap_or_default();
println!(
" {} {} in / {} out{}",
"⊘".dim(),
format_tokens(usage.input_tokens).dim(),
format_tokens(usage.output_tokens).dim(),
cost.dim()
);
}
pub fn format_tokens(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}k", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
pub fn confirm(prompt: &str) -> Result<bool> {
if !io::stdin().is_terminal() {
return Ok(false);
}
print!(" {} ", prompt.bold());
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let trimmed = input.trim().to_lowercase();
Ok(trimmed == "y" || trimmed == "yes")
}