use std::sync::OnceLock;
static ENABLED: OnceLock<bool> = OnceLock::new();
pub fn colour_enabled() -> bool {
*ENABLED.get_or_init(|| {
if std::env::var_os("NO_COLOR").is_some() || std::env::var_os("TERMAXA_NO_COLOR").is_some()
{
return false;
}
if std::env::var("CLICOLOR_FORCE")
.map(|v| v != "0")
.unwrap_or(false)
{
return true;
}
is_tty()
})
}
#[cfg(unix)]
fn is_tty() -> bool {
unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 }
}
#[cfg(windows)]
fn is_tty() -> bool {
type Handle = *mut core::ffi::c_void;
const STD_OUTPUT_HANDLE: u32 = -11i32 as u32;
extern "system" {
fn GetStdHandle(n_std_handle: u32) -> Handle;
fn GetConsoleMode(h_console_handle: Handle, lp_mode: *mut u32) -> i32;
}
unsafe {
let handle = GetStdHandle(STD_OUTPUT_HANDLE);
let mut mode: u32 = 0;
GetConsoleMode(handle, &mut mode) != 0
}
}
#[cfg(not(any(unix, windows)))]
fn is_tty() -> bool {
false
}
fn wrap(code: &str, s: &str) -> String {
if colour_enabled() {
format!("\x1b[{}m{}\x1b[0m", code, s)
} else {
s.to_string()
}
}
pub fn green(s: &str) -> String {
wrap("32", s)
}
pub fn amber(s: &str) -> String {
wrap("33", s)
}
pub fn red(s: &str) -> String {
wrap("31", s)
}
pub fn dim(s: &str) -> String {
wrap("2", s)
}
pub fn bold(s: &str) -> String {
wrap("1", s)
}
pub fn cyan(s: &str) -> String {
wrap("36", s)
}
pub fn decision(action: &str) -> String {
match action {
"allow" => green(action),
"ask" => amber(action),
"deny" => red(&bold(action)),
other => other.to_string(),
}
}
pub fn mark(action: &str, source: &str) -> String {
match (action, source) {
(_, "post") => green("✓"),
("allow", _) => green("✓"),
("ask", _) => amber("?"),
("deny", _) => red("✗"),
_ => dim("•"),
}
}
pub fn field(label: &str, value: &str) -> String {
format!("{}{}", dim(&format!("{:<10}", label)), value)
}
pub fn welcome(version: &str) {
println!();
println!(" {} {}", bold(&green("termaxa")), dim(version));
println!();
println!(" Predict what will happen.");
println!(" Protect what matters.");
println!(" Recover when you're wrong.");
println!();
println!(" {}", dim("Try this — no setup required:"));
println!();
println!(" {}", cyan("termaxa check \"rm -rf /\""));
println!();
println!(" {}", dim("Then, in a project you're working in:"));
println!();
println!(
" {} {}",
cyan("termaxa init --claude-code"),
dim("(or --cursor)")
);
println!(
" {} {}",
cyan("termaxa doctor"),
dim("check the wiring")
);
println!(
" {} {}",
cyan("termaxa report"),
dim("what your agent did")
);
println!();
println!(" {}", dim("termaxa --help for the full surface"));
println!();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decision_words_are_unchanged_when_colour_is_off() {
for w in ["allow", "ask", "deny"] {
assert!(decision(w).contains(w), "decision() dropped the word {w}");
}
}
#[test]
fn post_receipts_mark_as_success_not_denial() {
assert!(mark("ask", "post").contains('✓'));
assert!(mark("deny", "post").contains('✓'));
assert!(mark("deny", "hook").contains('✗'));
assert!(mark("ask", "hook").contains('?'));
assert!(mark("allow", "hook").contains('✓'));
}
#[test]
fn field_pads_the_label_before_colouring_it() {
let line = field("command", "rm -rf /");
assert!(line.contains("command"));
assert!(line.contains("rm -rf /"));
let plain: String = strip_ansi(&line);
assert!(
plain.starts_with("command "),
"label must be padded to a fixed column, got {plain:?}"
);
assert!(plain.ends_with("rm -rf /"));
assert_eq!(
strip_ansi(&field("rule", "x")).find('x'),
strip_ansi(&field("reason", "x")).find('x'),
"all labels must align the value to the same column"
);
}
#[test]
fn every_colour_helper_keeps_the_text_it_wraps() {
for (name, got) in [
("green", green("hello")),
("amber", amber("hello")),
("red", red("hello")),
("dim", dim("hello")),
("bold", bold("hello")),
("cyan", cyan("hello")),
] {
assert!(got.contains("hello"), "{name}() dropped its text: {got:?}");
}
}
#[test]
fn every_colour_helper_answers_to_the_same_gate() {
for (name, got) in [
("green", green("x")),
("amber", amber("x")),
("red", red("x")),
("dim", dim("x")),
("bold", bold("x")),
("cyan", cyan("x")),
] {
assert_eq!(
got != "x",
colour_enabled(),
"{name}() disagrees with the colour gate: {got:?}"
);
}
}
#[test]
fn an_unrecognised_decision_word_passes_through_unchanged() {
assert_eq!(decision("skipped"), "skipped");
assert!(mark("skipped", "hook").contains('•'));
}
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\x1b' {
for c in chars.by_ref() {
if c == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
}