diff --git a/src/claude_code.rs b/src/claude_code.rs index 884a5df..b82a5aa 100644 --- a/src/claude_code.rs +++ b/src/claude_code.rs @@ -1,32 +1,11 @@ use crate::config::Config; +use crate::output; use crate::provider::{AgentSession, BootstrapMessage, OutputChunk, ProviderError, StepMessage}; use serde::Deserialize; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; -/// ANSI color codes for verbose output. -mod color { - // Label prefix — dim grey - pub const DIM: &str = "\x1b[38;5;243m"; - // Content text — lighter grey - pub const LIGHT: &str = "\x1b[38;5;250m"; - // Tool names — soft blue - pub const TOOL: &str = "\x1b[38;5;111m"; - // Thinking — soft purple - pub const THINKING: &str = "\x1b[38;5;183m"; - // Tool result — soft green - pub const RESULT: &str = "\x1b[38;5;151m"; - // Message/prompt — soft yellow - pub const PROMPT: &str = "\x1b[38;5;223m"; - // Launch command — soft cyan - pub const CMD: &str = "\x1b[38;5;152m"; - // Separator — very dim - pub const SEP: &str = "\x1b[38;5;238m"; - // Reset - pub const RESET: &str = "\x1b[0m"; -} - /// Claude Code CLI provider adapter. /// /// Implements the `AgentSession` trait by driving a single long-lived `claude` @@ -167,25 +146,26 @@ impl ClaudeCodeAdapter { .stderr(Stdio::piped()); if self.verbose { + let c = output::stderr_colors(); let args: Vec<_> = cmd .get_args() .map(|a| a.to_string_lossy().to_string()) .collect(); eprintln!( "{}[verbose]{} {}launch:{} {} {}{}", - color::DIM, - color::RESET, - color::DIM, - color::RESET, - color::CMD, + c.dim, + c.reset, + c.dim, + c.reset, + c.cmd, args.join(" "), - color::RESET + c.reset ); eprintln!( "{} binary: {}{}", - color::DIM, + c.dim, cmd.get_program().to_string_lossy(), - color::RESET + c.reset ); } @@ -226,16 +206,17 @@ impl ClaudeCodeAdapter { let input_line = format_stream_input(message); if self.verbose { + let c = output::stderr_colors(); eprintln!( "{}[verbose]{} {}prompt ({} bytes):{}", - color::DIM, - color::RESET, - color::DIM, + c.dim, + c.reset, + c.dim, message.len(), - color::RESET + c.reset ); - eprintln!("{}{}{}", color::PROMPT, message, color::RESET); - eprintln!("{}───{}", color::SEP, color::RESET); + eprintln!("{}{}{}", c.prompt, message, c.reset); + eprintln!("{}───{}", c.sep, c.reset); } stdin @@ -360,6 +341,8 @@ impl<'a> Iterator for StreamTurnIterator<'a> { return None; } + let c = output::stderr_colors(); + loop { let mut line = String::new(); match self.reader.read_line(&mut line) { @@ -427,7 +410,22 @@ impl<'a> Iterator for StreamTurnIterator<'a> { .chars() .take(12) .collect::(); - eprintln!("{}[verbose]{} {}tool:{} {}{}{} {}{}{} {}({}){}", color::DIM, color::RESET, color::DIM, color::RESET, color::TOOL, name, color::RESET, color::LIGHT, input_preview, color::RESET, color::DIM, id_short, color::RESET); + eprintln!( + "{}[verbose]{} {}tool:{} {}{}{} {}{}{} {}({}){}", + c.dim, + c.reset, + c.dim, + c.reset, + c.tool, + name, + c.reset, + c.light, + input_preview, + c.reset, + c.dim, + id_short, + c.reset + ); } } "thinking" => { @@ -435,17 +433,9 @@ impl<'a> Iterator for StreamTurnIterator<'a> { if let Some(thinking) = &block.thinking { eprintln!( "{}[verbose]{} {}thinking:{}", - color::DIM, - color::RESET, - color::DIM, - color::RESET - ); - eprintln!( - "{}{}{}", - color::THINKING, - thinking, - color::RESET + c.dim, c.reset, c.dim, c.reset ); + eprintln!("{}{}{}", c.thinking, thinking, c.reset); } } } @@ -479,20 +469,15 @@ impl<'a> Iterator for StreamTurnIterator<'a> { .collect::(); eprintln!( "{}[verbose]{} {}result:{} {}({}){}", - color::DIM, - color::RESET, - color::DIM, - color::RESET, - color::DIM, + c.dim, + c.reset, + c.dim, + c.reset, + c.dim, id_short, - color::RESET - ); - eprintln!( - "{}{}{}", - color::RESULT, - result_text, - color::RESET + c.reset ); + eprintln!("{}{}{}", c.result, result_text, c.reset); } } } diff --git a/src/lib.rs b/src/lib.rs index 11b6d36..91d098d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod discovery; pub mod executor; pub mod exit_code; pub mod expand; +pub mod output; pub mod provider; pub mod report; pub mod run; diff --git a/src/main.rs b/src/main.rs index e6ece35..4a0e460 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ use bugatti::exit_code::{ EXIT_STEP_ERROR, }; use bugatti::expand; +use bugatti::output; use bugatti::provider::AgentSession; use bugatti::report::{self, ReportInput}; use bugatti::run::{self, ArtifactDir, EffectiveConfigSummary}; @@ -72,9 +73,14 @@ fn main() { let cli = Cli::parse(); + let c = output::stdout_colors(); println!( - "\x1b[1mbugatti\x1b[0m \x1b[38;5;243mv{}\x1b[0m", - env!("CARGO_PKG_VERSION") + "{}bugatti{} {}v{}{}", + c.bold, + c.reset, + c.dim, + env!("CARGO_PKG_VERSION"), + c.reset ); println!(); @@ -384,9 +390,10 @@ fn run_test_with_artifacts( ); // Print per-test run info - let dim = "\x1b[38;5;243m"; - let light = "\x1b[38;5;250m"; - let reset = "\x1b[0m"; + let c = output::stdout_colors(); + let dim = c.dim; + let light = c.light; + let reset = c.reset; if ctx.effective.provider.agent_args.is_empty() { println!(" {dim}Provider:{reset} {}", ctx.effective.provider.name); } else { diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..c5d351a --- /dev/null +++ b/src/output.rs @@ -0,0 +1,110 @@ +use std::io::IsTerminal; +use std::sync::OnceLock; + +#[derive(Clone, Copy, Debug)] +pub enum Stream { + Stdout, + Stderr, +} + +/// Shared ANSI palette used by terminal output formatting. +#[derive(Debug)] +pub struct Colors { + pub enabled: bool, + pub bold: &'static str, + pub dim: &'static str, + pub light: &'static str, + pub tool: &'static str, + pub thinking: &'static str, + pub result: &'static str, + pub prompt: &'static str, + pub cmd: &'static str, + pub sep: &'static str, + pub reset: &'static str, +} + +static COLORS: OnceLock = OnceLock::new(); +static STDERR_COLORS: OnceLock = OnceLock::new(); + +fn detect_color_enabled(stream: Stream) -> bool { + if std::env::var_os("NO_COLOR").is_some() { + return false; + } + + match stream { + Stream::Stdout => std::io::stdout().is_terminal(), + Stream::Stderr => std::io::stderr().is_terminal(), + } +} + +fn build_colors(enabled: bool) -> Colors { + let code = |value| if enabled { value } else { "" }; + + Colors { + enabled, + bold: code("\x1b[1m"), + dim: code("\x1b[38;5;243m"), + light: code("\x1b[38;5;250m"), + tool: code("\x1b[38;5;111m"), + thinking: code("\x1b[38;5;183m"), + result: code("\x1b[38;5;151m"), + prompt: code("\x1b[38;5;223m"), + cmd: code("\x1b[38;5;152m"), + sep: code("\x1b[38;5;238m"), + reset: code("\x1b[0m"), + } +} + +/// Returns a lazily initialized singleton color palette. +pub fn colors() -> &'static Colors { + stdout_colors() +} + +/// Returns a lazily initialized stdout color palette. +pub fn stdout_colors() -> &'static Colors { + COLORS.get_or_init(|| build_colors(detect_color_enabled(Stream::Stdout))) +} + +/// Returns a lazily initialized stderr color palette. +pub fn stderr_colors() -> &'static Colors { + STDERR_COLORS.get_or_init(|| build_colors(detect_color_enabled(Stream::Stderr))) +} + +/// Returns whether ANSI color output should be enabled. +/// +/// Color is disabled when: +/// - `NO_COLOR` is set to any value +/// - stdout is not a terminal (e.g. piped/redirected) +pub fn color_enabled() -> bool { + stdout_colors().enabled +} + +/// Returns whether ANSI color output should be enabled for stderr. +pub fn color_enabled_stderr() -> bool { + stderr_colors().enabled +} + +/// Returns `code` when color is enabled, otherwise an empty string. +pub fn ansi(code: &'static str) -> &'static str { + if stdout_colors().enabled { + code + } else { + "" + } +} + +/// Returns `code` when stderr color is enabled, otherwise an empty string. +pub fn ansi_stderr(code: &'static str) -> &'static str { + if stderr_colors().enabled { + code + } else { + "" + } +} + +pub mod prelude { + pub use super::{ + ansi, ansi_stderr, color_enabled, color_enabled_stderr, colors, stderr_colors, + stdout_colors, Colors, Stream, + }; +}