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>
374 lines
11 KiB
Rust
374 lines
11 KiB
Rust
use std::cell::RefCell;
|
|
use std::fs;
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{Duration, SystemTime};
|
|
|
|
/// Diagnostic entry for a single cache lookup.
|
|
#[derive(Debug, Clone)]
|
|
pub struct CacheDiag {
|
|
pub key: String,
|
|
pub hit: bool,
|
|
pub age_ms: Option<u64>,
|
|
}
|
|
|
|
pub struct Cache {
|
|
dir: Option<PathBuf>,
|
|
jitter_pct: u8,
|
|
diagnostics: RefCell<Vec<CacheDiag>>,
|
|
}
|
|
|
|
impl Cache {
|
|
/// Create a disabled cache where all operations are no-ops.
|
|
/// Used for --no-cache mode.
|
|
pub fn disabled() -> Self {
|
|
Self {
|
|
dir: None,
|
|
jitter_pct: 0,
|
|
diagnostics: RefCell::new(Vec::new()),
|
|
}
|
|
}
|
|
|
|
/// Create cache with secure directory. Returns disabled cache on failure.
|
|
/// Replaces `{session_id}`, `{cache_version}`, and `{config_hash}` in template.
|
|
pub fn new(
|
|
template: &str,
|
|
session_id: &str,
|
|
cache_version: u32,
|
|
config_hash: &str,
|
|
jitter_pct: u8,
|
|
) -> Self {
|
|
let dir_str = template
|
|
.replace("{session_id}", session_id)
|
|
.replace("{cache_version}", &cache_version.to_string())
|
|
.replace("{config_hash}", config_hash);
|
|
let dir = PathBuf::from(&dir_str);
|
|
|
|
if !dir.exists() {
|
|
if fs::create_dir_all(&dir).is_err() {
|
|
return Self {
|
|
dir: None,
|
|
jitter_pct,
|
|
diagnostics: RefCell::new(Vec::new()),
|
|
};
|
|
}
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
|
|
}
|
|
}
|
|
|
|
// Security: verify ownership, not a symlink, not world-writable
|
|
if !verify_cache_dir(&dir) {
|
|
return Self {
|
|
dir: None,
|
|
jitter_pct,
|
|
diagnostics: RefCell::new(Vec::new()),
|
|
};
|
|
}
|
|
|
|
Self {
|
|
dir: Some(dir),
|
|
jitter_pct,
|
|
diagnostics: RefCell::new(Vec::new()),
|
|
}
|
|
}
|
|
|
|
pub fn dir(&self) -> Option<&Path> {
|
|
self.dir.as_deref()
|
|
}
|
|
|
|
/// Get cached value if fresher than TTL (with per-key jitter applied).
|
|
pub fn get(&self, key: &str, ttl: Duration) -> Option<String> {
|
|
let path = match self.key_path(key) {
|
|
Some(p) => p,
|
|
None => {
|
|
self.record_diag(key, false, None);
|
|
return None;
|
|
}
|
|
};
|
|
let meta = match fs::metadata(&path).ok() {
|
|
Some(m) => m,
|
|
None => {
|
|
self.record_diag(key, false, None);
|
|
return None;
|
|
}
|
|
};
|
|
let modified = match meta.modified().ok() {
|
|
Some(m) => m,
|
|
None => {
|
|
self.record_diag(key, false, None);
|
|
return None;
|
|
}
|
|
};
|
|
let age = match SystemTime::now().duration_since(modified).ok() {
|
|
Some(a) => a,
|
|
None => {
|
|
self.record_diag(key, false, None);
|
|
return None;
|
|
}
|
|
};
|
|
let age_ms = age.as_millis() as u64;
|
|
let effective_ttl = self.jittered_ttl(key, ttl);
|
|
if age < effective_ttl {
|
|
let value = fs::read_to_string(&path).ok();
|
|
self.record_diag(key, value.is_some(), Some(age_ms));
|
|
value
|
|
} else {
|
|
self.record_diag(key, false, Some(age_ms));
|
|
None
|
|
}
|
|
}
|
|
|
|
fn record_diag(&self, key: &str, hit: bool, age_ms: Option<u64>) {
|
|
if let Ok(mut diags) = self.diagnostics.try_borrow_mut() {
|
|
diags.push(CacheDiag {
|
|
key: key.to_string(),
|
|
hit,
|
|
age_ms,
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Return collected cache diagnostics (for --dump-state).
|
|
pub fn diagnostics(&self) -> Vec<CacheDiag> {
|
|
self.diagnostics.borrow().clone()
|
|
}
|
|
|
|
/// Apply deterministic per-key jitter to TTL.
|
|
/// Uses FNV-1a hash of key to produce stable jitter (same key = same jitter every time).
|
|
fn jittered_ttl(&self, key: &str, base_ttl: Duration) -> Duration {
|
|
if self.jitter_pct == 0 {
|
|
return base_ttl;
|
|
}
|
|
|
|
// FNV-1a hash of key for deterministic per-key jitter
|
|
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
|
for byte in key.bytes() {
|
|
hash ^= u64::from(byte);
|
|
hash = hash.wrapping_mul(0x0100_0000_01b3);
|
|
}
|
|
|
|
// Map hash to range [-jitter_pct, +jitter_pct]
|
|
let jitter_range = f64::from(self.jitter_pct) / 100.0;
|
|
let normalized = (hash % 2001) as f64 / 1000.0 - 1.0; // [-1.0, 1.0]
|
|
let multiplier = 1.0 + (normalized * jitter_range);
|
|
|
|
let jittered_ms = (base_ttl.as_millis() as f64 * multiplier) as u64;
|
|
// Clamp: minimum 100ms to avoid zero TTL
|
|
Duration::from_millis(jittered_ms.max(100))
|
|
}
|
|
|
|
/// Get stale cached value (ignores TTL). Used as fallback on command failure.
|
|
pub fn get_stale(&self, key: &str) -> Option<String> {
|
|
let path = self.key_path(key)?;
|
|
fs::read_to_string(&path).ok()
|
|
}
|
|
|
|
/// Atomic write: write to .tmp then rename (prevents partial reads).
|
|
/// Uses flock with LOCK_NB — skips cache on contention rather than blocking.
|
|
pub fn set(&self, key: &str, value: &str) -> Option<()> {
|
|
let path = self.key_path(key)?;
|
|
let tmp = path.with_extension("tmp");
|
|
|
|
// Try non-blocking flock
|
|
let lock_path = path.with_extension("lock");
|
|
let lock_file = fs::File::create(&lock_path).ok()?;
|
|
if !try_flock(&lock_file) {
|
|
return None; // contention — skip cache write
|
|
}
|
|
|
|
let mut f = fs::File::create(&tmp).ok()?;
|
|
f.write_all(value.as_bytes()).ok()?;
|
|
fs::rename(&tmp, &path).ok()?;
|
|
|
|
unlock(&lock_file);
|
|
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
|
|
.chars()
|
|
.map(|c| {
|
|
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
|
|
c
|
|
} else {
|
|
'_'
|
|
}
|
|
})
|
|
.collect();
|
|
Some(dir.join(safe_key))
|
|
}
|
|
}
|
|
|
|
fn verify_cache_dir(dir: &Path) -> bool {
|
|
let meta = match fs::symlink_metadata(dir) {
|
|
Ok(m) => m,
|
|
Err(_) => return false,
|
|
};
|
|
if !meta.is_dir() || meta.file_type().is_symlink() {
|
|
return false;
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::MetadataExt;
|
|
// Must be owned by current user
|
|
if meta.uid() != unsafe { libc::getuid() } {
|
|
return false;
|
|
}
|
|
// Must not be world-writable
|
|
if meta.mode() & 0o002 != 0 {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
fn try_flock(file: &fs::File) -> bool {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::io::AsRawFd;
|
|
let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
|
ret == 0
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
let _ = file;
|
|
true
|
|
}
|
|
}
|
|
|
|
fn unlock(file: &fs::File) {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::io::AsRawFd;
|
|
unsafe {
|
|
libc::flock(file.as_raw_fd(), libc::LOCK_UN);
|
|
}
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
let _ = file;
|
|
}
|
|
}
|
|
|
|
/// Session ID: first 12 chars of MD5 hex of project_dir.
|
|
/// Same algorithm as bash for cache compatibility during migration.
|
|
pub fn session_id(project_dir: &str) -> String {
|
|
use md5::{Digest, Md5};
|
|
let hash = Md5::digest(project_dir.as_bytes());
|
|
format!("{:x}", hash)[..12].to_string()
|
|
}
|
|
|
|
/// Garbage-collect old cache directories.
|
|
/// Runs at most once per `gc_interval_hours`. Deletes dirs older than `gc_days`
|
|
/// that match /tmp/claude-sl-* and are owned by the current user.
|
|
/// Never blocks: uses non-blocking flock on a sentinel file.
|
|
pub fn gc(gc_days: u16, gc_interval_hours: u16) {
|
|
let lock_path = Path::new("/tmp/claude-sl-gc.lock");
|
|
|
|
// Check interval: if lock file exists and is younger than gc_interval, skip
|
|
if let Ok(meta) = fs::metadata(lock_path) {
|
|
if let Ok(modified) = meta.modified() {
|
|
if let Ok(age) = SystemTime::now().duration_since(modified) {
|
|
if age < Duration::from_secs(u64::from(gc_interval_hours) * 3600) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try non-blocking lock
|
|
let lock_file = match fs::File::create(lock_path) {
|
|
Ok(f) => f,
|
|
Err(_) => return,
|
|
};
|
|
if !try_flock(&lock_file) {
|
|
return; // another process is GC-ing
|
|
}
|
|
|
|
// Touch the lock file (create already set mtime to now)
|
|
let max_age = Duration::from_secs(u64::from(gc_days) * 86400);
|
|
|
|
let entries = match fs::read_dir("/tmp") {
|
|
Ok(e) => e,
|
|
Err(_) => {
|
|
unlock(&lock_file);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let uid = current_uid();
|
|
|
|
for entry in entries.flatten() {
|
|
let name = entry.file_name();
|
|
let name_str = name.to_string_lossy();
|
|
if !name_str.starts_with("claude-sl-") {
|
|
continue;
|
|
}
|
|
|
|
let path = entry.path();
|
|
|
|
// Safety: skip symlinks
|
|
let meta = match fs::symlink_metadata(&path) {
|
|
Ok(m) => m,
|
|
Err(_) => continue,
|
|
};
|
|
if !meta.is_dir() || meta.file_type().is_symlink() {
|
|
continue;
|
|
}
|
|
|
|
// Only delete dirs owned by current user
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::MetadataExt;
|
|
if meta.uid() != uid {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Check age
|
|
if let Ok(modified) = meta.modified() {
|
|
if let Ok(age) = SystemTime::now().duration_since(modified) {
|
|
if age > max_age {
|
|
let _ = fs::remove_dir_all(&path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
unlock(&lock_file);
|
|
}
|
|
|
|
fn current_uid() -> u32 {
|
|
#[cfg(unix)]
|
|
{
|
|
unsafe { libc::getuid() }
|
|
}
|
|
#[cfg(not(unix))]
|
|
{
|
|
0
|
|
}
|
|
}
|