Skip to main content

sr_ai/ui/
mod.rs

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
10/// Create a styled spinner for long-running operations.
11pub 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
25/// Finish a spinner, replacing it with a green checkmark line.
26pub 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
32/// Print the command header.
33pub fn header(cmd: &str) {
34    println!();
35    println!("  {}", cmd.cyan().bold());
36    println!("  {}", "─".repeat(40).dim());
37    println!();
38}
39
40/// Print a completed phase with green checkmark.
41pub 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
48/// Print a warning message.
49pub fn warn(msg: &str) {
50    println!("  {} {}", "⚠".yellow().bold(), msg.yellow());
51}
52
53/// Print an info message.
54pub fn info(msg: &str) {
55    println!("  {} {}", "ℹ".cyan(), msg.dim());
56}
57
58/// Display the commit plan with file statuses and optional cache label.
59pub 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
130/// Print commit execution header.
131pub 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
140/// Print a file staging result.
141pub 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
149/// Print commit created with short SHA.
150pub fn commit_created(sha: &str) {
151    println!("    {} {}", "→".green().bold(), sha.green());
152}
153
154/// Print commit skipped notice.
155pub fn commit_skipped() {
156    println!("    {} {}", "−".yellow(), "skipped (no staged files)".dim());
157}
158
159/// Print commit failed notice.
160pub fn commit_failed(reason: &str) {
161    println!(
162        "    {} {} {}",
163        "✗".red().bold(),
164        "failed:".red(),
165        reason.dim()
166    );
167}
168
169/// Print final summary with commit list.
170pub 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
186/// Display invalid commit messages found during pre-validation.
187pub 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
210/// Display commits that failed during execution.
211pub 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
233/// Display a tool call above an active spinner.
234pub fn tool_call(pb: &ProgressBar, cmd: &str) {
235    pb.println(format!("    {} {}", "▸".cyan(), cmd.dim()));
236}
237
238/// Display token usage and cost.
239pub 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
253/// Format a token count for display (e.g. 1234 -> "1.2k").
254pub 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
264/// Ask for yes/no confirmation. Returns false in non-TTY environments.
265pub 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}