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:
Taylor Eernisse
2026-02-09 23:41:50 -05:00
parent f46c3da69c
commit e0c4a0fa9a
8 changed files with 879 additions and 20 deletions

82
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"] }

View File

@@ -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<PathBuf> {
let dir = self.dir.as_ref()?;
let safe_key: String = key

View File

@@ -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() {

View File

@@ -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<ModelInfo>,
@@ -9,28 +9,52 @@ pub struct InputData {
pub workspace: Option<Workspace>,
pub version: Option<String>,
pub output_style: Option<OutputStyle>,
pub transcript_path: Option<String>,
pub session_id: Option<String>,
pub cwd: Option<String>,
pub vim: Option<VimInfo>,
pub agent: Option<AgentInfo>,
pub exceeds_200k_tokens: Option<bool>,
#[serde(flatten)]
pub extra: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct VimInfo {
pub mode: Option<String>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct AgentInfo {
pub name: Option<String>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct ModelInfo {
pub id: Option<String>,
pub display_name: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct CostInfo {
pub total_cost_usd: Option<f64>,
pub total_duration_ms: Option<u64>,
pub total_api_duration_ms: Option<u64>,
pub total_lines_added: Option<u64>,
pub total_lines_removed: Option<u64>,
pub total_tool_uses: Option<u64>,
pub last_tool_name: Option<String>,
pub total_turns: Option<u64>,
/// Captures any fields we don't explicitly model (for debugging via --dump-state).
#[serde(flatten)]
pub extra: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct ContextWindow {
pub used_percentage: Option<f64>,
@@ -40,21 +64,23 @@ pub struct ContextWindow {
pub current_usage: Option<CurrentUsage>,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct CurrentUsage {
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub cache_read_input_tokens: Option<u64>,
pub cache_creation_input_tokens: Option<u64>,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct Workspace {
pub project_dir: Option<String>,
pub terminal_width: Option<u16>,
}
#[derive(Debug, Default, Deserialize)]
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct OutputStyle {
pub name: Option<String>,

View File

@@ -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;

476
src/terminal.rs Normal file
View File

@@ -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<Vec<(u8, u8, u8)>> {
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<Vec<(u8, u8, u8)>> {
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<Vec<(u8, u8, u8)>> {
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<usize> {
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<Vec<(u8, u8, u8)>> {
// 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<Vec<(u8, u8, u8)>> {
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<Vec<(u8, u8, u8)>> {
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::<usize>() {
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<Vec<(u8, u8, u8)>> {
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<Vec<(u8, u8, u8)>> {
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<Vec<(u8, u8, u8)>> {
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::<Vec<_>>()
.join(";")
}
/// Deserialize a palette from a cache string.
pub fn palette_from_cache(s: &str) -> Option<Vec<(u8, u8, u8)>> {
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<u8> {
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
}
}

132
src/transcript.rs Normal file
View File

@@ -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<String>,
/// 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<String> = 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<Self> {
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<TranscriptStats> {
let file = std::fs::File::open(path).ok()?;
let reader = std::io::BufReader::new(file);
let mut stats = TranscriptStats::default();
let mut counts: HashMap<String, u64> = 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)
}