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:
82
Cargo.lock
generated
82
Cargo.lock
generated
@@ -112,6 +112,7 @@ checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
|
|||||||
name = "claude-statusline"
|
name = "claude-statusline"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"colorgrad",
|
||||||
"criterion",
|
"criterion",
|
||||||
"libc",
|
"libc",
|
||||||
"md-5",
|
"md-5",
|
||||||
@@ -123,6 +124,15 @@ dependencies = [
|
|||||||
"unicode-width",
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorgrad"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "faedab4fd8670120c2be7f49225fbdb8b6db6d46f04ce4f864b1f1cdd55e6400"
|
||||||
|
dependencies = [
|
||||||
|
"csscolorparser",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "criterion"
|
name = "criterion"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -200,6 +210,15 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "csscolorparser"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5fda6aace1fbef3aa217b27f4c8d7d071ef2a70a5ca51050b1f17d40299d3f16"
|
||||||
|
dependencies = [
|
||||||
|
"phf",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -322,6 +341,48 @@ version = "11.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
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]]
|
[[package]]
|
||||||
name = "plotters"
|
name = "plotters"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
@@ -368,6 +429,21 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"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]]
|
[[package]]
|
||||||
name = "rayon"
|
name = "rayon"
|
||||||
version = "1.11.0"
|
version = "1.11.0"
|
||||||
@@ -496,6 +572,12 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "siphasher"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.114"
|
version = "2.0.114"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ unicode-segmentation = "1"
|
|||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
serde_path_to_error = "0.1"
|
serde_path_to_error = "0.1"
|
||||||
serde_ignored = "0.1"
|
serde_ignored = "0.1"
|
||||||
|
colorgrad = "0.7"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
criterion = { version = "0.5", features = ["html_reports"] }
|
criterion = { version = "0.5", features = ["html_reports"] }
|
||||||
|
|||||||
17
src/cache.rs
17
src/cache.rs
@@ -187,6 +187,23 @@ impl Cache {
|
|||||||
Some(())
|
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> {
|
fn key_path(&self, key: &str) -> Option<PathBuf> {
|
||||||
let dir = self.dir.as_ref()?;
|
let dir = self.dir.as_ref()?;
|
||||||
let safe_key: String = key
|
let safe_key: String = key
|
||||||
|
|||||||
147
src/color.rs
147
src/color.rs
@@ -4,6 +4,9 @@ use crate::theme::Theme;
|
|||||||
pub const RESET: &str = "\x1b[0m";
|
pub const RESET: &str = "\x1b[0m";
|
||||||
pub const BOLD: &str = "\x1b[1m";
|
pub const BOLD: &str = "\x1b[1m";
|
||||||
pub const DIM: &str = "\x1b[2m";
|
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 RED: &str = "\x1b[31m";
|
||||||
pub const GREEN: &str = "\x1b[32m";
|
pub const GREEN: &str = "\x1b[32m";
|
||||||
pub const YELLOW: &str = "\x1b[33m";
|
pub const YELLOW: &str = "\x1b[33m";
|
||||||
@@ -12,7 +15,92 @@ pub const MAGENTA: &str = "\x1b[35m";
|
|||||||
pub const CYAN: &str = "\x1b[36m";
|
pub const CYAN: &str = "\x1b[36m";
|
||||||
pub const WHITE: &str = "\x1b[37m";
|
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).
|
/// 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 {
|
pub fn resolve_color(name: &str, theme: Theme, palette: &ThemeColors) -> String {
|
||||||
if let Some(key) = name.strip_prefix("p:") {
|
if let Some(key) = name.strip_prefix("p:") {
|
||||||
let map = match theme {
|
let map = match theme {
|
||||||
@@ -27,18 +115,53 @@ pub fn resolve_color(name: &str, theme: Theme, palette: &ThemeColors) -> String
|
|||||||
|
|
||||||
let mut result = String::new();
|
let mut result = String::new();
|
||||||
for part in name.split_whitespace() {
|
for part in name.split_whitespace() {
|
||||||
result.push_str(match part {
|
let resolved = match part {
|
||||||
"red" => RED,
|
// Named foreground colors
|
||||||
"green" => GREEN,
|
"red" => RED.to_string(),
|
||||||
"yellow" => YELLOW,
|
"green" => GREEN.to_string(),
|
||||||
"blue" => BLUE,
|
"yellow" => YELLOW.to_string(),
|
||||||
"magenta" => MAGENTA,
|
"blue" => BLUE.to_string(),
|
||||||
"cyan" => CYAN,
|
"magenta" => MAGENTA.to_string(),
|
||||||
"white" => WHITE,
|
"cyan" => CYAN.to_string(),
|
||||||
"dim" => DIM,
|
"white" => WHITE.to_string(),
|
||||||
"bold" => BOLD,
|
// 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() {
|
if result.is_empty() {
|
||||||
|
|||||||
42
src/input.rs
42
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)]
|
#[serde(default)]
|
||||||
pub struct InputData {
|
pub struct InputData {
|
||||||
pub model: Option<ModelInfo>,
|
pub model: Option<ModelInfo>,
|
||||||
@@ -9,28 +9,52 @@ pub struct InputData {
|
|||||||
pub workspace: Option<Workspace>,
|
pub workspace: Option<Workspace>,
|
||||||
pub version: Option<String>,
|
pub version: Option<String>,
|
||||||
pub output_style: Option<OutputStyle>,
|
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)]
|
#[serde(default)]
|
||||||
pub struct ModelInfo {
|
pub struct ModelInfo {
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct CostInfo {
|
pub struct CostInfo {
|
||||||
pub total_cost_usd: Option<f64>,
|
pub total_cost_usd: Option<f64>,
|
||||||
pub total_duration_ms: Option<u64>,
|
pub total_duration_ms: Option<u64>,
|
||||||
|
pub total_api_duration_ms: Option<u64>,
|
||||||
pub total_lines_added: Option<u64>,
|
pub total_lines_added: Option<u64>,
|
||||||
pub total_lines_removed: Option<u64>,
|
pub total_lines_removed: Option<u64>,
|
||||||
pub total_tool_uses: Option<u64>,
|
pub total_tool_uses: Option<u64>,
|
||||||
pub last_tool_name: Option<String>,
|
pub last_tool_name: Option<String>,
|
||||||
pub total_turns: Option<u64>,
|
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)]
|
#[serde(default)]
|
||||||
pub struct ContextWindow {
|
pub struct ContextWindow {
|
||||||
pub used_percentage: Option<f64>,
|
pub used_percentage: Option<f64>,
|
||||||
@@ -40,21 +64,23 @@ pub struct ContextWindow {
|
|||||||
pub current_usage: Option<CurrentUsage>,
|
pub current_usage: Option<CurrentUsage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct CurrentUsage {
|
pub struct CurrentUsage {
|
||||||
|
pub input_tokens: Option<u64>,
|
||||||
|
pub output_tokens: Option<u64>,
|
||||||
pub cache_read_input_tokens: Option<u64>,
|
pub cache_read_input_tokens: Option<u64>,
|
||||||
pub cache_creation_input_tokens: Option<u64>,
|
pub cache_creation_input_tokens: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Workspace {
|
pub struct Workspace {
|
||||||
pub project_dir: Option<String>,
|
pub project_dir: Option<String>,
|
||||||
pub terminal_width: Option<u16>,
|
pub terminal_width: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct OutputStyle {
|
pub struct OutputStyle {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ pub mod layout;
|
|||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod section;
|
pub mod section;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
|
pub mod terminal;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
|
pub mod transcript;
|
||||||
pub mod trend;
|
pub mod trend;
|
||||||
pub mod width;
|
pub mod width;
|
||||||
|
|||||||
476
src/terminal.rs
Normal file
476
src/terminal.rs
Normal 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
132
src/transcript.rs
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user