Extract terminal crate and prettify attractor CLI output

Move ANSI Styles struct from agent/cli.rs into a shared terminal crate
so both binaries can use it. Add green and yellow color codes. Prettify
all attractor CLI output: bold headers, colored diagnostics by severity,
green/red status, yellow warnings, dimmed event details, and styled
interviewer prompts. Move pipeline status output from stdout to stderr.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-02-23 12:06:24 -05:00
parent b4397564ec
commit dddc6df8c8
12 changed files with 251 additions and 103 deletions

6
Cargo.lock generated
View file

@ -18,6 +18,7 @@ dependencies = [
"llm",
"serde",
"serde_json",
"terminal",
"thiserror 2.0.18",
"tokio",
"uuid",
@ -170,6 +171,7 @@ dependencies = [
"serde",
"serde_json",
"tempfile",
"terminal",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
@ -2109,6 +2111,10 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "terminal"
version = "0.1.0"
[[package]]
name = "termtree"
version = "0.5.1"

View file

@ -21,6 +21,7 @@ clap.workspace = true
anyhow.workspace = true
dotenvy.workspace = true
llm = { path = "../llm" }
terminal = { path = "../terminal" }
thiserror.workspace = true
serde.workspace = true
serde_json.workspace = true

View file

@ -8,38 +8,7 @@ use std::io::{IsTerminal, Write};
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
/// Pre-resolved ANSI escape codes for styled terminal output.
/// All fields are empty strings when color is disabled (non-TTY stderr).
struct Styles {
bold: &'static str,
dim: &'static str,
cyan: &'static str,
red: &'static str,
reset: &'static str,
}
impl Styles {
fn new(use_color: bool) -> Self {
if use_color {
Self {
bold: "\x1b[1m",
dim: "\x1b[2m",
cyan: "\x1b[36m",
red: "\x1b[31m",
reset: "\x1b[0m",
}
} else {
Self {
bold: "",
dim: "",
cyan: "",
red: "",
reset: "",
}
}
}
}
use terminal::Styles;
/// Minimal CLI for the agent agentic loop.
#[derive(Parser)]
@ -316,8 +285,7 @@ pub async fn run() -> anyhow::Result<()> {
let cli = Cli::parse();
// Resolve color support once, leak to get 'static lifetime for use across threads
let styles: &'static Styles =
Box::leak(Box::new(Styles::new(std::io::stderr().is_terminal())));
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
// Validate provider API key
if !validate_api_key(&cli.provider) {
@ -428,13 +396,7 @@ mod tests {
use super::*;
use serde_json::json;
static NO_COLOR: Styles = Styles {
bold: "",
dim: "",
cyan: "",
red: "",
reset: "",
};
static NO_COLOR: Styles = Styles::new(false);
// tool_category tests

View file

@ -25,6 +25,7 @@ clap.workspace = true
anyhow.workspace = true
dotenvy.workspace = true
agent = { path = "../agent" }
terminal = { path = "../terminal" }
llm = { path = "../llm" }
thiserror.workspace = true
serde.workspace = true

View file

@ -6,6 +6,7 @@ use std::path::Path;
use clap::{Args, Parser, Subcommand};
use std::path::PathBuf;
use terminal::Styles;
use crate::event::PipelineEvent;
use crate::validation::{Diagnostic, Severity};
@ -75,27 +76,38 @@ pub fn read_dot_file(path: &Path) -> anyhow::Result<String> {
.map_err(|e| anyhow::anyhow!("Failed to read {}: {e}", path.display()))
}
/// Print diagnostics to stderr, grouped by severity.
pub fn print_diagnostics(diagnostics: &[Diagnostic]) {
/// Print diagnostics to stderr, colored by severity.
pub fn print_diagnostics(diagnostics: &[Diagnostic], styles: &Styles) {
for d in diagnostics {
let prefix = match d.severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "info",
};
let location = match (&d.node_id, &d.edge) {
(Some(node), _) => format!(" [node: {node}]"),
(_, Some((from, to))) => format!(" [edge: {from} -> {to}]"),
_ => String::new(),
};
eprintln!("{prefix}{location}: {} ({})", d.message, d.rule);
match d.severity {
Severity::Error => eprintln!(
"{red}error{reset}{location}: {} ({dim}{}{reset})",
d.message, d.rule,
red = styles.red, dim = styles.dim, reset = styles.reset,
),
Severity::Warning => eprintln!(
"{yellow}warning{reset}{location}: {} ({dim}{}{reset})",
d.message, d.rule,
yellow = styles.yellow, dim = styles.dim, reset = styles.reset,
),
Severity::Info => eprintln!(
"{dim}info{location}: {} ({}){reset}",
d.message, d.rule,
dim = styles.dim, reset = styles.reset,
),
}
}
}
/// One-line summary of a pipeline event for `-v` output.
/// One-line summary of a pipeline event for `-v` output (dimmed).
#[must_use]
pub fn format_event_summary(event: &PipelineEvent) -> String {
match event {
pub fn format_event_summary(event: &PipelineEvent, styles: &Styles) -> String {
let body = match event {
PipelineEvent::PipelineStarted { name, id } => {
format!("[PIPELINE_STARTED] name={name} id={id}")
}
@ -179,30 +191,35 @@ pub fn format_event_summary(event: &PipelineEvent) -> String {
PipelineEvent::CheckpointSaved { node_id } => {
format!("[CHECKPOINT_SAVED] node={node_id}")
}
}
};
format!("{dim}{body}{reset}", dim = styles.dim, reset = styles.reset)
}
/// Multi-line detail view of a pipeline event for `-vv` output.
/// Box-drawing is dimmed; values are normal.
#[must_use]
pub fn format_event_detail(event: &PipelineEvent) -> String {
pub fn format_event_detail(event: &PipelineEvent, styles: &Styles) -> String {
let d = styles.dim;
let r = styles.reset;
match event {
PipelineEvent::PipelineStarted { name, id } => {
format!(
"── PIPELINE_STARTED ─────────────────────────\n name: {name}\n id: {id}\n"
"{d}── PIPELINE_STARTED ─────────────────────────{r}\n {d}name:{r} {name}\n {d}id:{r} {id}\n"
)
}
PipelineEvent::PipelineCompleted {
duration_ms,
artifact_count,
} => {
format!("── PIPELINE_COMPLETED ───────────────────────\n duration_ms: {duration_ms}\n artifact_count: {artifact_count}\n")
format!("{d}── PIPELINE_COMPLETED ───────────────────────{r}\n {d}duration_ms:{r} {duration_ms}\n {d}artifact_count:{r} {artifact_count}\n")
}
PipelineEvent::PipelineFailed { error, duration_ms } => {
format!("── PIPELINE_FAILED ──────────────────────────\n error: {error}\n duration_ms: {duration_ms}\n")
format!("{d}── PIPELINE_FAILED ──────────────────────────{r}\n {d}error:{r} {error}\n {d}duration_ms:{r} {duration_ms}\n")
}
PipelineEvent::StageStarted { name, index } => {
format!(
"── STAGE_STARTED ────────────────────────────\n name: {name}\n index: {index}\n"
"{d}── STAGE_STARTED ────────────────────────────{r}\n {d}name:{r} {name}\n {d}index:{r} {index}\n"
)
}
PipelineEvent::StageCompleted {
@ -210,7 +227,7 @@ pub fn format_event_detail(event: &PipelineEvent) -> String {
index,
duration_ms,
} => {
format!("── STAGE_COMPLETED ──────────────────────────\n name: {name}\n index: {index}\n duration_ms: {duration_ms}\n")
format!("{d}── STAGE_COMPLETED ──────────────────────────{r}\n {d}name:{r} {name}\n {d}index:{r} {index}\n {d}duration_ms:{r} {duration_ms}\n")
}
PipelineEvent::StageFailed {
name,
@ -218,7 +235,7 @@ pub fn format_event_detail(event: &PipelineEvent) -> String {
error,
will_retry,
} => {
format!("── STAGE_FAILED ─────────────────────────────\n name: {name}\n index: {index}\n error: {error}\n will_retry: {will_retry}\n")
format!("{d}── STAGE_FAILED ─────────────────────────────{r}\n {d}name:{r} {name}\n {d}index:{r} {index}\n {d}error:{r} {error}\n {d}will_retry:{r} {will_retry}\n")
}
PipelineEvent::StageRetrying {
name,
@ -226,13 +243,13 @@ pub fn format_event_detail(event: &PipelineEvent) -> String {
attempt,
delay_ms,
} => {
format!("── STAGE_RETRYING ───────────────────────────\n name: {name}\n index: {index}\n attempt: {attempt}\n delay_ms: {delay_ms}\n")
format!("{d}── STAGE_RETRYING ───────────────────────────{r}\n {d}name:{r} {name}\n {d}index:{r} {index}\n {d}attempt:{r} {attempt}\n {d}delay_ms:{r} {delay_ms}\n")
}
PipelineEvent::ParallelStarted { branch_count } => {
format!("── PARALLEL_STARTED ─────────────────────────\n branch_count: {branch_count}\n")
format!("{d}── PARALLEL_STARTED ─────────────────────────{r}\n {d}branch_count:{r} {branch_count}\n")
}
PipelineEvent::ParallelBranchStarted { branch, index } => {
format!("── PARALLEL_BRANCH_STARTED ──────────────────\n branch: {branch}\n index: {index}\n")
format!("{d}── PARALLEL_BRANCH_STARTED ──────────────────{r}\n {d}branch:{r} {branch}\n {d}index:{r} {index}\n")
}
PipelineEvent::ParallelBranchCompleted {
branch,
@ -240,35 +257,35 @@ pub fn format_event_detail(event: &PipelineEvent) -> String {
duration_ms,
success,
} => {
format!("── PARALLEL_BRANCH_COMPLETED ────────────────\n branch: {branch}\n index: {index}\n duration_ms: {duration_ms}\n success: {success}\n")
format!("{d}── PARALLEL_BRANCH_COMPLETED ────────────────{r}\n {d}branch:{r} {branch}\n {d}index:{r} {index}\n {d}duration_ms:{r} {duration_ms}\n {d}success:{r} {success}\n")
}
PipelineEvent::ParallelCompleted {
duration_ms,
success_count,
failure_count,
} => {
format!("── PARALLEL_COMPLETED ───────────────────────\n duration_ms: {duration_ms}\n success_count: {success_count}\n failure_count: {failure_count}\n")
format!("{d}── PARALLEL_COMPLETED ───────────────────────{r}\n {d}duration_ms:{r} {duration_ms}\n {d}success_count:{r} {success_count}\n {d}failure_count:{r} {failure_count}\n")
}
PipelineEvent::InterviewStarted { question, stage } => {
format!("── INTERVIEW_STARTED ────────────────────────\n stage: {stage}\n question: {question}\n")
format!("{d}── INTERVIEW_STARTED ────────────────────────{r}\n {d}stage:{r} {stage}\n {d}question:{r} {question}\n")
}
PipelineEvent::InterviewCompleted {
question,
answer,
duration_ms,
} => {
format!("── INTERVIEW_COMPLETED ──────────────────────\n question: {question}\n answer: {answer}\n duration_ms: {duration_ms}\n")
format!("{d}── INTERVIEW_COMPLETED ──────────────────────{r}\n {d}question:{r} {question}\n {d}answer:{r} {answer}\n {d}duration_ms:{r} {duration_ms}\n")
}
PipelineEvent::InterviewTimeout {
question,
stage,
duration_ms,
} => {
format!("── INTERVIEW_TIMEOUT ────────────────────────\n question: {question}\n stage: {stage}\n duration_ms: {duration_ms}\n")
format!("{d}── INTERVIEW_TIMEOUT ────────────────────────{r}\n {d}question:{r} {question}\n {d}stage:{r} {stage}\n {d}duration_ms:{r} {duration_ms}\n")
}
PipelineEvent::CheckpointSaved { node_id } => {
format!(
"── CHECKPOINT_SAVED ─────────────────────────\n node_id: {node_id}\n"
"{d}── CHECKPOINT_SAVED ─────────────────────────{r}\n {d}node_id:{r} {node_id}\n"
)
}
}

View file

@ -3,6 +3,7 @@ use std::sync::Arc;
use anyhow::bail;
use chrono::Local;
use terminal::Styles;
use crate::checkpoint::Checkpoint;
use crate::engine::{PipelineEngine, RunConfig};
@ -24,24 +25,25 @@ use super::{format_event_detail, format_event_summary, print_diagnostics, read_d
///
/// Returns an error if the pipeline cannot be read, parsed, validated, or executed.
#[allow(clippy::too_many_lines)]
pub async fn run_command(args: RunArgs) -> anyhow::Result<()> {
pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Result<()> {
// 1. Parse and validate pipeline
let source = read_dot_file(&args.pipeline)?;
let (graph, diagnostics) = PipelineBuilder::new().prepare(&source)?;
println!(
"Parsed pipeline: {} ({} nodes, {} edges)",
eprintln!(
"{bold}Parsed pipeline:{reset} {} ({dim}{} nodes, {} edges{reset})",
graph.name,
graph.nodes.len(),
graph.edges.len(),
bold = styles.bold, dim = styles.dim, reset = styles.reset,
);
let goal = graph.goal();
if !goal.is_empty() {
println!("Goal: {goal}");
eprintln!("{bold}Goal:{reset} {goal}", bold = styles.bold, reset = styles.reset);
}
print_diagnostics(&diagnostics);
print_diagnostics(&diagnostics, styles);
if diagnostics.iter().any(|d| d.severity == Severity::Error) {
bail!("Validation failed");
@ -59,12 +61,12 @@ pub async fn run_command(args: RunArgs) -> anyhow::Result<()> {
// 3. Build event emitter
let mut emitter = EventEmitter::new();
if args.verbose >= 2 {
emitter.on_event(|event| {
eprint!("{}", format_event_detail(event));
emitter.on_event(move |event| {
eprint!("{}", format_event_detail(event, styles));
});
} else if args.verbose >= 1 {
emitter.on_event(|event| {
eprintln!("{}", format_event_summary(event));
emitter.on_event(move |event| {
eprintln!("{}", format_event_summary(event, styles));
});
}
@ -72,7 +74,7 @@ pub async fn run_command(args: RunArgs) -> anyhow::Result<()> {
let interviewer: Arc<dyn Interviewer> = if args.auto_approve {
Arc::new(AutoApproveInterviewer)
} else {
Arc::new(ConsoleInterviewer)
Arc::new(ConsoleInterviewer::new(styles))
};
// 5. Resolve backend, model, and provider
@ -81,12 +83,18 @@ pub async fn run_command(args: RunArgs) -> anyhow::Result<()> {
} else {
match llm::client::Client::from_env().await {
Ok(c) if c.provider_names().is_empty() => {
eprintln!("Warning: No LLM providers configured. Running in dry-run mode.");
eprintln!(
"{yellow}Warning:{reset} No LLM providers configured. Running in dry-run mode.",
yellow = styles.yellow, reset = styles.reset,
);
true
}
Ok(_) => false,
Err(e) => {
eprintln!("Warning: Failed to initialize LLM client: {e}. Running in dry-run mode.");
eprintln!(
"{yellow}Warning:{reset} Failed to initialize LLM client: {e}. Running in dry-run mode.",
yellow = styles.yellow, reset = styles.reset,
);
true
}
}
@ -144,15 +152,32 @@ pub async fn run_command(args: RunArgs) -> anyhow::Result<()> {
};
// 8. Print result
println!("\n=== Pipeline Result ===");
println!("Status: {}", outcome.status.to_string().to_uppercase());
eprintln!(
"\n{bold}=== Pipeline Result ==={reset}",
bold = styles.bold, reset = styles.reset,
);
let status_str = outcome.status.to_string().to_uppercase();
let status_color = match outcome.status {
StageStatus::Success | StageStatus::PartialSuccess => styles.green,
_ => styles.red,
};
eprintln!("Status: {status_color}{status_str}{reset}", reset = styles.reset);
if let Some(notes) = &outcome.notes {
println!("Notes: {notes}");
eprintln!("Notes: {notes}");
}
if let Some(failure) = &outcome.failure_reason {
println!("Failure: {failure}");
eprintln!(
"{red}Failure: {failure}{reset}",
red = styles.red, reset = styles.reset,
);
}
println!("Logs: {}", logs_dir.display());
eprintln!(
"{dim}Logs: {}{reset}",
logs_dir.display(),
dim = styles.dim, reset = styles.reset,
);
// 9. Exit code
match outcome.status {

View file

@ -1,4 +1,5 @@
use anyhow::bail;
use terminal::Styles;
use crate::pipeline::PipelineBuilder;
use crate::validation::Severity;
@ -10,23 +11,27 @@ use super::{print_diagnostics, read_dot_file, ValidateArgs};
/// # Errors
///
/// Returns an error if the file cannot be read, parsed, or has validation errors.
pub fn validate_command(args: &ValidateArgs) -> anyhow::Result<()> {
pub fn validate_command(args: &ValidateArgs, styles: &Styles) -> anyhow::Result<()> {
let source = read_dot_file(&args.pipeline)?;
let (graph, diagnostics) = PipelineBuilder::new().prepare(&source)?;
println!(
"Parsed pipeline: {} ({} nodes, {} edges)",
eprintln!(
"{bold}Parsed pipeline:{reset} {} ({dim}{} nodes, {} edges{reset})",
graph.name,
graph.nodes.len(),
graph.edges.len(),
bold = styles.bold, dim = styles.dim, reset = styles.reset,
);
print_diagnostics(&diagnostics);
print_diagnostics(&diagnostics, styles);
if diagnostics.iter().any(|d| d.severity == Severity::Error) {
bail!("Validation failed");
}
println!("Validation: OK");
eprintln!(
"Validation: {green}OK{reset}",
green = styles.green, reset = styles.reset,
);
Ok(())
}

View file

@ -1,10 +1,20 @@
use async_trait::async_trait;
use terminal::Styles;
use tokio::io::{AsyncBufReadExt, BufReader};
use super::{Answer, AnswerValue, Interviewer, Question, QuestionType};
/// Reads from stdin to collect answers. Displays formatted prompts per spec 6.4.
pub struct ConsoleInterviewer;
pub struct ConsoleInterviewer {
styles: &'static Styles,
}
impl ConsoleInterviewer {
#[must_use]
pub const fn new(styles: &'static Styles) -> Self {
Self { styles }
}
}
fn find_matching_option(
response: &str,
@ -48,12 +58,21 @@ async fn read_line(prompt: &str) -> std::io::Result<String> {
#[async_trait]
impl Interviewer for ConsoleInterviewer {
async fn ask(&self, question: Question) -> Answer {
eprintln!("[?] {}", question.text);
let s = self.styles;
eprintln!(
"{bold}{cyan}?{reset} {}",
question.text,
bold = s.bold, cyan = s.cyan, reset = s.reset,
);
match question.question_type {
QuestionType::MultipleChoice => {
for (i, opt) in question.options.iter().enumerate() {
eprintln!(" [{}] {} - {}", i + 1, opt.key, opt.label);
eprintln!(
" {dim}[{reset}{bold}{}{reset}{dim}]{reset} {} - {}",
i + 1, opt.key, opt.label,
dim = s.dim, bold = s.bold, reset = s.reset,
);
}
if question.allow_freeform {
eprintln!(" Or type a free-text response");
@ -86,7 +105,11 @@ impl Interviewer for ConsoleInterviewer {
}
async fn inform(&self, message: &str, stage: &str) {
eprintln!("[{stage}] {message}");
let s = self.styles;
eprintln!(
"{dim}[{stage}]{reset} {message}",
dim = s.dim, reset = s.reset,
);
}
}

View file

@ -1,18 +1,25 @@
use clap::Parser;
use terminal::Styles;
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
let cli = attractor::cli::Cli::parse();
let result = match cli.command {
attractor::cli::Command::Run(args) => attractor::cli::run::run_command(args).await,
attractor::cli::Command::Validate(args) => attractor::cli::validate::validate_command(&args),
attractor::cli::Command::Run(args) => attractor::cli::run::run_command(args, styles).await,
attractor::cli::Command::Validate(args) => {
attractor::cli::validate::validate_command(&args, styles)
}
};
if let Err(e) = result {
eprintln!("Error: {e:#}");
eprintln!(
"{red}Error:{reset} {e:#}",
red = styles.red, reset = styles.reset,
);
std::process::exit(1);
}
}

View file

@ -14,7 +14,7 @@ fn validate_simple() {
.args(["validate", "../../test/simple.dot"])
.assert()
.success()
.stdout(predicate::str::contains("Validation: OK"));
.stderr(predicate::str::contains("Validation: OK"));
}
#[test]
@ -23,7 +23,7 @@ fn validate_branching() {
.args(["validate", "../../test/branching.dot"])
.assert()
.success()
.stdout(predicate::str::contains("Validation: OK"));
.stderr(predicate::str::contains("Validation: OK"));
}
#[test]
@ -32,7 +32,7 @@ fn validate_conditions() {
.args(["validate", "../../test/conditions.dot"])
.assert()
.success()
.stdout(predicate::str::contains("Validation: OK"));
.stderr(predicate::str::contains("Validation: OK"));
}
#[test]
@ -41,7 +41,7 @@ fn validate_parallel() {
.args(["validate", "../../test/parallel.dot"])
.assert()
.success()
.stdout(predicate::str::contains("Validation: OK"));
.stderr(predicate::str::contains("Validation: OK"));
}
#[test]
@ -50,7 +50,7 @@ fn validate_styled() {
.args(["validate", "../../test/styled.dot"])
.assert()
.success()
.stdout(predicate::str::contains("Validation: OK"));
.stderr(predicate::str::contains("Validation: OK"));
}
#[test]

View file

@ -0,0 +1,12 @@
[package]
name = "terminal"
edition.workspace = true
version.workspace = true
license.workspace = true
description = "Shared ANSI terminal styling for CLI binaries"
[lib]
doctest = false
[lints]
workspace = true

View file

@ -0,0 +1,89 @@
use std::io::IsTerminal;
/// Pre-resolved ANSI escape codes for styled terminal output.
/// All fields are empty strings when color is disabled (non-TTY or `NO_COLOR`).
pub struct Styles {
pub bold: &'static str,
pub dim: &'static str,
pub cyan: &'static str,
pub green: &'static str,
pub yellow: &'static str,
pub red: &'static str,
pub reset: &'static str,
}
// SAFETY: Styles contains only `&'static str` fields, which are inherently Send + Sync.
unsafe impl Send for Styles {}
unsafe impl Sync for Styles {}
impl Styles {
#[must_use]
pub const fn new(use_color: bool) -> Self {
if use_color {
Self {
bold: "\x1b[1m",
dim: "\x1b[2m",
cyan: "\x1b[36m",
green: "\x1b[32m",
yellow: "\x1b[33m",
red: "\x1b[31m",
reset: "\x1b[0m",
}
} else {
Self {
bold: "",
dim: "",
cyan: "",
green: "",
yellow: "",
red: "",
reset: "",
}
}
}
/// Create styles based on whether stderr is a TTY.
/// Respects `NO_COLOR` environment variable.
#[must_use]
pub fn detect_stderr() -> Self {
let use_color = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none();
Self::new(use_color)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn styles_with_color() {
let s = Styles::new(true);
assert_eq!(s.bold, "\x1b[1m");
assert_eq!(s.dim, "\x1b[2m");
assert_eq!(s.cyan, "\x1b[36m");
assert_eq!(s.green, "\x1b[32m");
assert_eq!(s.yellow, "\x1b[33m");
assert_eq!(s.red, "\x1b[31m");
assert_eq!(s.reset, "\x1b[0m");
}
#[test]
fn styles_without_color() {
let s = Styles::new(false);
assert!(s.bold.is_empty());
assert!(s.dim.is_empty());
assert!(s.cyan.is_empty());
assert!(s.green.is_empty());
assert!(s.yellow.is_empty());
assert!(s.red.is_empty());
assert!(s.reset.is_empty());
}
static NO_COLOR: Styles = Styles::new(false);
#[test]
fn no_color_static_is_empty() {
assert!(NO_COLOR.bold.is_empty());
assert!(NO_COLOR.reset.is_empty());
}
}