Files
claude-statusline/src/color.rs
Taylor Eernisse e0c4a0fa9a feat: add colorgrad, transcript parser, terminal palette detection, and expanded color/input systems
Infrastructure layer for the TUI visual overhaul. Introduces foundational
modules and capabilities that the section-level features build on:

colorgrad (0.7) dependency:
  OKLab gradient interpolation for per-character color transitions in
  sparklines and context bars. Adds ~100K to binary (929K -> 1.0M).

color.rs expansion:
  - parse_hex(): #RRGGBB and #RGB -> (u8, u8, u8) conversion
  - fg_rgb()/bg_rgb(): 24-bit true-color ANSI escape generation
  - gradient_fg(): two-point interpolation via colorgrad
  - make_gradient()/sample_fg(): multi-stop gradient construction and sampling
  - resolve_color() now supports: hex (#FF6B35), bg:color, bg:#hex,
    italic, underline, strikethrough, and palette refs (p:success)
  - Named background constants (BG_RED through BG_WHITE)

transcript.rs (new module):
  Parses Claude Code transcript JSONL files to derive tool use counts,
  turn counts, and per-tool breakdowns. Claude Code doesn't include
  total_tool_uses or total_turns in its JSON — we compute them by scanning
  the transcript. Includes compact cache serialization format and
  skip_lines support for /clear offset handling.

terminal.rs (new module):
  Auto-detects the terminal's ANSI color palette for theme-aware tool
  coloring. Priority chain: WezTerm config > Kitty config > Alacritty
  config > OSC 4 escape sequence query. Parses Lua (WezTerm), key-value
  (Kitty), and TOML/YAML (Alacritty) config formats. OSC 4 queries
  use raw /dev/tty I/O with termios to avoid pipe interference. Includes
  cache serialization helpers for 1-hour TTL caching.

input.rs updates:
  - All structs now derive Serialize (for --dump-state diagnostics)
  - New fields: transcript_path, session_id, cwd, vim.mode, agent.name,
    exceeds_200k_tokens, cost.total_api_duration_ms
  - CurrentUsage: added input_tokens and output_tokens fields
  - #[serde(flatten)] extras on InputData and CostInfo for forward compat

cache.rs:
  Added flush_prefix() for /clear detection — removes all cache entries
  matching a key prefix (e.g., "trend_" to reset all sparkline history).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 23:41:50 -05:00

209 lines
7.0 KiB
Rust

use crate::config::ThemeColors;
use crate::theme::Theme;
pub const RESET: &str = "\x1b[0m";
pub const BOLD: &str = "\x1b[1m";
pub const DIM: &str = "\x1b[2m";
pub const ITALIC: &str = "\x1b[3m";
pub const UNDERLINE: &str = "\x1b[4m";
pub const STRIKETHROUGH: &str = "\x1b[9m";
pub const RED: &str = "\x1b[31m";
pub const GREEN: &str = "\x1b[32m";
pub const YELLOW: &str = "\x1b[33m";
pub const BLUE: &str = "\x1b[34m";
pub const MAGENTA: &str = "\x1b[35m";
pub const CYAN: &str = "\x1b[36m";
pub const WHITE: &str = "\x1b[37m";
// Named background colors
const BG_RED: &str = "\x1b[41m";
const BG_GREEN: &str = "\x1b[42m";
const BG_YELLOW: &str = "\x1b[43m";
const BG_BLUE: &str = "\x1b[44m";
const BG_MAGENTA: &str = "\x1b[45m";
const BG_CYAN: &str = "\x1b[46m";
const BG_WHITE: &str = "\x1b[47m";
/// Parse a hex color string (#RRGGBB or #RGB) into (R, G, B).
pub fn parse_hex(s: &str) -> Option<(u8, u8, u8)> {
let hex = s.strip_prefix('#')?;
match hex.len() {
6 => {
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some((r, g, b))
}
3 => {
let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 17;
let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 17;
let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 17;
Some((r, g, b))
}
_ => None,
}
}
/// Emit a 24-bit foreground ANSI escape for an (R, G, B) tuple.
pub fn fg_rgb(r: u8, g: u8, b: u8) -> String {
format!("\x1b[38;2;{r};{g};{b}m")
}
/// Emit a 24-bit background ANSI escape for an (R, G, B) tuple.
pub fn bg_rgb(r: u8, g: u8, b: u8) -> String {
format!("\x1b[48;2;{r};{g};{b}m")
}
/// Interpolate a gradient between two hex colors at position `t` (0.0..=1.0).
/// Returns a 24-bit foreground ANSI escape.
pub fn gradient_fg(from_hex: &str, to_hex: &str, t: f32) -> String {
use colorgrad::Gradient;
let grad = colorgrad::GradientBuilder::new()
.html_colors(&[from_hex, to_hex])
.build::<colorgrad::LinearGradient>()
.unwrap_or_else(|_| {
colorgrad::GradientBuilder::new()
.html_colors(&["#00ff00", "#ff0000"])
.build::<colorgrad::LinearGradient>()
.expect("fallback gradient must build")
});
let c = grad.at(t.clamp(0.0, 1.0));
let [r, g, b, _] = c.to_rgba8();
fg_rgb(r, g, b)
}
/// Build a multi-stop gradient from hex color strings (e.g., ["#50fa7b", "#f1fa8c", "#ff5555"]).
pub fn make_gradient(colors: &[&str]) -> colorgrad::LinearGradient {
colorgrad::GradientBuilder::new()
.html_colors(colors)
.build::<colorgrad::LinearGradient>()
.unwrap_or_else(|_| {
colorgrad::GradientBuilder::new()
.html_colors(&["#00ff00", "#ffff00", "#ff0000"])
.build::<colorgrad::LinearGradient>()
.expect("fallback gradient must build")
})
}
/// Sample a gradient at position `t` and return a 24-bit foreground ANSI escape.
pub fn sample_fg(grad: &colorgrad::LinearGradient, t: f32) -> String {
use colorgrad::Gradient;
let c = grad.at(t.clamp(0.0, 1.0));
let [r, g, b, _] = c.to_rgba8();
fg_rgb(r, g, b)
}
/// Resolve a color name to ANSI escape sequence(s).
///
/// Supported formats (space-separated, combinable):
/// - Named: red, green, yellow, blue, magenta, cyan, white
/// - Modifiers: dim, bold, italic, underline, strikethrough
/// - Hex: #FF6B35, #F00
/// - Background: bg:red, bg:#FF6B35
/// - Palette: p:success (resolved through theme palette)
pub fn resolve_color(name: &str, theme: Theme, palette: &ThemeColors) -> String {
if let Some(key) = name.strip_prefix("p:") {
let map = match theme {
Theme::Dark => &palette.dark,
Theme::Light => &palette.light,
};
if let Some(resolved) = map.get(key) {
return resolve_color(resolved, theme, palette);
}
return RESET.to_string();
}
let mut result = String::new();
for part in name.split_whitespace() {
let resolved = match part {
// Named foreground colors
"red" => RED.to_string(),
"green" => GREEN.to_string(),
"yellow" => YELLOW.to_string(),
"blue" => BLUE.to_string(),
"magenta" => MAGENTA.to_string(),
"cyan" => CYAN.to_string(),
"white" => WHITE.to_string(),
// Modifiers
"dim" => DIM.to_string(),
"bold" => BOLD.to_string(),
"italic" => ITALIC.to_string(),
"underline" => UNDERLINE.to_string(),
"strikethrough" => STRIKETHROUGH.to_string(),
// Hex foreground
s if s.starts_with('#') => {
if let Some((r, g, b)) = parse_hex(s) {
fg_rgb(r, g, b)
} else {
String::new()
}
}
// Background colors
s if s.starts_with("bg:") => {
let bg_val = &s[3..];
match bg_val {
"red" => BG_RED.to_string(),
"green" => BG_GREEN.to_string(),
"yellow" => BG_YELLOW.to_string(),
"blue" => BG_BLUE.to_string(),
"magenta" => BG_MAGENTA.to_string(),
"cyan" => BG_CYAN.to_string(),
"white" => BG_WHITE.to_string(),
hex if hex.starts_with('#') => {
if let Some((r, g, b)) = parse_hex(hex) {
bg_rgb(r, g, b)
} else {
String::new()
}
}
_ => String::new(),
}
}
_ => String::new(),
};
result.push_str(&resolved);
}
if result.is_empty() {
RESET.to_string()
} else {
result
}
}
/// Determine whether color output should be used.
/// Precedence: NO_COLOR > --color= CLI flag > CLAUDE_STATUSLINE_COLOR env > config
pub fn should_use_color(cli_color: Option<&str>, config_color: &crate::config::ColorMode) -> bool {
if std::env::var("NO_COLOR").is_ok() {
return false;
}
if let Some(flag) = cli_color {
return match flag {
"always" => true,
"never" => false,
_ => atty_stdout(),
};
}
if let Ok(env_color) = std::env::var("CLAUDE_STATUSLINE_COLOR") {
return match env_color.as_str() {
"always" => true,
"never" => false,
_ => atty_stdout(),
};
}
match config_color {
crate::config::ColorMode::Always => true,
crate::config::ColorMode::Never => false,
crate::config::ColorMode::Auto => {
atty_stdout() && std::env::var("TERM").map_or(true, |t| t != "dumb")
}
}
}
fn atty_stdout() -> bool {
unsafe { libc::isatty(libc::STDOUT_FILENO) != 0 }
}