diff --git a/Cargo.lock b/Cargo.lock index fcf8ac2..534bd47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,6 +112,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" name = "claude-statusline" version = "0.1.0" dependencies = [ + "colorgrad", "criterion", "libc", "md-5", @@ -123,6 +124,15 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "colorgrad" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faedab4fd8670120c2be7f49225fbdb8b6db6d46f04ce4f864b1f1cdd55e6400" +dependencies = [ + "csscolorparser", +] + [[package]] name = "criterion" version = "0.5.1" @@ -200,6 +210,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "csscolorparser" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda6aace1fbef3aa217b27f4c8d7d071ef2a70a5ca51050b1f17d40299d3f16" +dependencies = [ + "phf", +] + [[package]] name = "digest" version = "0.10.7" @@ -322,6 +341,48 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "plotters" version = "0.3.7" @@ -368,6 +429,21 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rayon" version = "1.11.0" @@ -496,6 +572,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "syn" version = "2.0.114" diff --git a/Cargo.toml b/Cargo.toml index 2372d1c..a2036d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ unicode-segmentation = "1" libc = "0.2" serde_path_to_error = "0.1" serde_ignored = "0.1" +colorgrad = "0.7" [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } diff --git a/src/cache.rs b/src/cache.rs index 0b08413..a4a894e 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -187,6 +187,23 @@ impl Cache { Some(()) } + /// Remove cache entries matching a prefix (e.g., "trend_" to flush all trend data). + pub fn flush_prefix(&self, prefix: &str) { + let dir = match &self.dir { + Some(d) => d, + None => return, + }; + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.starts_with(prefix) { + let _ = fs::remove_file(entry.path()); + } + } + } + } + fn key_path(&self, key: &str) -> Option { let dir = self.dir.as_ref()?; let safe_key: String = key diff --git a/src/color.rs b/src/color.rs index 1693186..4941821 100644 --- a/src/color.rs +++ b/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::() + .unwrap_or_else(|_| { + colorgrad::GradientBuilder::new() + .html_colors(&["#00ff00", "#ff0000"]) + .build::() + .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::() + .unwrap_or_else(|_| { + colorgrad::GradientBuilder::new() + .html_colors(&["#00ff00", "#ffff00", "#ff0000"]) + .build::() + .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() { diff --git a/src/input.rs b/src/input.rs index 4a0cc1c..f0e5fae 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,6 +1,6 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, Serialize)] #[serde(default)] pub struct InputData { pub model: Option, @@ -9,28 +9,52 @@ pub struct InputData { pub workspace: Option, pub version: Option, pub output_style: Option, + pub transcript_path: Option, + pub session_id: Option, + pub cwd: Option, + pub vim: Option, + pub agent: Option, + pub exceeds_200k_tokens: Option, + #[serde(flatten)] + pub extra: std::collections::HashMap, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, Serialize)] +#[serde(default)] +pub struct VimInfo { + pub mode: Option, +} + +#[derive(Debug, Default, Deserialize, Serialize)] +#[serde(default)] +pub struct AgentInfo { + pub name: Option, +} + +#[derive(Debug, Default, Deserialize, Serialize)] #[serde(default)] pub struct ModelInfo { pub id: Option, pub display_name: Option, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, Serialize)] #[serde(default)] pub struct CostInfo { pub total_cost_usd: Option, pub total_duration_ms: Option, + pub total_api_duration_ms: Option, pub total_lines_added: Option, pub total_lines_removed: Option, pub total_tool_uses: Option, pub last_tool_name: Option, pub total_turns: Option, + /// Captures any fields we don't explicitly model (for debugging via --dump-state). + #[serde(flatten)] + pub extra: std::collections::HashMap, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, Serialize)] #[serde(default)] pub struct ContextWindow { pub used_percentage: Option, @@ -40,21 +64,23 @@ pub struct ContextWindow { pub current_usage: Option, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, Serialize)] #[serde(default)] pub struct CurrentUsage { + pub input_tokens: Option, + pub output_tokens: Option, pub cache_read_input_tokens: Option, pub cache_creation_input_tokens: Option, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, Serialize)] #[serde(default)] pub struct Workspace { pub project_dir: Option, pub terminal_width: Option, } -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Deserialize, Serialize)] #[serde(default)] pub struct OutputStyle { pub name: Option, diff --git a/src/lib.rs b/src/lib.rs index bdb2b76..95ee349 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,8 @@ pub mod layout; pub mod metrics; pub mod section; pub mod shell; +pub mod terminal; pub mod theme; +pub mod transcript; pub mod trend; pub mod width; diff --git a/src/terminal.rs b/src/terminal.rs new file mode 100644 index 0000000..21d597d --- /dev/null +++ b/src/terminal.rs @@ -0,0 +1,476 @@ +use crate::color; +use std::fs::OpenOptions; +use std::io::{Read, Write}; +use std::os::unix::io::AsRawFd; + +/// Detect the terminal's color palette automatically. +/// Priority: WezTerm config > Kitty config > Alacritty config > OSC 4 query. +pub fn detect_palette() -> Option> { + if let Some(p) = parse_wezterm_config() { + return Some(p); + } + if let Some(p) = parse_kitty_config() { + return Some(p); + } + if let Some(p) = parse_alacritty_config() { + return Some(p); + } + query_ansi_palette() +} + +// ── WezTerm config parsing ────────────────────────────────────────────── + +/// Parse WezTerm's Lua config for ANSI color definitions. +/// Checks `WEZTERM_CONFIG_FILE` env var, then common paths. +fn parse_wezterm_config() -> Option> { + let path = std::env::var("WEZTERM_CONFIG_FILE").ok().or_else(|| { + // Only probe filesystem if WezTerm is the active terminal + if std::env::var("TERM_PROGRAM").ok().as_deref() != Some("WezTerm") { + return None; + } + let home = std::env::var("HOME").ok()?; + let candidates = [ + format!("{home}/.wezterm.lua"), + format!("{home}/.config/wezterm/wezterm.lua"), + ]; + candidates + .into_iter() + .find(|p| std::path::Path::new(p).exists()) + })?; + + let content = std::fs::read_to_string(&path).ok()?; + extract_wezterm_colors(&content) +} + +/// Extract ANSI + bright colors from WezTerm Lua config text. +/// Looks for `ansi = { "#hex", ... }` and `brights = { "#hex", ... }` blocks. +fn extract_wezterm_colors(lua: &str) -> Option> { + let ansi = extract_lua_color_array(lua, "ansi")?; + let brights = extract_lua_color_array(lua, "brights").unwrap_or_default(); + + // Skip index 0 (black) and 7 (white) — they're usually bg/fg adjacent. + // Order: cyan, green, purple, red, yellow, blue, then brights. + let mut palette = Vec::new(); + + // ANSI indices: 1=red, 2=green, 3=yellow, 4=blue, 5=purple, 6=cyan + let ansi_order = [6, 2, 5, 1, 3, 4]; // cyan, green, purple, red, yellow, blue + for &idx in &ansi_order { + if let Some(c) = ansi.get(idx) { + palette.push(*c); + } + } + + // Bright variants for additional colors + let bright_order = [6, 2]; // bright cyan, bright green + for &idx in &bright_order { + if let Some(c) = brights.get(idx) { + palette.push(*c); + } + } + + if palette.is_empty() { + None + } else { + Some(palette) + } +} + +/// Find a Lua key name as a whole word (not matching substrings like "brightness" for "brights"). +fn find_lua_key(lua: &str, name: &str) -> Option { + let bytes = lua.as_bytes(); + let mut search_from = 0; + while let Some(rel_pos) = lua[search_from..].find(name) { + let pos = search_from + rel_pos; + let before_ok = pos == 0 || !bytes[pos - 1].is_ascii_alphanumeric(); + let after_ok = bytes + .get(pos + name.len()) + .is_none_or(|c| !c.is_ascii_alphanumeric()); + if before_ok && after_ok { + return Some(pos); + } + search_from = pos + 1; + } + None +} + +/// Extract a Lua array of hex color strings: `name = { "#hex", "#hex", ... }` +fn extract_lua_color_array(lua: &str, name: &str) -> Option> { + // Find `name` as a whole word (not "brightness" matching "brights") + let start = find_lua_key(lua, name)?; + let after_name = &lua[start + name.len()..]; + let brace_start = after_name.find('{')?; + let brace_content = &after_name[brace_start + 1..]; + let brace_end = brace_content.find('}')?; + let block = &brace_content[..brace_end]; + + let colors: Vec<(u8, u8, u8)> = block + .split('"') + .filter(|s| s.starts_with('#')) + .filter_map(color::parse_hex) + .collect(); + + if colors.is_empty() { + None + } else { + Some(colors) + } +} + +// ── Kitty config parsing ──────────────────────────────────────────────── + +/// Parse Kitty's config for ANSI color definitions. +fn parse_kitty_config() -> Option> { + if std::env::var("TERM_PROGRAM").ok().as_deref() != Some("kitty") { + return None; + } + let home = std::env::var("HOME").ok()?; + let path = format!("{home}/.config/kitty/kitty.conf"); + let content = std::fs::read_to_string(path).ok()?; + extract_kitty_colors(&content) +} + +/// Extract colors from Kitty config format: `color1 #hex` +fn extract_kitty_colors(conf: &str) -> Option> { + let mut ansi = [None; 16]; + for line in conf.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("color") { + let mut parts = rest.splitn(2, char::is_whitespace); + if let (Some(idx_str), Some(hex)) = (parts.next(), parts.next()) { + if let Ok(idx) = idx_str.parse::() { + if idx < 16 { + if let Some(rgb) = color::parse_hex(hex.trim()) { + ansi[idx] = Some(rgb); + } + } + } + } + } + } + + // Same ordering: cyan(6), green(2), purple(5), red(1), yellow(3), blue(4), + // bright cyan(14), bright green(10) + let order = [6, 2, 5, 1, 3, 4, 14, 10]; + let palette: Vec<(u8, u8, u8)> = order.iter().filter_map(|&i| ansi[i]).collect(); + + if palette.is_empty() { + None + } else { + Some(palette) + } +} + +// ── Alacritty config parsing ──────────────────────────────────────────── + +/// Parse Alacritty's TOML/YAML config for ANSI color definitions. +fn parse_alacritty_config() -> Option> { + if std::env::var("TERM_PROGRAM").ok().as_deref() != Some("Alacritty") { + return None; + } + let home = std::env::var("HOME").ok()?; + let candidates = [ + format!("{home}/.config/alacritty/alacritty.toml"), + format!("{home}/.config/alacritty/alacritty.yml"), + format!("{home}/.alacritty.toml"), + format!("{home}/.alacritty.yml"), + ]; + let path = candidates + .iter() + .find(|p| std::path::Path::new(p.as_str()).exists())?; + let content = std::fs::read_to_string(path).ok()?; + extract_alacritty_colors(&content) +} + +/// Extract hex colors from Alacritty config (simple line-based parsing). +/// Looks for lines like `red = "#hex"` or `red: '#hex'` within color sections. +fn extract_alacritty_colors(conf: &str) -> Option> { + let color_names = ["cyan", "green", "magenta", "red", "yellow", "blue"]; + let mut colors = Vec::new(); + for line in conf.lines() { + let trimmed = line.trim(); + for &name in &color_names { + if trimmed.starts_with(name) { + // Extract hex from: `red = "#FF0000"` or `red: '#FF0000'` + if let Some(hex_start) = trimmed.find('#') { + let hex_str: String = trimmed[hex_start..] + .chars() + .take_while(|c| *c == '#' || c.is_ascii_hexdigit()) + .collect(); + if let Some(rgb) = color::parse_hex(&hex_str) { + colors.push(rgb); + } + } + } + } + } + + if colors.is_empty() { + None + } else { + // Deduplicate (normal + bright sections may both match) + colors.dedup(); + Some(colors) + } +} + +// ── OSC 4 terminal query ──────────────────────────────────────────────── + +/// ANSI color indices to query for the tool palette. +const QUERY_INDICES: &[u8] = &[6, 2, 5, 1, 3, 4, 14, 10]; + +/// Query the terminal's ANSI color palette via OSC 4 escape sequences. +/// Opens `/dev/tty` directly (bypassing stdin/stdout pipes) to communicate +/// with the terminal emulator. Returns `None` if the terminal doesn't respond +/// or `/dev/tty` isn't available. +fn query_ansi_palette() -> Option> { + let mut tty = OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty") + .ok()?; + let fd = tty.as_raw_fd(); + + // Save terminal state + let mut old_termios: libc::termios = unsafe { std::mem::zeroed() }; + if unsafe { libc::tcgetattr(fd, &mut old_termios) } != 0 { + return None; + } + + // Set raw mode with short read timeout + let mut raw = old_termios; + unsafe { libc::cfmakeraw(&mut raw) }; + raw.c_cc[libc::VMIN] = 0; + raw.c_cc[libc::VTIME] = 1; // 100ms timeout per read + if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) } != 0 { + return None; + } + + // Send all queries at once for speed + let mut query = String::new(); + for &idx in QUERY_INDICES { + query.push_str(&format!("\x1b]4;{idx};?\x07")); + } + let write_ok = tty.write_all(query.as_bytes()).is_ok() && tty.flush().is_ok(); + + // Read all responses + let mut buf = vec![0u8; 1024]; + let mut total = 0; + if write_ok { + loop { + match tty.read(&mut buf[total..]) { + Ok(0) => break, + Ok(n) => { + total += n; + if total >= buf.len() - 64 { + break; + } + } + Err(_) => break, + } + } + } + + // Restore terminal state (MUST happen even on error) + unsafe { libc::tcsetattr(fd, libc::TCSANOW, &old_termios) }; + + if total == 0 { + return None; + } + + let colors = parse_osc4_responses(&buf[..total]); + if colors.is_empty() { + None + } else { + Some(colors) + } +} + +// ── Cache helpers ─────────────────────────────────────────────────────── + +/// Serialize a palette to a cache-friendly string: "r,g,b;r,g,b;..." +pub fn palette_to_cache(palette: &[(u8, u8, u8)]) -> String { + palette + .iter() + .map(|(r, g, b)| format!("{r},{g},{b}")) + .collect::>() + .join(";") +} + +/// Deserialize a palette from a cache string. +pub fn palette_from_cache(s: &str) -> Option> { + let colors: Vec<(u8, u8, u8)> = s + .split(';') + .filter_map(|triplet| { + let parts: Vec<&str> = triplet.split(',').collect(); + if parts.len() == 3 { + Some(( + parts[0].parse().ok()?, + parts[1].parse().ok()?, + parts[2].parse().ok()?, + )) + } else { + None + } + }) + .collect(); + if colors.is_empty() { + None + } else { + Some(colors) + } +} + +// ── Shared parsing helpers ────────────────────────────────────────────── + +/// Parse OSC 4 responses from a byte buffer. +fn parse_osc4_responses(buf: &[u8]) -> Vec<(u8, u8, u8)> { + let s = String::from_utf8_lossy(buf); + let mut colors = Vec::new(); + + for chunk in s.split('\x1b') { + if let Some(rgb_pos) = chunk.find("rgb:") { + let rgb_str = &chunk[rgb_pos + 4..]; + if let Some(parsed) = parse_rgb_triplet(rgb_str) { + colors.push(parsed); + } + } + } + + colors +} + +fn parse_rgb_triplet(s: &str) -> Option<(u8, u8, u8)> { + let parts: Vec<&str> = s.split('/').collect(); + if parts.len() < 3 { + return None; + } + + let r = parse_osc_component(parts[0])?; + let g = parse_osc_component(parts[1])?; + let b_str: String = parts[2] + .chars() + .take_while(|c| c.is_ascii_hexdigit()) + .collect(); + let b = parse_osc_component(&b_str)?; + Some((r, g, b)) +} + +fn parse_osc_component(hex: &str) -> Option { + let clean: String = hex.chars().take_while(|c| c.is_ascii_hexdigit()).collect(); + match clean.len() { + 4 => u8::from_str_radix(&clean[0..2], 16).ok(), + 2 => u8::from_str_radix(&clean, 16).ok(), + 1 => u8::from_str_radix(&clean, 16).ok().map(|v| v * 17), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── OSC 4 parsing ─────────────────────────────────────────────────── + + #[test] + fn parse_osc4_response_4digit() { + let response = b"\x1b]4;6;rgb:8b8b/e9e9/fdfd\x07"; + let colors = parse_osc4_responses(response); + assert_eq!(colors, vec![(0x8b, 0xe9, 0xfd)]); + } + + #[test] + fn parse_osc4_response_2digit() { + let response = b"\x1b]4;1;rgb:ff/00/55\x07"; + let colors = parse_osc4_responses(response); + assert_eq!(colors, vec![(0xff, 0x00, 0x55)]); + } + + #[test] + fn parse_multiple_responses() { + let response = b"\x1b]4;6;rgb:8b8b/e9e9/fdfd\x07\x1b]4;2;rgb:5050/fafa/7b7b\x07"; + let colors = parse_osc4_responses(response); + assert_eq!(colors, vec![(0x8b, 0xe9, 0xfd), (0x50, 0xfa, 0x7b)]); + } + + #[test] + fn parse_st_terminated() { + let response = b"\x1b]4;1;rgb:ffff/0000/5555\x1b\\"; + let colors = parse_osc4_responses(response); + assert_eq!(colors, vec![(0xff, 0x00, 0x55)]); + } + + // ── Cache roundtrip ───────────────────────────────────────────────── + + #[test] + fn cache_roundtrip() { + let palette = vec![(139, 233, 253), (80, 250, 123)]; + let cached = palette_to_cache(&palette); + let restored = palette_from_cache(&cached).unwrap(); + assert_eq!(palette, restored); + } + + // ── WezTerm config parsing ────────────────────────────────────────── + + #[test] + fn wezterm_explicit_colors() { + let lua = r##" +config.colors = { + ansi = { + "#100F0F", -- Black + "#AF3029", -- Red + "#66800B", -- Green + "#AD8301", -- Yellow + "#205EA6", -- Blue + "#5E409D", -- Purple + "#24837B", -- Cyan + "#CECDC3", -- White + }, + brights = { + "#575653", -- Black + "#D14D41", -- Red + "#879A39", -- Green + "#D0A215", -- Yellow + "#4385BE", -- Blue + "#8B7EC8", -- Purple + "#3AA99F", -- Cyan + "#FFFCF0", -- White + }, +} +"##; + let palette = extract_wezterm_colors(lua).unwrap(); + // Order: cyan(6), green(2), purple(5), red(1), yellow(3), blue(4), + // bright cyan(14→6), bright green(10→2) + assert_eq!(palette.len(), 8); + assert_eq!(palette[0], (0x24, 0x83, 0x7B)); // cyan + assert_eq!(palette[1], (0x66, 0x80, 0x0B)); // green + assert_eq!(palette[2], (0x5E, 0x40, 0x9D)); // purple + assert_eq!(palette[3], (0xAF, 0x30, 0x29)); // red + assert_eq!(palette[4], (0xAD, 0x83, 0x01)); // yellow + assert_eq!(palette[5], (0x20, 0x5E, 0xA6)); // blue + assert_eq!(palette[6], (0x3A, 0xA9, 0x9F)); // bright cyan + assert_eq!(palette[7], (0x87, 0x9A, 0x39)); // bright green + } + + // ── Kitty config parsing ──────────────────────────────────────────── + + #[test] + fn kitty_colors() { + let conf = r##" +color0 #1d2021 +color1 #cc241d +color2 #98971a +color3 #d79921 +color4 #458588 +color5 #b16286 +color6 #689d6a +color7 #a89984 +color10 #b8bb26 +color14 #8ec07c +"##; + let palette = extract_kitty_colors(conf).unwrap(); + assert_eq!(palette[0], (0x68, 0x9d, 0x6a)); // color6 cyan + assert_eq!(palette[1], (0x98, 0x97, 0x1a)); // color2 green + assert_eq!(palette[2], (0xb1, 0x62, 0x86)); // color5 magenta + assert_eq!(palette[3], (0xcc, 0x24, 0x1d)); // color1 red + } +} diff --git a/src/transcript.rs b/src/transcript.rs new file mode 100644 index 0000000..d7aa528 --- /dev/null +++ b/src/transcript.rs @@ -0,0 +1,132 @@ +use serde::Serialize; +use std::collections::HashMap; +use std::io::BufRead; +use std::path::Path; + +/// Statistics derived from a Claude Code transcript JSONL file. +#[derive(Debug, Default, Clone, Serialize)] +pub struct TranscriptStats { + pub total_tool_uses: u64, + pub total_turns: u64, + pub last_tool_name: Option, + /// Per-tool counts sorted by count descending. + pub tool_counts: Vec<(String, u64)>, +} + +/// Cached format: "tools:N,turns:N,last:Name;ToolA=C,ToolB=C,..." +impl TranscriptStats { + pub fn to_cache_string(&self) -> String { + let mut s = format!("tools:{},turns:{}", self.total_tool_uses, self.total_turns); + if let Some(name) = &self.last_tool_name { + s.push_str(&format!(",last:{name}")); + } + if !self.tool_counts.is_empty() { + s.push(';'); + let parts: Vec = self + .tool_counts + .iter() + .map(|(name, count)| format!("{name}={count}")) + .collect(); + s.push_str(&parts.join(",")); + } + s + } + + pub fn from_cache_string(s: &str) -> Option { + let mut stats = Self::default(); + + // Split on ';' — first part is summary, second is per-tool counts + let (summary, breakdown) = s.split_once(';').unwrap_or((s, "")); + + for part in summary.split(',') { + if let Some((key, val)) = part.split_once(':') { + match key { + "tools" => stats.total_tool_uses = val.parse().unwrap_or(0), + "turns" => stats.total_turns = val.parse().unwrap_or(0), + "last" => { + if !val.is_empty() { + stats.last_tool_name = Some(val.to_string()); + } + } + _ => {} + } + } + } + + if !breakdown.is_empty() { + for part in breakdown.split(',') { + if let Some((name, count_str)) = part.split_once('=') { + let count: u64 = count_str.parse().unwrap_or(0); + if count > 0 { + stats.tool_counts.push((name.to_string(), count)); + } + } + } + } + + Some(stats) + } +} + +/// Parse a Claude Code transcript JSONL file and extract tool use and turn counts. +/// `skip_lines` skips that many lines from the start (used after /clear to ignore +/// pre-clear entries in the same transcript file). +/// +/// Transcript format (one JSON object per line): +/// - `{"type": "user", ...}` — a user turn +/// - `{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Read"}, ...]}}` — tool uses +pub fn parse_transcript(path: &Path, skip_lines: usize) -> Option { + let file = std::fs::File::open(path).ok()?; + let reader = std::io::BufReader::new(file); + + let mut stats = TranscriptStats::default(); + let mut counts: HashMap = HashMap::new(); + + for line in reader.lines().skip(skip_lines) { + let line = match line { + Ok(l) => l, + Err(_) => continue, + }; + if line.is_empty() { + continue; + } + + let v: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + + let msg_type = v.get("type").and_then(|t| t.as_str()).unwrap_or(""); + + match msg_type { + "user" => { + stats.total_turns += 1; + } + "assistant" => { + if let Some(content) = v + .get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_array()) + { + for block in content { + if block.get("type").and_then(|t| t.as_str()) == Some("tool_use") { + stats.total_tool_uses += 1; + if let Some(name) = block.get("name").and_then(|n| n.as_str()) { + stats.last_tool_name = Some(name.to_string()); + *counts.entry(name.to_string()).or_insert(0) += 1; + } + } + } + } + } + _ => {} + } + } + + // Sort by count descending + let mut sorted: Vec<(String, u64)> = counts.into_iter().collect(); + sorted.sort_by(|a, b| b.1.cmp(&a.1)); + stats.tool_counts = sorted; + + Some(stats) +}