Skip to main content

llm_git/
style.rs

1//! Terminal styling utilities for consistent CLI output.
2//!
3//! Respects `NO_COLOR` environment variable and terminal capabilities.
4
5use std::{
6   io::{self, Write},
7   sync::OnceLock,
8   thread,
9   time::Duration,
10};
11
12use owo_colors::OwoColorize;
13
14/// Whether color output is enabled (cached on first call).
15static COLOR_ENABLED: OnceLock<bool> = OnceLock::new();
16
17/// Whether stdout is piped (not a terminal), cached on first call.
18static PIPE_MODE: OnceLock<bool> = OnceLock::new();
19
20/// Check if colors should be used.
21pub fn colors_enabled() -> bool {
22   *COLOR_ENABLED.get_or_init(|| {
23      // NO_COLOR takes precedence (https://no-color.org/)
24      if std::env::var("NO_COLOR").is_ok() {
25         return false;
26      }
27      // Check if stdout is a terminal and supports color
28      supports_color::on(supports_color::Stream::Stdout).is_some_and(|level| level.has_basic)
29   })
30}
31
32/// Check if stdout is piped (not a terminal).
33///
34/// When true, only the raw commit message should go to stdout;
35/// all progress/status output should go to stderr.
36pub fn pipe_mode() -> bool {
37   *PIPE_MODE.get_or_init(|| {
38      use std::io::IsTerminal;
39      !std::io::stdout().is_terminal()
40   })
41}
42
43// === Color Palette ===
44
45/// Success: checkmarks, completed actions (green + bold).
46pub fn success(s: &str) -> String {
47   if colors_enabled() {
48      s.green().bold().to_string()
49   } else {
50      s.to_string()
51   }
52}
53
54/// Warning: soft limit violations, non-fatal issues (yellow).
55pub fn warning(s: &str) -> String {
56   if colors_enabled() {
57      s.yellow().to_string()
58   } else {
59      s.to_string()
60   }
61}
62
63/// Error: failures, hard errors (red + bold).
64pub fn error(s: &str) -> String {
65   if colors_enabled() {
66      s.red().bold().to_string()
67   } else {
68      s.to_string()
69   }
70}
71
72/// Info: informational messages (cyan).
73pub fn info(s: &str) -> String {
74   if colors_enabled() {
75      s.cyan().to_string()
76   } else {
77      s.to_string()
78   }
79}
80
81/// Print warning message, clearing any active spinner line first.
82///
83/// This ensures warnings appear on their own line even when a spinner is
84/// active, by writing a carriage return + clear-line escape sequence before the
85/// message.
86pub fn warn(msg: &str) {
87   if !pipe_mode() {
88      // Clear current line in case spinner is active (stdout, not stderr)
89      print!("\r\x1b[K");
90      io::stdout().flush().ok();
91   }
92   eprintln!("{} {}", warning(icons::WARNING), warning(msg));
93}
94
95/// Dim: less important details, file paths (dimmed).
96pub fn dim(s: &str) -> String {
97   if colors_enabled() {
98      s.dimmed().to_string()
99   } else {
100      s.to_string()
101   }
102}
103
104/// Bold: headers, key values.
105pub fn bold(s: &str) -> String {
106   if colors_enabled() {
107      s.bold().to_string()
108   } else {
109      s.to_string()
110   }
111}
112
113/// Model name styling (magenta).
114pub fn model(s: &str) -> String {
115   if colors_enabled() {
116      s.magenta().to_string()
117   } else {
118      s.to_string()
119   }
120}
121
122/// Commit type styling (blue + bold).
123pub fn commit_type(s: &str) -> String {
124   if colors_enabled() {
125      s.blue().bold().to_string()
126   } else {
127      s.to_string()
128   }
129}
130
131/// Scope styling (cyan).
132pub fn scope(s: &str) -> String {
133   if colors_enabled() {
134      s.cyan().to_string()
135   } else {
136      s.to_string()
137   }
138}
139
140/// Get terminal width, capped at 120 columns.
141pub fn term_width() -> usize {
142   terminal_size::terminal_size()
143      .map_or(80, |(w, _)| w.0 as usize)
144      .min(120)
145}
146
147// === Unicode Box Drawing ===
148
149/// Box drawing characters.
150pub mod box_chars {
151   pub const TOP_LEFT: char = '\u{256D}';
152   pub const TOP_RIGHT: char = '\u{256E}';
153   pub const BOTTOM_LEFT: char = '\u{2570}';
154   pub const BOTTOM_RIGHT: char = '\u{256F}';
155   pub const HORIZONTAL: char = '\u{2500}';
156   pub const VERTICAL: char = '\u{2502}';
157}
158
159/// Wrap text to fit within a given width, preserving words.
160fn wrap_line(line: &str, max_width: usize) -> Vec<String> {
161   if line.is_empty() {
162      return vec![String::new()];
163   }
164
165   let mut lines = Vec::new();
166   let mut current = String::new();
167
168   for word in line.split_whitespace() {
169      let word_len = word.chars().count();
170      let current_len = current.chars().count();
171
172      if current.is_empty() {
173         // First word on line - take it even if too long
174         current = word.to_string();
175      } else if current_len + 1 + word_len <= max_width {
176         // Word fits with space
177         current.push(' ');
178         current.push_str(word);
179      } else {
180         // Word doesn't fit - start new line
181         lines.push(current);
182         current = word.to_string();
183      }
184   }
185
186   if !current.is_empty() {
187      lines.push(current);
188   }
189
190   lines
191}
192
193/// Render a box-framed message with word wrapping.
194pub fn boxed_message(title: &str, content: &str, width: usize) -> String {
195   use box_chars::*;
196
197   let mut out = String::new();
198   let inner_width = width.saturating_sub(4); // Account for "│ " and " │"
199
200   // Top border with title
201   let title_len = title.chars().count();
202   let border_width = width.saturating_sub(2);
203   let padding = border_width.saturating_sub(title_len + 2);
204   let left_pad = padding / 2;
205   let right_pad = padding - left_pad;
206
207   out.push(TOP_LEFT);
208   out.push_str(&HORIZONTAL.to_string().repeat(left_pad));
209   out.push(' ');
210   out.push_str(&if colors_enabled() {
211      bold(title)
212   } else {
213      title.to_string()
214   });
215   out.push(' ');
216   out.push_str(&HORIZONTAL.to_string().repeat(right_pad));
217   out.push(TOP_RIGHT);
218   out.push('\n');
219
220   // Content lines with wrapping
221   for line in content.lines() {
222      let wrapped = wrap_line(line, inner_width);
223      for wrapped_line in wrapped {
224         out.push(VERTICAL);
225         out.push(' ');
226         let line_chars = wrapped_line.chars().count();
227         out.push_str(&wrapped_line);
228         let pad = inner_width.saturating_sub(line_chars);
229         out.push_str(&" ".repeat(pad));
230         out.push(' ');
231         out.push(VERTICAL);
232         out.push('\n');
233      }
234   }
235
236   // Bottom border
237   out.push(BOTTOM_LEFT);
238   out.push_str(&HORIZONTAL.to_string().repeat(border_width));
239   out.push(BOTTOM_RIGHT);
240
241   out
242}
243
244/// Print an info message that clears any spinner line first.
245pub fn print_info(msg: &str) {
246   use std::io::IsTerminal;
247   if std::io::stderr().is_terminal() && colors_enabled() {
248      // Clear line, print message with newline
249      eprintln!("\r\x1b[K{} {msg}", icons::INFO.cyan());
250   } else {
251      eprintln!("{} {msg}", icons::INFO);
252   }
253}
254
255/// Horizontal separator line.
256pub fn separator(width: usize) -> String {
257   let line = box_chars::HORIZONTAL.to_string().repeat(width);
258   if colors_enabled() { dim(&line) } else { line }
259}
260
261/// Section header with decorative lines.
262pub fn section_header(title: &str, width: usize) -> String {
263   let title_len = title.chars().count();
264   let line_len = (width.saturating_sub(title_len + 2)) / 2;
265   let line = box_chars::HORIZONTAL.to_string().repeat(line_len);
266
267   if colors_enabled() {
268      format!("{} {} {}", dim(&line), bold(title), dim(&line))
269   } else {
270      format!("{line} {title} {line}")
271   }
272}
273
274// === Status Icons ===
275
276pub mod icons {
277   pub const SUCCESS: &str = "\u{2713}";
278   pub const WARNING: &str = "\u{26A0}";
279   pub const ERROR: &str = "\u{2717}";
280   pub const INFO: &str = "\u{2139}";
281   pub const ARROW: &str = "\u{2192}";
282   pub const BULLET: &str = "\u{2022}";
283   pub const CLIPBOARD: &str = "\u{1F4CB}";
284   pub const SEARCH: &str = "\u{1F50D}";
285   pub const ROBOT: &str = "\u{1F916}";
286   pub const SAVE: &str = "\u{1F4BE}";
287}
288
289// === Spinner ===
290
291const SPINNER_FRAMES: &[char] = &[
292   '\u{280B}', '\u{2819}', '\u{2839}', '\u{2838}', '\u{283C}', '\u{2834}', '\u{2826}', '\u{2827}',
293   '\u{2807}', '\u{280F}',
294];
295
296/// Run an async operation with a spinner animation. Falls back to static text
297/// if not a TTY.
298pub async fn with_spinner<F: Future<Output = T>, T>(message: &str, f: F) -> T {
299   // No spinner if not a TTY or colors disabled
300   if !colors_enabled() {
301      eprintln!("{message}");
302      return f.await;
303   }
304   let (tx, rx) = std::sync::mpsc::channel::<()>();
305   let msg = message.to_string();
306
307   let spinner = thread::spawn(move || {
308      let mut idx = 0;
309      loop {
310         if rx.try_recv().is_ok() {
311            // Clear spinner line and show success
312            print!("\r\x1b[K{} {}\n", icons::SUCCESS.green(), msg);
313            io::stdout().flush().ok();
314            break;
315         }
316         print!("\r{} {}", SPINNER_FRAMES[idx].cyan(), msg);
317         io::stdout().flush().ok();
318         idx = (idx + 1) % SPINNER_FRAMES.len();
319         thread::sleep(Duration::from_millis(80));
320      }
321   });
322
323   let result = f.await;
324   tx.send(()).ok();
325   spinner.join().ok();
326   result
327}
328
329/// Run an async operation with a spinner, but show error on failure.
330pub async fn with_spinner_result<F: Future<Output = Result<T, E>>, T, E>(
331   message: &str,
332   f: F,
333) -> Result<T, E> {
334   if !colors_enabled() {
335      eprintln!("{message}");
336      return f.await;
337   }
338   let (tx, rx) = std::sync::mpsc::channel::<bool>();
339   let msg = message.to_string();
340
341   let spinner = thread::spawn(move || {
342      let mut idx = 0;
343      loop {
344         match rx.try_recv() {
345            Ok(success) => {
346               let icon = if success {
347                  icons::SUCCESS.green().to_string()
348               } else {
349                  icons::ERROR.red().to_string()
350               };
351               print!("\r\x1b[K{icon} {msg}\n");
352               io::stdout().flush().ok();
353               break;
354            },
355            Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
356            Err(std::sync::mpsc::TryRecvError::Empty) => {},
357         }
358         print!("\r{} {}", SPINNER_FRAMES[idx].cyan(), msg);
359         io::stdout().flush().ok();
360         idx = (idx + 1) % SPINNER_FRAMES.len();
361         thread::sleep(Duration::from_millis(80));
362      }
363   });
364
365   let result = f.await;
366   tx.send(result.is_ok()).ok();
367   spinner.join().ok();
368   result
369}