From 07a9d04c29ebcab389d10f3a92bca982da1f5657 Mon Sep 17 00:00:00 2001 From: thinkingjet Date: Tue, 7 Apr 2026 14:45:15 +1000 Subject: [PATCH 1/3] feat(output): respect NO_COLOR and disable ANSI when piped Implements issue #15 behavior across main and claude-code output paths. Changes: - Add src/output.rs with color_enabled() and ansi() helpers - Export output module from src/lib.rs - Gate ANSI banner and styled run labels in src/main.rs - Gate verbose ANSI formatting in src/claude_code.rs Validation run: - cargo check -q - cargo test --all-targets - cargo test --doc - cargo run --quiet -- test DOES_NOT_EXIST.test.toml 2>&1 | cat -v - script -q /dev/null cargo run --quiet -- test DOES_NOT_EXIST.test.toml | cat -v - NO_COLOR=1 script -q /dev/null cargo run --quiet -- test DOES_NOT_EXIST.test.toml | cat -v --- src/claude_code.rs | 67 ++++++++++++++++++++++++++-------------------- src/lib.rs | 1 + src/main.rs | 15 ++++++----- src/output.rs | 19 +++++++++++++ 4 files changed, 66 insertions(+), 36 deletions(-) create mode 100644 src/output.rs diff --git a/src/claude_code.rs b/src/claude_code.rs index aedd149..c7322f8 100644 --- a/src/claude_code.rs +++ b/src/claude_code.rs @@ -25,6 +25,10 @@ mod color { pub const SEP: &str = "\x1b[38;5;238m"; // Reset pub const RESET: &str = "\x1b[0m"; + + pub fn ansi(code: &'static str) -> &'static str { + crate::output::ansi(code) + } } /// Claude Code CLI provider adapter. @@ -173,19 +177,19 @@ impl ClaudeCodeAdapter { .collect(); eprintln!( "{}[verbose]{} {}launch:{} {} {}{}", - color::DIM, - color::RESET, - color::DIM, - color::RESET, - color::CMD, + color::ansi(color::DIM), + color::ansi(color::RESET), + color::ansi(color::DIM), + color::ansi(color::RESET), + color::ansi(color::CMD), args.join(" "), - color::RESET + color::ansi(color::RESET) ); eprintln!( "{} binary: {}{}", - color::DIM, + color::ansi(color::DIM), cmd.get_program().to_string_lossy(), - color::RESET + color::ansi(color::RESET) ); } @@ -228,14 +232,19 @@ impl ClaudeCodeAdapter { if self.verbose { eprintln!( "{}[verbose]{} {}prompt ({} bytes):{}", - color::DIM, - color::RESET, - color::DIM, + color::ansi(color::DIM), + color::ansi(color::RESET), + color::ansi(color::DIM), message.len(), - color::RESET + color::ansi(color::RESET) + ); + eprintln!( + "{}{}{}", + color::ansi(color::PROMPT), + message, + color::ansi(color::RESET) ); - eprintln!("{}{}{}", color::PROMPT, message, color::RESET); - eprintln!("{}───{}", color::SEP, color::RESET); + eprintln!("{}───{}", color::ansi(color::SEP), color::ansi(color::RESET)); } stdin @@ -427,7 +436,7 @@ 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:{} {}{}{} {}{}{} {}({}){}", color::ansi(color::DIM), color::ansi(color::RESET), color::ansi(color::DIM), color::ansi(color::RESET), color::ansi(color::TOOL), name, color::ansi(color::RESET), color::ansi(color::LIGHT), input_preview, color::ansi(color::RESET), color::ansi(color::DIM), id_short, color::ansi(color::RESET)); } } "thinking" => { @@ -435,16 +444,16 @@ impl<'a> Iterator for StreamTurnIterator<'a> { if let Some(thinking) = &block.thinking { eprintln!( "{}[verbose]{} {}thinking:{}", - color::DIM, - color::RESET, - color::DIM, - color::RESET + color::ansi(color::DIM), + color::ansi(color::RESET), + color::ansi(color::DIM), + color::ansi(color::RESET) ); eprintln!( "{}{}{}", - color::THINKING, + color::ansi(color::THINKING), thinking, - color::RESET + color::ansi(color::RESET) ); } } @@ -479,19 +488,19 @@ impl<'a> Iterator for StreamTurnIterator<'a> { .collect::(); eprintln!( "{}[verbose]{} {}result:{} {}({}){}", - color::DIM, - color::RESET, - color::DIM, - color::RESET, - color::DIM, + color::ansi(color::DIM), + color::ansi(color::RESET), + color::ansi(color::DIM), + color::ansi(color::RESET), + color::ansi(color::DIM), id_short, - color::RESET + color::ansi(color::RESET) ); eprintln!( "{}{}{}", - color::RESULT, + color::ansi(color::RESULT), result_text, - color::RESET + color::ansi(color::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..9070725 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,10 +73,10 @@ fn main() { let cli = Cli::parse(); - println!( - "\x1b[1mbugatti\x1b[0m \x1b[38;5;243mv{}\x1b[0m", - env!("CARGO_PKG_VERSION") - ); + let bold = output::ansi("\x1b[1m"); + let dim = output::ansi("\x1b[38;5;243m"); + let reset = output::ansi("\x1b[0m"); + println!("{bold}bugatti{reset} {dim}v{}{reset}", env!("CARGO_PKG_VERSION")); println!(); let is_update_command = matches!(&cli.command, Commands::Update { .. }); @@ -384,9 +385,9 @@ 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 dim = output::ansi("\x1b[38;5;243m"); + let light = output::ansi("\x1b[38;5;250m"); + let reset = output::ansi("\x1b[0m"); 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..ee8f572 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,19 @@ +use std::io::IsTerminal; + +/// 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 { + std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal() +} + +/// Returns `code` when color is enabled, otherwise an empty string. +pub fn ansi(code: &'static str) -> &'static str { + if color_enabled() { + code + } else { + "" + } +} From a15252e8052b529aca888a0b5eca97b6e0312df9 Mon Sep 17 00:00:00 2001 From: thinkingjet Date: Fri, 10 Apr 2026 23:49:24 +1000 Subject: [PATCH 2/3] refactor(output): centralize ANSI palette with lazy singleton Use OnceLock-backed Colors palette to avoid repeated NO_COLOR/TTY checks on every color lookup. - Add public Colors fields for shared styling constants - Provide colors() singleton accessor and output prelude exports - Switch main.rs and claude_code.rs call sites to shared palette Validation: - cargo check -q - cargo test -q --all-targets - runtime checks for piped, TTY default, and NO_COLOR=1 --- src/claude_code.rs | 113 ++++++++++++++++++--------------------------- src/main.rs | 20 +++++--- src/output.rs | 55 +++++++++++++++++++++- 3 files changed, 111 insertions(+), 77 deletions(-) diff --git a/src/claude_code.rs b/src/claude_code.rs index c7322f8..fd51168 100644 --- a/src/claude_code.rs +++ b/src/claude_code.rs @@ -1,36 +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"; - - pub fn ansi(code: &'static str) -> &'static str { - crate::output::ansi(code) - } -} - /// Claude Code CLI provider adapter. /// /// Implements the `AgentSession` trait by driving a single long-lived `claude` @@ -171,25 +146,26 @@ impl ClaudeCodeAdapter { .stderr(Stdio::piped()); if self.verbose { + let c = output::colors(); let args: Vec<_> = cmd .get_args() .map(|a| a.to_string_lossy().to_string()) .collect(); eprintln!( "{}[verbose]{} {}launch:{} {} {}{}", - color::ansi(color::DIM), - color::ansi(color::RESET), - color::ansi(color::DIM), - color::ansi(color::RESET), - color::ansi(color::CMD), + c.dim, + c.reset, + c.dim, + c.reset, + c.cmd, args.join(" "), - color::ansi(color::RESET) + c.reset ); eprintln!( "{} binary: {}{}", - color::ansi(color::DIM), + c.dim, cmd.get_program().to_string_lossy(), - color::ansi(color::RESET) + c.reset ); } @@ -230,21 +206,17 @@ impl ClaudeCodeAdapter { let input_line = format_stream_input(message); if self.verbose { + let c = output::colors(); eprintln!( "{}[verbose]{} {}prompt ({} bytes):{}", - color::ansi(color::DIM), - color::ansi(color::RESET), - color::ansi(color::DIM), + c.dim, + c.reset, + c.dim, message.len(), - color::ansi(color::RESET) - ); - eprintln!( - "{}{}{}", - color::ansi(color::PROMPT), - message, - color::ansi(color::RESET) + c.reset ); - eprintln!("{}───{}", color::ansi(color::SEP), color::ansi(color::RESET)); + eprintln!("{}{}{}", c.prompt, message, c.reset); + eprintln!("{}───{}", c.sep, c.reset); } stdin @@ -402,6 +374,7 @@ impl<'a> Iterator for StreamTurnIterator<'a> { } "tool_use" => { if self.verbose { + let c = output::colors(); let name = block.name.as_deref().unwrap_or("unknown"); let input_preview = block @@ -436,25 +409,33 @@ impl<'a> Iterator for StreamTurnIterator<'a> { .chars() .take(12) .collect::(); - eprintln!("{}[verbose]{} {}tool:{} {}{}{} {}{}{} {}({}){}", color::ansi(color::DIM), color::ansi(color::RESET), color::ansi(color::DIM), color::ansi(color::RESET), color::ansi(color::TOOL), name, color::ansi(color::RESET), color::ansi(color::LIGHT), input_preview, color::ansi(color::RESET), color::ansi(color::DIM), id_short, color::ansi(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" => { if self.verbose { if let Some(thinking) = &block.thinking { + let c = output::colors(); eprintln!( "{}[verbose]{} {}thinking:{}", - color::ansi(color::DIM), - color::ansi(color::RESET), - color::ansi(color::DIM), - color::ansi(color::RESET) - ); - eprintln!( - "{}{}{}", - color::ansi(color::THINKING), - thinking, - color::ansi(color::RESET) + c.dim, c.reset, c.dim, c.reset ); + eprintln!("{}{}{}", c.thinking, thinking, c.reset); } } } @@ -470,6 +451,7 @@ impl<'a> Iterator for StreamTurnIterator<'a> { if let Some(msg) = &event.message { for block in &msg.content { if block.block_type == "tool_result" { + let c = output::colors(); let result_text = block .content .as_ref() @@ -488,20 +470,15 @@ impl<'a> Iterator for StreamTurnIterator<'a> { .collect::(); eprintln!( "{}[verbose]{} {}result:{} {}({}){}", - color::ansi(color::DIM), - color::ansi(color::RESET), - color::ansi(color::DIM), - color::ansi(color::RESET), - color::ansi(color::DIM), + c.dim, + c.reset, + c.dim, + c.reset, + c.dim, id_short, - color::ansi(color::RESET) - ); - eprintln!( - "{}{}{}", - color::ansi(color::RESULT), - result_text, - color::ansi(color::RESET) + c.reset ); + eprintln!("{}{}{}", c.result, result_text, c.reset); } } } diff --git a/src/main.rs b/src/main.rs index 9070725..222ca5a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,10 +73,15 @@ fn main() { let cli = Cli::parse(); - let bold = output::ansi("\x1b[1m"); - let dim = output::ansi("\x1b[38;5;243m"); - let reset = output::ansi("\x1b[0m"); - println!("{bold}bugatti{reset} {dim}v{}{reset}", env!("CARGO_PKG_VERSION")); + let c = output::colors(); + println!( + "{}bugatti{} {}v{}{}", + c.bold, + c.reset, + c.dim, + env!("CARGO_PKG_VERSION"), + c.reset + ); println!(); let is_update_command = matches!(&cli.command, Commands::Update { .. }); @@ -385,9 +390,10 @@ fn run_test_with_artifacts( ); // Print per-test run info - let dim = output::ansi("\x1b[38;5;243m"); - let light = output::ansi("\x1b[38;5;250m"); - let reset = output::ansi("\x1b[0m"); + let c = output::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 index ee8f572..07b9689 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,4 +1,51 @@ use std::io::IsTerminal; +use std::sync::OnceLock; + +/// 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(); + +fn detect_color_enabled() -> bool { + std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal() +} + +fn build_colors() -> Colors { + let enabled = detect_color_enabled(); + 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 { + COLORS.get_or_init(build_colors) +} /// Returns whether ANSI color output should be enabled. /// @@ -6,14 +53,18 @@ use std::io::IsTerminal; /// - `NO_COLOR` is set to any value /// - stdout is not a terminal (e.g. piped/redirected) pub fn color_enabled() -> bool { - std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal() + colors().enabled } /// Returns `code` when color is enabled, otherwise an empty string. pub fn ansi(code: &'static str) -> &'static str { - if color_enabled() { + if colors().enabled { code } else { "" } } + +pub mod prelude { + pub use super::{ansi, color_enabled, colors, Colors}; +} From 492e0e5f49f287c6aa12c14d8f583d41a3fc6980 Mon Sep 17 00:00:00 2001 From: thinkingjet Date: Sat, 11 Apr 2026 00:15:43 +1000 Subject: [PATCH 3/3] fix(output): make color decisions stream-aware Use separate lazy palettes for stdout and stderr to avoid ANSI leaks when only one stream is redirected. - Add Stream enum and per-stream OnceLock palettes - Add stdout_colors()/stderr_colors() accessors - Keep existing helpers for compatibility - Route claude verbose eprintln! paths to stderr palette Validation: - cargo check -q - cargo test -q --all-targets - smoke checks for piped, TTY, NO_COLOR, and redirected stderr --- src/claude_code.rs | 9 ++++---- src/main.rs | 4 ++-- src/output.rs | 56 +++++++++++++++++++++++++++++++++++++++------- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/src/claude_code.rs b/src/claude_code.rs index 3a3410b..b82a5aa 100644 --- a/src/claude_code.rs +++ b/src/claude_code.rs @@ -146,7 +146,7 @@ impl ClaudeCodeAdapter { .stderr(Stdio::piped()); if self.verbose { - let c = output::colors(); + let c = output::stderr_colors(); let args: Vec<_> = cmd .get_args() .map(|a| a.to_string_lossy().to_string()) @@ -206,7 +206,7 @@ impl ClaudeCodeAdapter { let input_line = format_stream_input(message); if self.verbose { - let c = output::colors(); + let c = output::stderr_colors(); eprintln!( "{}[verbose]{} {}prompt ({} bytes):{}", c.dim, @@ -341,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) { @@ -374,7 +376,6 @@ impl<'a> Iterator for StreamTurnIterator<'a> { } "tool_use" => { if self.verbose { - let c = output::colors(); let name = block.name.as_deref().unwrap_or("unknown"); let input_preview = block @@ -430,7 +431,6 @@ impl<'a> Iterator for StreamTurnIterator<'a> { "thinking" => { if self.verbose { if let Some(thinking) = &block.thinking { - let c = output::colors(); eprintln!( "{}[verbose]{} {}thinking:{}", c.dim, c.reset, c.dim, c.reset @@ -451,7 +451,6 @@ impl<'a> Iterator for StreamTurnIterator<'a> { if let Some(msg) = &event.message { for block in &msg.content { if block.block_type == "tool_result" { - let c = output::colors(); let result_text = block .content .as_ref() diff --git a/src/main.rs b/src/main.rs index 222ca5a..4a0e460 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,7 +73,7 @@ fn main() { let cli = Cli::parse(); - let c = output::colors(); + let c = output::stdout_colors(); println!( "{}bugatti{} {}v{}{}", c.bold, @@ -390,7 +390,7 @@ fn run_test_with_artifacts( ); // Print per-test run info - let c = output::colors(); + let c = output::stdout_colors(); let dim = c.dim; let light = c.light; let reset = c.reset; diff --git a/src/output.rs b/src/output.rs index 07b9689..c5d351a 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,6 +1,12 @@ 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 { @@ -18,13 +24,20 @@ pub struct Colors { } static COLORS: OnceLock = OnceLock::new(); +static STDERR_COLORS: OnceLock = OnceLock::new(); -fn detect_color_enabled() -> bool { - std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal() +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() -> Colors { - let enabled = detect_color_enabled(); +fn build_colors(enabled: bool) -> Colors { let code = |value| if enabled { value } else { "" }; Colors { @@ -44,7 +57,17 @@ fn build_colors() -> Colors { /// Returns a lazily initialized singleton color palette. pub fn colors() -> &'static Colors { - COLORS.get_or_init(build_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. @@ -53,12 +76,26 @@ pub fn colors() -> &'static Colors { /// - `NO_COLOR` is set to any value /// - stdout is not a terminal (e.g. piped/redirected) pub fn color_enabled() -> bool { - colors().enabled + 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 colors().enabled { + 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 { "" @@ -66,5 +103,8 @@ pub fn ansi(code: &'static str) -> &'static str { } pub mod prelude { - pub use super::{ansi, color_enabled, colors, Colors}; + pub use super::{ + ansi, ansi_stderr, color_enabled, color_enabled_stderr, colors, stderr_colors, + stdout_colors, Colors, Stream, + }; }