1use crate::ai::AiUsage;
2use crate::commands::commit::CommitPlan;
3use anyhow::Result;
4use crossterm::style::Stylize;
5use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
6use std::collections::HashMap;
7use std::io::{self, IsTerminal, Write};
8use std::time::Duration;
9
10pub fn spinner(message: &str) -> ProgressBar {
12 let pb = ProgressBar::new_spinner();
13 pb.set_draw_target(ProgressDrawTarget::stdout());
14 pb.set_style(
15 ProgressStyle::default_spinner()
16 .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
17 .template(" {spinner:.cyan} {msg}")
18 .unwrap(),
19 );
20 pb.set_message(message.to_string());
21 pb.enable_steady_tick(Duration::from_millis(80));
22 pb
23}
24
25pub fn spinner_done(pb: &ProgressBar, detail: Option<&str>) {
27 let msg = pb.message();
28 pb.finish_and_clear();
29 phase_ok(&msg, detail);
30}
31
32pub fn header(cmd: &str) {
34 println!();
35 println!(" {}", cmd.cyan().bold());
36 println!(" {}", "─".repeat(40).dim());
37 println!();
38}
39
40pub fn phase_ok(msg: &str, detail: Option<&str>) {
42 let suffix = detail
43 .map(|d| format!(" · {}", d.dim()))
44 .unwrap_or_default();
45 println!(" {} {msg}{suffix}", "✓".green().bold());
46}
47
48pub fn warn(msg: &str) {
50 println!(" {} {}", "⚠".yellow().bold(), msg.yellow());
51}
52
53pub fn info(msg: &str) {
55 println!(" {} {}", "ℹ".cyan(), msg.dim());
56}
57
58pub fn display_plan(
60 plan: &CommitPlan,
61 statuses: &HashMap<String, char>,
62 cache_label: Option<&str>,
63) {
64 let count = plan.commits.len();
65 let count_str = format!("{count} commit{}", if count == 1 { "" } else { "s" });
66 let label = match cache_label {
67 Some(l) => format!("{count_str} · {l}"),
68 None => count_str,
69 };
70
71 println!();
72 println!(" {} {}", "COMMIT PLAN".bold(), format!("· {label}").dim());
73 let rule = "─".repeat(50);
74 println!(" {}", rule.as_str().dim());
75
76 for (i, commit) in plan.commits.iter().enumerate() {
77 let order = commit.order.unwrap_or(i as u32 + 1);
78 let idx = format!("[{order}]");
79
80 println!();
81 println!(
82 " {} {}",
83 idx.as_str().cyan().bold(),
84 commit.message.as_str().bold()
85 );
86
87 if let Some(body) = &commit.body
88 && !body.is_empty()
89 {
90 for line in body.lines() {
91 println!(" {} {}", "│".dim(), line.dim());
92 }
93 }
94
95 if let Some(footer) = &commit.footer
96 && !footer.is_empty()
97 {
98 println!(" {}", "│".dim());
99 for line in footer.lines() {
100 println!(" {} {}", "│".dim(), line.yellow());
101 }
102 }
103
104 println!(" {}", "│".dim());
105
106 let fc = commit.files.len();
107 if fc == 0 {
108 println!(" {} {}", "└─".dim(), "(no files)".dim());
109 } else {
110 for (j, file) in commit.files.iter().enumerate() {
111 let is_last = j == fc - 1;
112 let connector = if is_last { "└─" } else { "├─" };
113 let status_char = statuses.get(file).copied().unwrap_or('~');
114 let status_styled = match status_char {
115 'A' => format!("{}", "A".green()),
116 'D' => format!("{}", "D".red()),
117 'M' => format!("{}", "M".yellow()),
118 'R' => format!("{}", "R".blue()),
119 _ => format!("{}", "·".dim()),
120 };
121 println!(" {} {} {}", connector.dim(), status_styled, file);
122 }
123 }
124 }
125
126 println!();
127 println!(" {}", rule.as_str().dim());
128}
129
130pub fn commit_start(index: usize, total: usize, message: &str) {
132 println!();
133 println!(
134 " {} {}",
135 format!("[{index}/{total}]").as_str().cyan().bold(),
136 message.bold()
137 );
138}
139
140pub fn file_staged(file: &str, success: bool) {
142 if success {
143 println!(" {} {}", "✓".green(), file.dim());
144 } else {
145 println!(" {} {} {}", "⚠".yellow(), file, "(not found)".dim());
146 }
147}
148
149pub fn commit_created(sha: &str) {
151 println!(" {} {}", "→".green().bold(), sha.green());
152}
153
154pub fn commit_skipped() {
156 println!(" {} {}", "−".yellow(), "skipped (no staged files)".dim());
157}
158
159pub fn commit_failed(reason: &str) {
161 println!(
162 " {} {} {}",
163 "✗".red().bold(),
164 "failed:".red(),
165 reason.dim()
166 );
167}
168
169pub fn summary(commits: &[(String, String)]) {
171 let count = commits.len();
172 println!();
173 println!(
174 " {} {} commit{} created",
175 "✓".green().bold(),
176 count.to_string().as_str().bold(),
177 if count == 1 { "" } else { "s" }
178 );
179 println!();
180 for (sha, msg) in commits {
181 println!(" {} {}", sha.as_str().dim(), msg);
182 }
183 println!();
184}
185
186pub fn invalid_messages(invalid: &[(usize, String, String)]) {
188 println!();
189 println!(
190 " {} {}",
191 "⚠".yellow().bold(),
192 format!(
193 "{} commit message{} failed validation:",
194 invalid.len(),
195 if invalid.len() == 1 { "" } else { "s" }
196 )
197 .yellow()
198 );
199 for (idx, msg, reason) in invalid {
200 println!(
201 " {} {} — {}",
202 format!("[{idx}]").cyan(),
203 msg,
204 reason.as_str().dim()
205 );
206 }
207 println!();
208}
209
210pub fn failed_commits(failed: &[(usize, String, String)]) {
212 println!(
213 " {} {}",
214 "⚠".yellow().bold(),
215 format!(
216 "{} commit{} failed:",
217 failed.len(),
218 if failed.len() == 1 { "" } else { "s" }
219 )
220 .yellow()
221 );
222 for (idx, msg, reason) in failed {
223 println!(
224 " {} {} — {}",
225 format!("[{idx}]").cyan(),
226 msg,
227 reason.as_str().dim()
228 );
229 }
230 println!();
231}
232
233pub fn tool_call(pb: &ProgressBar, cmd: &str) {
235 pb.println(format!(" {} {}", "▸".cyan(), cmd.dim()));
236}
237
238pub fn usage(usage: &AiUsage) {
240 let cost = usage
241 .cost_usd
242 .map(|c| format!(" · ${c:.4}"))
243 .unwrap_or_default();
244 println!(
245 " {} {} in / {} out{}",
246 "⊘".dim(),
247 format_tokens(usage.input_tokens).dim(),
248 format_tokens(usage.output_tokens).dim(),
249 cost.dim()
250 );
251}
252
253pub fn format_tokens(n: u64) -> String {
255 if n >= 1_000_000 {
256 format!("{:.1}M", n as f64 / 1_000_000.0)
257 } else if n >= 1_000 {
258 format!("{:.1}k", n as f64 / 1_000.0)
259 } else {
260 n.to_string()
261 }
262}
263
264pub fn confirm(prompt: &str) -> Result<bool> {
266 if !io::stdin().is_terminal() {
267 return Ok(false);
268 }
269
270 print!(" {} ", prompt.bold());
271 io::stdout().flush()?;
272
273 let mut input = String::new();
274 io::stdin().read_line(&mut input)?;
275 let trimmed = input.trim().to_lowercase();
276
277 Ok(trimmed == "y" || trimmed == "yes")
278}