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>
209 lines
7.0 KiB
Rust
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 }
|
|
}
|