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