1use kdam::Animation;
2use std::borrow::Cow;
3use std::fmt::{Display, Write as _};
4use std::io::IsTerminal as _;
5use std::num::NonZeroU16;
6use std::sync::LazyLock;
7use std::sync::atomic::{AtomicU8, Ordering};
8use std::time::Duration;
9use syntect::easy::HighlightLines;
10use syntect::highlighting::{Theme, ThemeSet};
11use syntect::parsing::SyntaxSet;
12use syntect::util::as_24_bit_terminal_escaped;
13use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum OutputMode {
18 Quiet = 0,
20 Normal = 1,
22 Verbose = 2,
24 Json = 3,
26}
27
28static OUTPUT_MODE: AtomicU8 = AtomicU8::new(OutputMode::Normal as u8);
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31enum ColorMode {
32 Auto,
33 Always,
34 Never,
35}
36
37static COLOR_MODE: LazyLock<ColorMode> = LazyLock::new(color_mode_from_env);
38
39pub fn init_output_mode(mode: Option<OutputMode>) {
40 let mode = mode
41 .or_else(output_mode_from_env)
42 .unwrap_or(OutputMode::Normal);
43 set_output_mode(mode);
44}
45
46pub fn set_output_mode(mode: OutputMode) {
48 OUTPUT_MODE.store(mode as u8, Ordering::Relaxed);
49}
50
51pub fn output_mode() -> OutputMode {
52 match OUTPUT_MODE.load(Ordering::Relaxed) {
53 0 => OutputMode::Quiet,
54 2 => OutputMode::Verbose,
55 3 => OutputMode::Json,
56 _ => OutputMode::Normal,
57 }
58}
59
60pub fn is_quiet() -> bool {
61 matches!(output_mode(), OutputMode::Quiet | OutputMode::Json)
62}
63
64pub fn is_json() -> bool {
65 matches!(output_mode(), OutputMode::Json)
66}
67
68pub fn is_verbose() -> bool {
69 matches!(output_mode(), OutputMode::Verbose)
70}
71
72fn output_mode_from_env() -> Option<OutputMode> {
73 if truthy_env("OY_QUIET") {
74 return Some(OutputMode::Quiet);
75 }
76 if truthy_env("OY_VERBOSE") {
77 return Some(OutputMode::Verbose);
78 }
79 match std::env::var("OY_OUTPUT")
80 .ok()?
81 .to_ascii_lowercase()
82 .as_str()
83 {
84 "quiet" => Some(OutputMode::Quiet),
85 "verbose" => Some(OutputMode::Verbose),
86 "json" => Some(OutputMode::Json),
87 "normal" => Some(OutputMode::Normal),
88 _ => None,
89 }
90}
91
92fn truthy_env(name: &str) -> bool {
93 matches!(
94 std::env::var(name).ok().as_deref(),
95 Some("1" | "true" | "yes" | "on")
96 )
97}
98
99fn color_mode_from_env() -> ColorMode {
100 color_mode_from_values(
101 std::env::var_os("NO_COLOR").is_some(),
102 std::env::var("OY_COLOR").ok().as_deref(),
103 )
104}
105
106fn color_mode_from_values(no_color: bool, oy_color: Option<&str>) -> ColorMode {
107 if no_color {
108 return ColorMode::Never;
109 }
110 match oy_color.map(str::to_ascii_lowercase).as_deref() {
111 Some("always" | "1" | "true" | "yes" | "on") => ColorMode::Always,
112 Some("never" | "0" | "false" | "no" | "off") => ColorMode::Never,
113 _ => ColorMode::Auto,
114 }
115}
116
117pub fn color_enabled() -> bool {
118 color_enabled_for_stdout(std::io::stdout().is_terminal())
119}
120
121fn color_enabled_for_stdout(stdout_is_terminal: bool) -> bool {
122 color_enabled_for_mode(*COLOR_MODE, stdout_is_terminal)
123}
124
125fn color_enabled_for_mode(mode: ColorMode, stdout_is_terminal: bool) -> bool {
126 match mode {
127 ColorMode::Always => true,
128 ColorMode::Never => false,
129 ColorMode::Auto => stdout_is_terminal,
130 }
131}
132
133pub fn terminal_width() -> usize {
134 terminal_size::terminal_size()
135 .map(|(terminal_size::Width(width), _)| width as usize)
136 .filter(|width| *width >= 40)
137 .unwrap_or(100)
138}
139
140pub fn paint(code: &str, text: impl Display) -> String {
141 if color_enabled() {
142 format!("\x1b[{code}m{text}\x1b[0m")
143 } else {
144 text.to_string()
145 }
146}
147
148pub fn faint(text: impl Display) -> String {
149 paint("2", text)
150}
151
152pub fn bold(text: impl Display) -> String {
153 paint("1", text)
154}
155
156pub fn cyan(text: impl Display) -> String {
157 paint("36", text)
158}
159
160pub fn green(text: impl Display) -> String {
161 paint("32", text)
162}
163
164pub fn yellow(text: impl Display) -> String {
165 paint("33", text)
166}
167
168pub fn red(text: impl Display) -> String {
169 paint("31", text)
170}
171
172pub fn magenta(text: impl Display) -> String {
173 paint("35", text)
174}
175
176pub fn status_text(ok: bool, text: impl Display) -> String {
177 if ok { green(text) } else { red(text) }
178}
179
180pub fn bool_text(value: bool) -> String {
181 status_text(value, value)
182}
183
184pub fn path(text: impl Display) -> String {
185 paint("1;36", text)
186}
187
188pub fn out(text: &str) {
189 print!("{text}");
190}
191
192pub fn err(text: &str) {
193 eprint!("{text}");
194}
195
196pub fn line(text: impl Display) {
197 out(&format!("{text}\n"));
198}
199
200pub fn err_line(text: impl Display) {
201 err(&format!("{text}\n"));
202}
203
204pub fn markdown(text: &str) {
205 out(&render_markdown(text));
206}
207
208fn render_markdown(text: &str) -> String {
209 if !color_enabled() {
210 return text.to_string();
211 }
212 let mut in_fence = false;
213 let mut out = String::new();
214 for line in text.lines() {
215 let trimmed = line.trim_start();
216 let rendered = if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
217 in_fence = !in_fence;
218 faint(line)
219 } else if in_fence {
220 cyan(line)
221 } else if trimmed.starts_with('#') {
222 paint("1;35", line)
223 } else if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
224 cyan(line)
225 } else {
226 line.to_string()
227 };
228 let _ = writeln!(out, "{rendered}");
229 }
230 if text.ends_with('\n') {
231 out
232 } else {
233 out.trim_end_matches('\n').to_string()
234 }
235}
236
237pub fn code(path: &str, text: &str, first_line: usize) -> String {
238 numbered_block(path, &normalize_code_preview_text(text), first_line)
239}
240
241pub fn text_block(title: &str, text: &str) -> String {
242 numbered_block(title, text, 1)
243}
244
245pub fn block_title(title: &str) -> String {
246 path(format_args!("── {title}"))
247}
248
249#[cfg(test)]
250fn numbered_line(line_number: usize, width: usize, text: &str) -> String {
251 numbered_line_with_max_width(line_number, width, text, usize::MAX)
252}
253
254fn numbered_line_with_max_width(
255 line_number: usize,
256 width: usize,
257 text: &str,
258 max_width: usize,
259) -> String {
260 let text = normalize_code_preview_text(text);
261 let prefix = format!(
262 "{} {} ",
263 faint(format_args!("{line_number:>width$}")),
264 faint("│")
265 );
266 let available = max_width
267 .saturating_sub(ansi_stripped_width(&prefix))
268 .max(1);
269 format!("{prefix}{}", truncate_width(&text, available))
270}
271
272fn normalize_code_preview_text(text: &str) -> Cow<'_, str> {
273 const TAB_WIDTH: usize = 4;
274 if !text.contains('\t') {
275 return Cow::Borrowed(text);
276 }
277
278 let mut out = String::with_capacity(text.len());
279 let mut column = 0usize;
280 for ch in text.chars() {
281 match ch {
282 '\t' => {
283 let spaces = TAB_WIDTH - (column % TAB_WIDTH);
284 out.extend(std::iter::repeat_n(' ', spaces));
285 column += spaces;
286 }
287 '\n' | '\r' => {
288 out.push(ch);
289 column = 0;
290 }
291 _ => {
292 out.push(ch);
293 column += UnicodeWidthChar::width(ch).unwrap_or(0);
294 }
295 }
296 }
297 Cow::Owned(out)
298}
299
300fn numbered_block(title: &str, text: &str, first_line: usize) -> String {
301 let title = if title.is_empty() { "text" } else { title };
302 let line_count = text.lines().count().max(1);
303 let width = first_line
304 .saturating_add(line_count.saturating_sub(1))
305 .max(1)
306 .to_string()
307 .len();
308 let max_width = terminal_width().saturating_sub(4).max(40);
309 let code_width = max_width.saturating_sub(width + 3).max(1);
310 let mut out = String::new();
311 let _ = writeln!(out, "{}", truncate_width(&block_title(title), max_width));
312 if text.is_empty() {
313 let _ = writeln!(
314 out,
315 "{}",
316 numbered_line_with_max_width(first_line, width, "", max_width)
317 );
318 } else {
319 let display_text = text
320 .lines()
321 .map(|line| truncate_width(line, code_width))
322 .collect::<Vec<_>>()
323 .join("\n");
324 let highlighted = highlighted_block(title, &display_text);
325 let lines = highlighted.as_deref().unwrap_or(&display_text).lines();
326 for (idx, line) in lines.enumerate() {
327 let _ = writeln!(
328 out,
329 "{}",
330 numbered_line_with_max_width(first_line + idx, width, line, max_width)
331 );
332 }
333 }
334 out.trim_end().to_string()
335}
336
337static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
338static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
339
340fn highlighted_block(title: &str, text: &str) -> Option<String> {
341 if !color_enabled() {
342 return None;
343 }
344 let syntax = syntax_for_title(title)?;
345 let theme = terminal_theme()?;
346 let mut highlighter = HighlightLines::new(syntax, theme);
347 let mut out = String::new();
348 for line in text.lines() {
349 let ranges = highlighter.highlight_line(line, &SYNTAX_SET).ok()?;
350 let _ = writeln!(out, "{}", as_24_bit_terminal_escaped(&ranges, false));
351 }
352 Some(if text.ends_with('\n') {
353 out
354 } else {
355 out.trim_end_matches('\n').to_string()
356 })
357}
358
359fn syntax_for_title(title: &str) -> Option<&'static syntect::parsing::SyntaxReference> {
360 let syntaxes = &*SYNTAX_SET;
361 let name = title.rsplit('/').next().unwrap_or(title);
362 if let Some(ext) = name.rsplit_once('.').map(|(_, ext)| ext) {
363 syntaxes.find_syntax_by_extension(ext)
364 } else {
365 syntaxes.find_syntax_by_token(name)
366 }
367 .or_else(|| syntaxes.find_syntax_by_name(title))
368}
369
370fn terminal_theme() -> Option<&'static Theme> {
371 THEME_SET
372 .themes
373 .get("base16-ocean.dark")
374 .or_else(|| THEME_SET.themes.values().next())
375}
376
377pub fn diff(text: &str) -> String {
378 if !color_enabled() {
379 return text.to_string();
380 }
381 let mut out = String::new();
382 for line in text.lines() {
383 let rendered = if line.starts_with("+++") || line.starts_with("---") {
384 bold(line)
385 } else if line.starts_with("@@") {
386 cyan(line)
387 } else if line.starts_with('+') {
388 green(line)
389 } else if line.starts_with('-') {
390 red(line)
391 } else {
392 line.to_string()
393 };
394 let _ = writeln!(out, "{rendered}");
395 }
396 if text.ends_with('\n') {
397 out
398 } else {
399 out.trim_end_matches('\n').to_string()
400 }
401}
402
403pub fn section(title: &str) {
404 line(bold(title));
405}
406
407pub fn kv(key: &str, value: impl Display) {
408 line(format_args!(
409 " {} {value}",
410 faint(format_args!("{key:<11}"))
411 ));
412}
413
414pub fn success(text: impl Display) {
415 line(format_args!("{} {text}", green("✓")));
416}
417
418pub fn warn(text: impl Display) {
419 line(format_args!("{} {text}", yellow("!")));
420}
421
422pub fn progress(
423 label: &str,
424 current: usize,
425 total: usize,
426 detail: impl Display,
427 elapsed: Duration,
428) {
429 if is_quiet() {
430 return;
431 }
432 line(progress_line(
433 label,
434 current,
435 total,
436 &detail.to_string(),
437 elapsed,
438 ));
439}
440
441fn progress_line(
442 label: &str,
443 current: usize,
444 total: usize,
445 detail: &str,
446 elapsed: Duration,
447) -> String {
448 let total = total.max(1);
449 let current = current.min(total);
450 let head = format!(
451 " {} {current}/{total} {}",
452 progress_bar(current, total, 18),
453 cyan(label)
454 );
455 if detail.trim().is_empty() {
456 format!("{head} · {}", faint(format_duration(elapsed)))
457 } else {
458 format!("{head} · {detail} · {}", faint(format_duration(elapsed)))
459 }
460}
461
462fn progress_bar(current: usize, total: usize, width: u16) -> String {
463 let total = total.max(1);
464 let current = current.min(total);
465 let percentage = current as f32 / total as f32;
466 Animation::FillUp.fmt_render(
467 NonZeroU16::new(width.max(1)).expect("progress bar width is non-zero"),
468 percentage,
469 &None,
470 )
471}
472
473pub fn tool_batch(round: usize, count: usize) {
474 if is_quiet() {
475 return;
476 }
477 err_line(tool_batch_line(round, count));
478}
479
480pub fn tool_start(name: &str, detail: &str) {
481 if is_quiet() {
482 return;
483 }
484 err_line(tool_start_line(name, detail));
485}
486
487pub fn tool_result(name: &str, elapsed: Duration, preview: &str) {
488 if is_quiet() {
489 return;
490 }
491 let preview = preview.trim_end();
492 let head = tool_result_head(name, elapsed);
493 let Some((first, rest)) = preview.split_once('\n') else {
494 if preview.is_empty() {
495 err_line(head);
496 } else {
497 err_line(format_args!("{head} · {first}", first = preview));
498 }
499 return;
500 };
501 err_line(format_args!("{head} · {first}"));
502 for line in rest.lines() {
503 err_line(format_args!(" {line}"));
504 }
505}
506
507pub fn tool_error(name: &str, elapsed: Duration, err: impl Display) {
508 if is_quiet() {
509 return;
510 }
511 err_line(format_args!(
512 " {} {name} {} · {err:#}",
513 red("✗"),
514 format_duration(elapsed)
515 ));
516}
517
518pub fn format_duration(elapsed: Duration) -> String {
519 if elapsed.as_millis() < 1000 {
520 format!("{}ms", elapsed.as_millis())
521 } else {
522 format!("{:.1}s", elapsed.as_secs_f64())
523 }
524}
525
526fn tool_batch_line(round: usize, count: usize) -> String {
527 format!("{} tools r{round} ×{count}", magenta("↻"))
528}
529
530fn tool_start_line(name: &str, detail: &str) -> String {
531 if detail.is_empty() {
532 format!(" {} {name}", cyan("→"))
533 } else {
534 format!(" {} {name} · {detail}", cyan("→"))
535 }
536}
537
538fn tool_result_head(name: &str, elapsed: Duration) -> String {
539 format!(" {} {name} {}", green("✓"), format_duration(elapsed))
540}
541
542pub fn compact_spaces(value: &str) -> String {
543 value.split_whitespace().collect::<Vec<_>>().join(" ")
544}
545
546pub fn truncate_chars(text: &str, max: usize) -> String {
547 truncate_width(text, max)
548}
549
550pub fn truncate_width(text: &str, max_width: usize) -> String {
551 if ansi_stripped_width(text) <= max_width {
552 return text.to_string();
553 }
554 truncate_plain_width(text, max_width)
555}
556
557fn truncate_plain_width(text: &str, max_width: usize) -> String {
558 if UnicodeWidthStr::width(text) <= max_width {
559 return text.to_string();
560 }
561 let ellipsis = "…";
562 let limit = max_width.saturating_sub(UnicodeWidthStr::width(ellipsis));
563 let mut out = String::new();
564 let mut width = 0usize;
565 for ch in text.chars() {
566 let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
567 if width + ch_width > limit {
568 break;
569 }
570 width += ch_width;
571 out.push(ch);
572 }
573 out.push_str(ellipsis);
574 out
575}
576
577fn ansi_stripped_width(text: &str) -> usize {
578 let mut width = 0usize;
579 let mut chars = text.chars().peekable();
580 while let Some(ch) = chars.next() {
581 if ch == '\u{1b}' && chars.peek() == Some(&'[') {
582 chars.next();
583 for next in chars.by_ref() {
584 if ('@'..='~').contains(&next) {
585 break;
586 }
587 }
588 } else {
589 width += UnicodeWidthChar::width(ch).unwrap_or(0);
590 }
591 }
592 width
593}
594
595pub fn compact_preview(text: &str, max: usize) -> String {
596 truncate_width(&compact_spaces(text), max)
597}
598
599pub fn clamp_lines(text: &str, max_lines: usize, max_cols: usize) -> String {
600 let mut out = String::new();
601 let lines = text.lines().collect::<Vec<_>>();
602 for line in lines.iter().take(max_lines) {
603 if !out.is_empty() {
604 out.push('\n');
605 }
606 out.push_str(&truncate_width(line, max_cols));
607 }
608 if lines.len() > max_lines {
609 let _ = write!(out, "\n… {} more lines", lines.len() - max_lines);
610 }
611 out
612}
613
614#[allow(dead_code)]
615pub fn wrap_line(text: &str, indent: &str) -> String {
616 let width = terminal_width().saturating_sub(indent.width()).max(20);
617 textwrap::wrap(text, width)
618 .into_iter()
619 .map(|line| format!("{indent}{line}"))
620 .collect::<Vec<_>>()
621 .join("\n")
622}
623
624pub fn head_tail(text: &str, max_chars: usize) -> (String, bool) {
625 if text.chars().count() <= max_chars {
626 return (text.to_string(), false);
627 }
628 let head_len = max_chars / 2;
629 let tail_len = max_chars.saturating_sub(head_len);
630 let head = text.chars().take(head_len).collect::<String>();
631 let tail = text
632 .chars()
633 .rev()
634 .take(tail_len)
635 .collect::<Vec<_>>()
636 .into_iter()
637 .rev()
638 .collect::<String>();
639 let hidden = text
640 .chars()
641 .count()
642 .saturating_sub(head.chars().count() + tail.chars().count());
643 (
644 format!("{head}\n… [truncated {hidden} chars] …\n{tail}"),
645 true,
646 )
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652
653 fn color_mode_name(mode: ColorMode) -> &'static str {
654 match mode {
655 ColorMode::Auto => "auto",
656 ColorMode::Always => "always",
657 ColorMode::Never => "never",
658 }
659 }
660
661 #[test]
662 fn color_mode_env_parsing() {
663 assert_eq!(color_mode_name(color_mode_from_values(false, None)), "auto");
664 assert_eq!(
665 color_mode_name(color_mode_from_values(false, Some("always"))),
666 "always"
667 );
668 assert_eq!(
669 color_mode_name(color_mode_from_values(false, Some("on"))),
670 "always"
671 );
672 assert_eq!(
673 color_mode_name(color_mode_from_values(false, Some("off"))),
674 "never"
675 );
676 assert_eq!(
677 color_mode_name(color_mode_from_values(true, Some("always"))),
678 "never"
679 );
680 }
681
682 #[test]
683 fn color_auto_requires_terminal() {
684 assert!(!color_enabled_for_mode(ColorMode::Auto, false));
685 assert!(color_enabled_for_mode(ColorMode::Auto, true));
686 assert!(color_enabled_for_mode(ColorMode::Always, false));
687 assert!(!color_enabled_for_mode(ColorMode::Never, true));
688 }
689
690 #[test]
691 fn elapsed_format_is_compact() {
692 assert_eq!(format_duration(Duration::from_millis(42)), "42ms");
693 assert_eq!(format_duration(Duration::from_millis(1250)), "1.2s");
694 }
695
696 #[test]
697 fn progress_line_shows_bar_count_detail_and_elapsed() {
698 set_output_mode(OutputMode::Normal);
699 assert_eq!(progress_bar(2, 4, 8), "|████▂ |");
700 assert_eq!(
701 progress_line("review", 2, 4, "chunk 3", Duration::from_millis(1250)),
702 " |█████████▂ | 2/4 review · chunk 3 · 1.2s"
703 );
704 }
705
706 #[test]
707 fn tool_progress_lines_are_dense() {
708 set_output_mode(OutputMode::Normal);
709 assert_eq!(tool_batch_line(2, 3), "↻ tools r2 ×3");
710 assert_eq!(
711 tool_start_line("read", "path=src/main.rs"),
712 " → read · path=src/main.rs"
713 );
714 assert_eq!(
715 tool_result_head("read", Duration::from_millis(42)),
716 " ✓ read 42ms"
717 );
718 }
719
720 #[test]
721 fn numbered_line_expands_tabs_to_stable_columns() {
722 set_output_mode(OutputMode::Normal);
723 assert_eq!(numbered_line(7, 1, "\tlet x = 1;"), "7 │ let x = 1;");
724 assert_eq!(numbered_line(8, 1, "ab\tcd"), "8 │ ab cd");
725 assert_eq!(
726 code("demo.rs", "\tfn main() {}\n\t\tprintln!(\"hi\");", 1),
727 "── demo.rs\n1 │ fn main() {}\n2 │ println!(\"hi\");"
728 );
729 }
730
731 #[test]
732 fn numbered_line_clamps_long_read_lines_to_preview_width() {
733 set_output_mode(OutputMode::Normal);
734 let line = numbered_line_with_max_width(
735 394,
736 3,
737 r#" .filter(|line| !line.starts_with(&format!("> {}", prompts::AUDIT_TRANSPARENCY_PREFIX)))"#,
738 40,
739 );
740 assert!(UnicodeWidthStr::width(line.as_str()) <= 40, "{line}");
741 assert!(line.starts_with("394 │ "));
742 assert!(line.ends_with('…'));
743 assert!(!line.contains('\n'));
744 }
745
746 #[test]
747 fn code_preview_lines_fit_tool_result_indent_width() {
748 set_output_mode(OutputMode::Normal);
749 let preview = code(
750 "src/audit.rs",
751 r#"pub(crate) fn with_transparency_line(report: &str, snippet: &str) -> String {
752 .filter(|line| !line.starts_with(&format!("> {}", prompts::AUDIT_TRANSPARENCY_PREFIX)))"#,
753 390,
754 );
755 let max_width = terminal_width().saturating_sub(4).max(40);
756 for line in preview.lines() {
757 assert!(
758 UnicodeWidthStr::width(line) <= max_width,
759 "line exceeded {max_width}: {line}"
760 );
761 }
762 }
763}