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>
This commit is contained in:
147
src/color.rs
147
src/color.rs
@@ -4,6 +4,9 @@ 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";
|
||||
@@ -12,7 +15,92 @@ 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 {
|
||||
@@ -27,18 +115,53 @@ pub fn resolve_color(name: &str, theme: Theme, palette: &ThemeColors) -> String
|
||||
|
||||
let mut result = String::new();
|
||||
for part in name.split_whitespace() {
|
||||
result.push_str(match part {
|
||||
"red" => RED,
|
||||
"green" => GREEN,
|
||||
"yellow" => YELLOW,
|
||||
"blue" => BLUE,
|
||||
"magenta" => MAGENTA,
|
||||
"cyan" => CYAN,
|
||||
"white" => WHITE,
|
||||
"dim" => DIM,
|
||||
"bold" => BOLD,
|
||||
_ => "",
|
||||
});
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user