diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1 @@ +/target diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..a3dfa35 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,234 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634" +dependencies = [ + "defmt", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-static" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6142247df1a93c2b3587402a19710be3e6e942f1581a1702e76408f2c21d6590" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokenline" +version = "0.1.0" +dependencies = [ + "jiff", + "serde", + "serde_json", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..08945d4 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "tokenline" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +description = "A cache-aware statusline for AI coding CLIs" +license = "MIT" +repository = "https://github.com/inbrace-tech/tokenline" + +[[bin]] +name = "tokenline" +path = "src/main.rs" + +[lib] +name = "tokenline" +path = "src/lib.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +jiff = "0.2" + +[profile.release] +opt-level = "z" +lto = true +strip = true +panic = "unwind" # catch_unwind MUST work; abort would be a non-zero exit diff --git a/rust/src/cache.rs b/rust/src/cache.rs new file mode 100644 index 0000000..2e45e2e --- /dev/null +++ b/rust/src/cache.rs @@ -0,0 +1,144 @@ +use std::path::{Path, PathBuf}; + +pub struct CacheState { + pub last_ts: Option, + pub ttl: i64, + pub ttl_label: String, + pub last_tokens: u64, +} + +impl Default for CacheState { + fn default() -> Self { + CacheState { + last_ts: None, + ttl: 300, + ttl_label: "5m".into(), + last_tokens: 0, + } + } +} + +pub fn runtime_dir() -> PathBuf { + let base = platform_base(); + let _ = std::fs::create_dir_all(&base); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&base, std::fs::Permissions::from_mode(0o700)); + } + if base.is_dir() { + base + } else { + std::env::temp_dir() + } +} + +#[cfg(unix)] +fn platform_base() -> PathBuf { + let uid = std::env::var("UID").unwrap_or_default(); // often unset; fall through below + let uid = if uid.is_empty() { unix_uid() } else { uid }; + let root = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into()); + PathBuf::from(root).join(format!("tokenline-{}", uid)) +} + +#[cfg(unix)] +fn unix_uid() -> String { + // SAFETY: getuid() is always safe and never fails. + (unsafe { libc_getuid() }).to_string() +} +#[cfg(unix)] +unsafe extern "C" { + #[link_name = "getuid"] + fn libc_getuid() -> u32; +} + +#[cfg(windows)] +fn platform_base() -> PathBuf { + let root = std::env::var("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir()); + root.join("tokenline") +} + +fn file_for(dir: &Path, session_id: &str) -> PathBuf { + let id = if session_id.is_empty() { + "default" + } else { + session_id + }; + dir.join(format!("session-{}", id)) +} + +pub fn load(dir: &Path, session_id: &str) -> CacheState { + let mut st = CacheState::default(); + let Ok(raw) = std::fs::read_to_string(file_for(dir, session_id)) else { + return st; + }; + let parts: Vec<&str> = raw.split_whitespace().collect(); + if parts.len() >= 4 { + st.last_ts = parts[0].parse::().ok(); + if let Ok(t) = parts[1].parse::() { + st.ttl = t; + } + st.ttl_label = parts[2].to_string(); + if let Ok(tk) = parts[3].parse::() { + st.last_tokens = tk; + } + } + st +} + +pub fn store(dir: &Path, session_id: &str, st: &CacheState) { + let line = format!( + "{} {} {} {}", + st.last_ts.unwrap_or(0), + st.ttl, + st.ttl_label, + st.last_tokens + ); + let _ = std::fs::write(file_for(dir, session_id), line); // best-effort +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch() -> std::path::PathBuf { + let mut d = std::env::temp_dir(); + // unique-ish without rand/time crates: process id + d.push(format!("tokenline-test-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn roundtrip() { + let dir = scratch(); + let st = CacheState { + last_ts: Some(1_783_274_400), + ttl: 3600, + ttl_label: "1h".into(), + last_tokens: 88_802, + }; + store(&dir, "sess1", &st); + let got = load(&dir, "sess1"); + assert_eq!(got.last_ts, Some(1_783_274_400)); + assert_eq!(got.ttl, 3600); + assert_eq!(got.ttl_label, "1h"); + assert_eq!(got.last_tokens, 88_802); + } + + #[test] + fn missing_file_defaults() { + let dir = scratch(); + let got = load(&dir, "does-not-exist"); + assert_eq!(got.last_ts, None); + assert_eq!(got.ttl_label, "5m"); // default window + } + + #[test] + fn runtime_dir_exists() { + let d = runtime_dir(); + assert!(d.exists()); + } +} diff --git a/rust/src/cachetimer.rs b/rust/src/cachetimer.rs new file mode 100644 index 0000000..386d62d --- /dev/null +++ b/rust/src/cachetimer.rs @@ -0,0 +1,219 @@ +use crate::cache; +use crate::fmt::iso8601_to_epoch; +use crate::input::Session; +use std::path::Path; + +#[derive(Debug)] +pub enum Warmth { + Warm, + Cooling, + Cold, +} + +pub struct CacheInfo { + pub ttl_label: String, + pub remaining_secs: i64, + pub warmth: Warmth, + pub is_1h: bool, +} + +/// Read the last assistant/PLANNER turn's (timestamp, ephemeral_5m, ephemeral_1h) +/// from the tail of the transcript. Returns (last_ts, e5m, e1h). +fn scan_transcript(path: &str) -> (Option, u64, u64) { + let Ok(content) = std::fs::read_to_string(path) else { + return (None, 0, 0); + }; + // Tail ~200 lines (tokenline.sh:199). Scan from the end for the last match. + for line in content.lines().rev().take(200) { + let Ok(v) = serde_json::from_str::(line) else { + continue; + }; + let t = v.get("type").and_then(|t| t.as_str()).unwrap_or(""); + if t != "assistant" && t != "PLANNER_RESPONSE" { + continue; + } + let iso = v + .get("timestamp") + .and_then(|x| x.as_str()) + .or_else(|| v.get("created_at").and_then(|x| x.as_str())); + let cc = v.pointer("/message/usage/cache_creation"); + let e5m = cc + .and_then(|c| c.get("ephemeral_5m_input_tokens")) + .and_then(|x| x.as_u64()) + .unwrap_or(0); + let e1h = cc + .and_then(|c| c.get("ephemeral_1h_input_tokens")) + .and_then(|x| x.as_u64()) + .unwrap_or(0); + return (iso.and_then(iso8601_to_epoch), e5m, e1h); + } + (None, 0, 0) +} + +fn file_mtime(path: &str) -> Option { + let meta = std::fs::metadata(path).ok()?; + let mt = meta.modified().ok()?; + let secs = mt.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); + Some(secs as i64) +} + +pub fn cache_info(s: &Session, now: i64, dir: &Path) -> CacheInfo { + let mut state = cache::load(dir, &s.session_id); + + // Turn detection: token count changed => stamp `now` (tokenline.sh:182-188) + if s.tokens_used != state.last_tokens { + state.last_ts = Some(now); + state.last_tokens = s.tokens_used; + } + + // Transcript last-turn timestamp + ephemeral flags (tokenline.sh:195-214) + let (mut last_ts, e5m, e1h) = if !s.transcript_path.is_empty() { + let (ts, a, b) = scan_transcript(&s.transcript_path); + let ts = ts.or_else(|| file_mtime(&s.transcript_path)); // mtime fallback + (ts, a, b) + } else { + (None, 0, 0) + }; + + // Prefer cached ts if newer or transcript ts missing (tokenline.sh:216-223) + if let Some(cached) = state.last_ts { + if last_ts.is_none_or(|t| cached > t) { + last_ts = Some(cached); + } + } + // Always have a timestamp (tokenline.sh:225-229) + let last_ts = last_ts.unwrap_or(now); + state.last_ts = Some(last_ts); + + // TTL window determination (tokenline.sh:231-250) + let (ttl, ttl_label) = if s.is_gemini { + (300, "5m") + } else if e1h > 0 { + (3600, "1h") + } else if e5m > 0 { + (300, "5m") + } else { + // fall back to previously-determined session ttl + (state.ttl, if state.ttl == 3600 { "1h" } else { "5m" }) + }; + state.ttl = ttl; + state.ttl_label = ttl_label.to_string(); + cache::store(dir, &s.session_id, &state); + + let elapsed = now - last_ts; + let remaining = ttl - elapsed; + // pct10 == remaining*10/ttl (tokenline.sh:260) + let pct10 = if ttl > 0 { remaining * 10 / ttl } else { 0 }; + let warmth = if remaining <= 0 { + Warmth::Cold + } else if pct10 < 2 { + Warmth::Cooling + } + // < 20% left + else { + Warmth::Warm + }; + + CacheInfo { + ttl_label: ttl_label.to_string(), + remaining_secs: remaining, + warmth, + is_1h: ttl == 3600, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::Session; + + fn scratch() -> std::path::PathBuf { + let mut d = std::env::temp_dir(); + d.push(format!("tl-ct-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d + } + fn sess(json: &str) -> Session { + Session::from_raw(serde_json::from_str(json).unwrap()) + } + + #[test] + fn fresh_session_starts_warm_at_full_ttl() { + let dir = scratch(); + // no transcript, no cache => last_ts defaults to now => remaining == ttl + let s = sess( + r#"{"session_id":"warm1","context_window":{"current_usage": + {"cache_read_input_tokens":100}}}"#, + ); + let now = 1_783_000_000; + let ci = cache_info(&s, now, &dir); + assert_eq!(ci.ttl_label, "5m"); // no ephemeral fields => default 5m + assert!(ci.remaining_secs > 290 && ci.remaining_secs <= 300); + assert!(matches!(ci.warmth, Warmth::Warm)); + } + + #[test] + fn transcript_1h_window_and_countdown() { + let dir = scratch(); + // transcript whose last assistant turn is 100s ago and has ephemeral_1h > 0 + let tp = dir.join("t1.jsonl"); + // Must be a single physical line: scan_transcript reads JSONL (one JSON object + // per line), so a raw string spanning multiple source lines would embed real + // newlines and split this into unparsable fragments. + std::fs::write(&tp, format!( + "{}\n", + r#"{"type":"assistant","timestamp":"2026-07-05T00:00:00Z","message":{"usage":{"cache_creation":{"ephemeral_1h_input_tokens":10,"ephemeral_5m_input_tokens":0}}}}"# + )).unwrap(); + let turn_epoch = crate::fmt::iso8601_to_epoch("2026-07-05T00:00:00Z").unwrap(); + let now = turn_epoch + 100; // 100s after the turn + let s = sess(&format!( + r#"{{"session_id":"h1","transcript_path":"{}", + "context_window":{{"current_usage":{{"cache_read_input_tokens":100}}}}}}"#, + tp.display() + )); + // Prime last_tokens to match s.tokens_used so turn detection does NOT fire this + // poll (tokenline.sh:185 fires on a *change*; a truly first-ever poll would stamp + // `now` and clobber the transcript-derived ts, which is not what this test probes). + // This models steady-state polling: no new turn since last render, so the last + // real turn time must come from the transcript, not from "now". + crate::cache::store( + &dir, + "h1", + &crate::cache::CacheState { + last_ts: None, + ttl: 300, + ttl_label: "5m".into(), + last_tokens: 100, + }, + ); + let ci = cache_info(&s, now, &dir); + assert_eq!(ci.ttl_label, "1h"); + assert!(ci.is_1h); + assert!((ci.remaining_secs - 3500).abs() <= 2); // 3600 - 100 + assert!(matches!(ci.warmth, Warmth::Warm)); + } + + #[test] + fn expired_is_cold() { + let dir = scratch(); + let s = sess( + r#"{"session_id":"cold1","context_window":{"current_usage": + {"cache_read_input_tokens":100}}}"#, + ); + // Prime the cache with an old timestamp + 5m ttl + crate::cache::store( + &dir, + "cold1", + &crate::cache::CacheState { + last_ts: Some(1_000_000), + ttl: 300, + ttl_label: "5m".into(), + last_tokens: 100, + }, + ); + let now = 1_000_000 + 400; // 400s later, ttl 300 => expired + let ci = cache_info(&s, now, &dir); + assert!(ci.remaining_secs <= 0); + assert!(matches!(ci.warmth, Warmth::Cold)); + } +} diff --git a/rust/src/economics.rs b/rust/src/economics.rs new file mode 100644 index 0000000..9d4d287 --- /dev/null +++ b/rust/src/economics.rs @@ -0,0 +1,138 @@ +use crate::input::Session; + +/// Pure compute for the per-turn token economics line (tokenline.sh +/// `compute_turn_breakdown`, lines ~388-441). No color/presentation here — +/// that's render.rs's job. +pub struct Economics { + pub read: u64, + pub write: u64, + pub new: u64, + pub output: u64, + pub eq: u64, + pub saving_pct: i64, + pub read_label: String, + pub write_label: String, + pub new_label: String, + pub output_label: String, +} + +/// `ttl_label` is the cache TTL label ("5m" or "1h") driving the write +/// multiplier bump for non-Gemini models. Returns `None` when there's no +/// token activity at all (bash's no-cost-line guard). +pub fn compute(s: &Session, ttl_label: &str) -> Option { + if s.cur_cread == 0 && s.cur_cwrite == 0 && s.cur_input == 0 && s.cur_output == 0 { + return None; + } + + // Multiplier value + its bash-literal label string (not derived from the + // float — bash assigns these as fixed strings, e.g. write_mult="2"). + let (read_mult, read_label, write_mult, write_label, new_mult, new_label, out_mult, out_label): ( + f64, + &str, + f64, + &str, + f64, + &str, + f64, + &str, + ) = if s.is_gemini { + (0.25, "0.25", 1.0, "1.0", 1.0, "1", 4.0, "4") + } else if ttl_label == "1h" { + (0.1, "0.1", 2.0, "2", 1.0, "1", 5.0, "5") + } else { + (0.1, "0.1", 1.25, "1.25", 1.0, "1", 5.0, "5") + }; + + // Match bash awk `printf "%d"` (truncation toward zero). + let eq = (s.cur_cread as f64 * read_mult + + s.cur_cwrite as f64 * write_mult + + s.cur_input as f64 * new_mult + + s.cur_output as f64 * out_mult) as u64; + + let uncached_eq = ((s.cur_cread + s.cur_cwrite + s.cur_input) as f64 * new_mult + + s.cur_output as f64 * out_mult) as u64; + + let saving_pct = if uncached_eq > 0 { + (100.0 * (uncached_eq as f64 - eq as f64) / uncached_eq as f64) as i64 + } else { + 0 + }; + + Some(Economics { + read: s.cur_cread, + write: s.cur_cwrite, + new: s.cur_input, + output: s.cur_output, + eq, + saving_pct, + read_label: format!("{read_label}x"), + write_label: format!("{write_label}x"), + new_label: format!("{new_label}x"), + output_label: format!("{out_label}x"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sess(json: &str) -> Session { + Session::from_raw(serde_json::from_str(json).unwrap()) + } + + #[test] + fn golden_fixture_5m_matches_bash() { + // read 86.6k, write 2.2k, new 2, output 2.0k, ttl 5m, non-Gemini + // eq = trunc(86600*0.1 + 2200*1.25 + 2*1 + 2000*5) + // = trunc(8660 + 2750 + 2 + 10000) = 21412 + // Wait: recompute precisely below via the fixture's actual numbers. + let s = sess( + r#"{"model":{"display_name":"Opus 4.8"},"context_window":{"current_usage": + {"input_tokens":2,"output_tokens":2000,"cache_creation_input_tokens":2200, + "cache_read_input_tokens":86600}}}"#, + ); + let e = compute(&s, "5m").expect("activity present"); + assert_eq!(e.read, 86_600); + assert_eq!(e.write, 2_200); + assert_eq!(e.new, 2); + assert_eq!(e.output, 2_000); + assert_eq!(e.eq, 21_412); + assert_eq!(e.saving_pct, 78); + assert_eq!(e.read_label, "0.1x"); + assert_eq!(e.write_label, "1.25x"); + assert_eq!(e.new_label, "1x"); + assert_eq!(e.output_label, "5x"); + } + + #[test] + fn write_mult_bumps_to_2x_on_1h_ttl() { + let s = sess( + r#"{"context_window":{"current_usage": + {"cache_creation_input_tokens":1000,"output_tokens":0, + "input_tokens":0,"cache_read_input_tokens":0}}}"#, + ); + let e = compute(&s, "1h").expect("activity present"); + assert_eq!(e.eq, 2000); // 1000 * 2 + assert_eq!(e.write_label, "2x"); + } + + #[test] + fn gemini_multipliers() { + let s = sess( + r#"{"model":{"display_name":"Gemini 2.5 Pro"},"context_window":{"current_usage": + {"cache_read_input_tokens":1000,"cache_creation_input_tokens":1000, + "input_tokens":1000,"output_tokens":1000}}}"#, + ); + let e = compute(&s, "5m").expect("activity present"); + // eq = 250 + 1000 + 1000 + 4000 = 6250 + assert_eq!(e.eq, 6250); + assert_eq!(e.read_label, "0.25x"); + assert_eq!(e.write_label, "1.0x"); + } + + #[test] + fn zero_activity_returns_none() { + let s = sess("{}"); + assert!(compute(&s, "5m").is_none()); + } +} diff --git a/rust/src/fmt.rs b/rust/src/fmt.rs new file mode 100644 index 0000000..16814c1 --- /dev/null +++ b/rust/src/fmt.rs @@ -0,0 +1,144 @@ +//! Formatting helpers, byte-for-byte parity with `tokenline.sh`. +//! +//! Carries `fmt_k` / `fmt_eta`, the 256-color palette, and the ISO-8601 → epoch +//! parser. + +use jiff::Timestamp; + +/// Parse an RFC 3339 / ISO-8601 UTC timestamp to epoch seconds, or `None` on a +/// malformed value. Replaces the bash `epoch_from_iso` (`date -d`): transcripts +/// and rate-limit resets are fixed-format UTC, so `jiff::Timestamp` covers them +/// without pulling a timezone database. +pub fn iso8601_to_epoch(iso: &str) -> Option { + iso.parse::().ok().map(|t| t.as_second()) +} + +/// 256-color SGR sequences, byte-identical to `tokenline.sh`'s `COLOR_*` / +/// `STYLE_*` constants (`tokenline.sh:21-30`). Byte parity matters: the golden +/// oracle diffs the raw escape sequences, so `RESET` is `[00m` (two digits), +/// not `[0m`, exactly as the bash emits. +pub mod color { + pub const GRAY: &str = "\x1b[38;5;244m"; + pub const DARK_GRAY: &str = "\x1b[38;5;240m"; + pub const CYAN: &str = "\x1b[38;5;51m"; + pub const YELLOW: &str = "\x1b[38;5;226m"; + pub const MAGENTA: &str = "\x1b[38;5;201m"; + pub const ORANGE: &str = "\x1b[38;5;208m"; + pub const RED: &str = "\x1b[38;5;196m"; + pub const GREEN: &str = "\x1b[38;5;46m"; + pub const RESET: &str = "\x1b[00m"; + pub const BLINK: &str = "\x1b[1;5m"; +} + +/// Compact token count, mirroring the bash `fmt_k` (awk `%.1f`): +/// `1_500_000 -> "1.5M"`, `25_600 -> "25.6k"`, `900 -> "900"`. +pub fn fmt_k(v: u64) -> String { + if v >= 1_000_000 { + format!("{:.1}M", v as f64 / 1_000_000.0) + } else if v >= 1_000 { + format!("{:.1}k", v as f64 / 1_000.0) + } else { + v.to_string() + } +} + +/// Human ETA from raw seconds, mirroring the bash `fmt_eta`: +/// `<=0 -> "now"`, `<1h -> "%dm"`, `<1d -> "%dh%dm"/"%dh"`, else `"%dd%dh"/"%dd"`. +pub fn fmt_eta(secs: i64) -> String { + if secs <= 0 { + return "now".to_string(); + } + if secs < 3600 { + return format!("{}m", secs / 60); + } + if secs < 86_400 { + let (h, m) = (secs / 3600, (secs % 3600) / 60); + return if m > 0 { + format!("{}h{}m", h, m) + } else { + format!("{}h", h) + }; + } + let (d, h) = (secs / 86_400, (secs % 86_400) / 3600); + if h > 0 { + format!("{}d{}h", d, h) + } else { + format!("{}d", d) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fmt_k_tiers() { + assert_eq!(fmt_k(0), "0"); + assert_eq!(fmt_k(900), "900"); + assert_eq!(fmt_k(999), "999"); + assert_eq!(fmt_k(1_000), "1.0k"); + assert_eq!(fmt_k(2_000), "2.0k"); + assert_eq!(fmt_k(2_200), "2.2k"); + assert_eq!(fmt_k(25_600), "25.6k"); + assert_eq!(fmt_k(86_600), "86.6k"); + assert_eq!(fmt_k(1_000_000), "1.0M"); + assert_eq!(fmt_k(1_500_000), "1.5M"); + } + + #[test] + fn iso8601_to_epoch_parses_utc() { + // Exact epochs (verified independently). now in the golden = 1_783_000_000. + assert_eq!( + iso8601_to_epoch("2026-07-05T18:00:00Z"), + Some(1_783_274_400) + ); + assert_eq!( + iso8601_to_epoch("2026-07-06T12:00:00Z"), + Some(1_783_339_200) + ); + assert_eq!( + iso8601_to_epoch("2026-07-05T00:00:00Z"), + Some(1_783_209_600) + ); + } + + #[test] + fn iso8601_to_epoch_fractional_and_invalid() { + // Sub-second precision truncates to the whole second. + assert_eq!( + iso8601_to_epoch("2026-07-05T18:00:00.500Z"), + Some(1_783_274_400) + ); + assert_eq!(iso8601_to_epoch("not a timestamp"), None); + assert_eq!(iso8601_to_epoch(""), None); + } + + #[test] + fn palette_matches_bash_bytes() { + // Exact bytes captured from `tokenline.sh` output (cat -v): ESC[38;5;Nm. + assert_eq!(color::GRAY, "\x1b[38;5;244m"); + assert_eq!(color::CYAN, "\x1b[38;5;51m"); + assert_eq!(color::YELLOW, "\x1b[38;5;226m"); + assert_eq!(color::MAGENTA, "\x1b[38;5;201m"); + assert_eq!(color::ORANGE, "\x1b[38;5;208m"); + assert_eq!(color::GREEN, "\x1b[38;5;46m"); + assert_eq!(color::RED, "\x1b[38;5;196m"); + assert_eq!(color::DARK_GRAY, "\x1b[38;5;240m"); + // bash COLOR_RESET is [00m (two digits), STYLE_BLINK is [1;5m. + assert_eq!(color::RESET, "\x1b[00m"); + assert_eq!(color::BLINK, "\x1b[1;5m"); + } + + #[test] + fn fmt_eta_tiers() { + assert_eq!(fmt_eta(-5), "now"); + assert_eq!(fmt_eta(0), "now"); + assert_eq!(fmt_eta(30), "0m"); // %dm with integer division, like bash + assert_eq!(fmt_eta(90), "1m"); + assert_eq!(fmt_eta(3_600), "1h"); + assert_eq!(fmt_eta(3_660), "1h1m"); + assert_eq!(fmt_eta(86_400), "1d"); + assert_eq!(fmt_eta(90_000), "1d1h"); + assert_eq!(fmt_eta(273_600), "3d4h"); // 5h-window reset ETA in the golden + } +} diff --git a/rust/src/input.rs b/rust/src/input.rs new file mode 100644 index 0000000..1eee0e1 --- /dev/null +++ b/rust/src/input.rs @@ -0,0 +1,201 @@ +use serde::Deserialize; + +#[derive(Deserialize, Default)] +pub struct RawInput { + #[serde(default)] + pub model: Model, + #[serde(default)] + pub context_window: ContextWindow, + #[serde(default)] + pub transcript_path: String, + #[serde(default)] + pub session_id: String, + #[serde(default)] + pub rate_limits: RateLimits, +} + +#[derive(Deserialize, Default)] +pub struct Model { + #[serde(default)] + pub display_name: String, +} + +#[derive(Deserialize, Default)] +pub struct ContextWindow { + #[serde(default)] + pub used_percentage: Option, + #[serde(default)] + pub context_window_size: Option, + #[serde(default)] + pub current_usage: Usage, +} + +#[derive(Deserialize, Default)] +pub struct Usage { + #[serde(default)] + pub input_tokens: u64, + #[serde(default)] + pub output_tokens: u64, + #[serde(default)] + pub cache_creation_input_tokens: u64, + #[serde(default)] + pub cache_read_input_tokens: u64, +} + +#[derive(Deserialize, Default)] +pub struct RateLimits { + #[serde(default)] + pub five_hour: Option, + #[serde(default)] + pub seven_day: Option, +} + +#[derive(Deserialize, Default, Clone)] +pub struct RlRaw { + #[serde(default)] + pub used_percentage: Option, + #[serde(default)] + pub resets_at: Option, +} + +#[derive(PartialEq, Debug)] +pub enum Client { + ClaudeCode, + Antigravity, +} + +pub struct Session { + pub model: String, + pub used_pct: Option, + pub tokens_limit: Option, + pub transcript_path: String, + pub session_id: String, + pub rl_5h: Option, + pub rl_7d: Option, + pub cur_input: u64, + pub cur_output: u64, + pub cur_cwrite: u64, + pub cur_cread: u64, + pub tokens_used: u64, + pub cli_client: Client, + pub is_gemini: bool, +} + +impl Session { + pub fn from_raw(r: RawInput) -> Session { + let u = r.context_window.current_usage; + let tokens_used = + u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens; + + // Subagent transcript -> parent session (tokenline.sh:123-125) + // Normalize Windows backslashes once, up front, so all contains()/replace() + // logic below (written for forward slashes) works on both platforms. + let mut transcript_path = r.transcript_path.replace('\\', "/"); + if transcript_path.contains("/subagents/") { + if let Some(parent) = std::path::Path::new(&transcript_path) + .parent() + .and_then(|p| p.parent()) + { + transcript_path = format!("{}.jsonl", parent.display()); + } + } + + // Client + antigravity path correction (tokenline.sh:127-136) + let cli_client = if transcript_path.contains("/antigravity") { + Client::Antigravity + } else { + Client::ClaudeCode + }; + if cli_client == Client::Antigravity && transcript_path.contains("/antigravity/") { + transcript_path = transcript_path.replace("/antigravity/", "/antigravity-cli/"); + } + + let is_gemini = r.model.display_name.to_lowercase().contains("gemini"); + + Session { + model: r.model.display_name, + used_pct: r.context_window.used_percentage, + tokens_limit: r.context_window.context_window_size, + transcript_path, + session_id: r.session_id, + rl_5h: r.rate_limits.five_hour, + rl_7d: r.rate_limits.seven_day, + cur_input: u.input_tokens, + cur_output: u.output_tokens, + cur_cwrite: u.cache_creation_input_tokens, + cur_cread: u.cache_read_input_tokens, + tokens_used, + cli_client, + is_gemini, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#"{ + "model": {"display_name": "Opus 4.8"}, + "context_window": { + "used_percentage": 9.0, "context_window_size": 1000000, + "current_usage": {"input_tokens": 2, "output_tokens": 2000, + "cache_creation_input_tokens": 2200, "cache_read_input_tokens": 86600} + }, + "transcript_path": "/home/u/.claude/projects/p/abc.jsonl", + "session_id": "abc", + "rate_limits": { + "five_hour": {"used_percentage": 12.0, "resets_at": "2026-07-05T18:00:00Z"}, + "seven_day": {"used_percentage": 64.0, "resets_at": "2026-07-06T12:00:00Z"} + } + }"#; + + #[test] + fn parses_and_derives() { + let raw: RawInput = serde_json::from_str(SAMPLE).unwrap(); + let s = Session::from_raw(raw); + assert_eq!(s.model, "Opus 4.8"); + assert_eq!(s.tokens_limit, Some(1_000_000)); + assert_eq!(s.cur_cread, 86_600); + assert_eq!(s.tokens_used, 2 + 2200 + 86600); // input + cwrite + cread + assert_eq!(s.session_id, "abc"); + assert!(!s.is_gemini); + assert_eq!(s.cli_client, Client::ClaudeCode); + } + + #[test] + fn missing_fields_default_gracefully() { + let raw: RawInput = serde_json::from_str("{}").unwrap(); + let s = Session::from_raw(raw); + assert_eq!(s.model, ""); + assert_eq!(s.tokens_limit, None); + assert_eq!(s.tokens_used, 0); + } + + #[test] + fn detects_gemini_and_antigravity() { + let raw: RawInput = serde_json::from_str( + r#"{"model":{"display_name":"Gemini 2.5 Pro"}, + "transcript_path":"/x/antigravity/y/z.jsonl"}"#, + ) + .unwrap(); + let s = Session::from_raw(raw); + assert!(s.is_gemini); + assert_eq!(s.cli_client, Client::Antigravity); + assert!(s.transcript_path.contains("/antigravity-cli/")); + } + + #[test] + fn normalizes_windows_backslash_paths() { + // Backslash subagent path resolves to the parent .jsonl, same as the unix case. + let raw: RawInput = serde_json::from_str( + r#"{"transcript_path":"C:\\Users\\x\\antigravity\\p\\subagents\\s\\t.jsonl"}"#, + ) + .unwrap(); + let s = Session::from_raw(raw); + assert_eq!(s.cli_client, Client::Antigravity); + assert!(s.transcript_path.contains("/antigravity-cli/")); + assert!(s.transcript_path.ends_with(".jsonl")); + assert!(!s.transcript_path.contains("/subagents/")); + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..14224f2 --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1,19 @@ +//! tokenline — a cache-aware statusline for AI coding CLIs. +//! +//! Pure domain (deterministic, golden-testable) and a thin stateful edge. +//! Modules land bottom-up, one slice per PR (see inbrace-tech/tokenline#27). + +pub mod fmt; + +pub mod input; + +pub mod economics; + +pub mod ratelimits; + +pub mod cache; + +pub mod cachetimer; + +pub mod render; +pub use render::render; diff --git a/rust/src/main.rs b/rust/src/main.rs new file mode 100644 index 0000000..1d00510 --- /dev/null +++ b/rust/src/main.rs @@ -0,0 +1,42 @@ +// tokenline — a cache-aware statusline for AI coding CLIs. +// +// The imperative shell: read the host's JSON from stdin, stamp `now`, resolve +// the runtime cache dir, render, write, exit(0). It runs once per second inside +// the host CLI, so the one hard rule is *never disturb the host*: any panic +// anywhere is swallowed to empty output + exit(0), and writes go through +// `write_all` (not `print!`) so a closed pipe can't raise. +use std::io::{Read, Write}; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokenline::cache::runtime_dir; +use tokenline::input::RawInput; +use tokenline::render; + +fn main() { + std::panic::set_hook(Box::new(|_| {})); // no backtrace to the host's stderr + let line = std::panic::catch_unwind(run).unwrap_or_default(); + if !line.is_empty() { + let mut out = std::io::stdout(); + let _ = out.write_all(line.as_bytes()); + let _ = out.write_all(b"\n"); + let _ = out.flush(); + } + std::process::exit(0); +} + +// Returns the rendered statusline, or an empty string on any handled failure +// (unreadable stdin, malformed JSON) — the caller prints nothing in that case. +fn run() -> String { + let mut buf = String::new(); + if std::io::stdin().read_to_string(&mut buf).is_err() { + return String::new(); + } + let raw: RawInput = match serde_json::from_str(&buf) { + Ok(r) => r, + Err(_) => return String::new(), + }; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + render(raw, now, &runtime_dir()) +} diff --git a/rust/src/ratelimits.rs b/rust/src/ratelimits.rs new file mode 100644 index 0000000..4cfc572 --- /dev/null +++ b/rust/src/ratelimits.rs @@ -0,0 +1,238 @@ +use crate::fmt::iso8601_to_epoch; +use crate::input::{RlRaw, Session}; + +/// Pure compute for the rate-limit windows (tokenline.sh `rl_segment` / +/// `compute_rate_limits`, lines ~302-386). No color/ANSI here — that's +/// render.rs's job; this only exposes the severity bucket and pace flag as +/// enums for render.rs to map to color/blink. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Pace { + None, + Fast, + VeryFast, +} + +/// Severity bucket by `pct_int`, matching bash's `rl_color_for_pct` tiers. +/// `>=90` is also blinking in bash — carried separately as `RateLimit::blink` +/// rather than a 5th variant, since render.rs needs that as an orthogonal bit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Severity { + Good, // <25 + Warn, // >=25 + Caution, // >=50 + Critical, // >=75 (blink additionally when pct >= 90) +} + +pub struct RateLimit { + pub label: &'static str, + pub pct: i64, + pub eta_secs: i64, + pub bar_filled: usize, + pub bar_empty: usize, + pub severity: Severity, + pub blink: bool, + pub pace: Pace, +} + +const BAR_WIDTH: usize = 10; + +fn severity_of(pct: i64) -> (Severity, bool) { + if pct >= 90 { + (Severity::Critical, true) + } else if pct >= 75 { + (Severity::Critical, false) + } else if pct >= 50 { + (Severity::Caution, false) + } else if pct >= 25 { + (Severity::Warn, false) + } else { + (Severity::Good, false) + } +} + +fn bar_of(pct: i64) -> (usize, usize) { + let mut filled = ((pct.max(0) as usize) * BAR_WIDTH) / 100; + filled = filled.min(BAR_WIDTH); + (filled, BAR_WIDTH - filled) +} + +fn pace_of(pct_int: i64, eta_secs: i64, window_secs: i64) -> Pace { + if pct_int < 20 || eta_secs <= 0 || window_secs <= 0 { + return Pace::None; + } + let elapsed = (window_secs - eta_secs).max(0); + if elapsed < window_secs / 10 { + return Pace::None; + } + let pace = (pct_int as f64 * window_secs as f64) / (elapsed as f64 * 100.0); + if pace >= 1.5 { + Pace::VeryFast + } else if pace >= 1.25 { + Pace::Fast + } else { + Pace::None + } +} + +/// One window's worth of `rl_segment`. Returns `None` when `used_percentage` +/// is absent (bash's `[ -z "$pct" ] && return`). +fn rate_limit(label: &'static str, raw: &RlRaw, window_secs: i64, now: i64) -> Option { + let pct = raw.used_percentage?; + // bash `printf '%.0f'`: round half away from zero. pct is never negative + // here in practice, so floor(pct + 0.5) matches. + let pct_int = (pct + 0.5).floor() as i64; + + let eta_secs = raw + .resets_at + .as_deref() + .and_then(iso8601_to_epoch) + .map(|reset| (reset - now).max(0)) + .unwrap_or(0); + + let (severity, blink) = severity_of(pct_int); + let (bar_filled, bar_empty) = bar_of(pct_int); + + Some(RateLimit { + label, + pct: pct_int, + eta_secs, + bar_filled, + bar_empty, + severity, + blink, + pace: pace_of(pct_int, eta_secs, window_secs), + }) +} + +/// tokenline.sh `compute_rate_limits`: Gemini has no rate-limit windows at +/// all; each window is independently absent when its pct is missing. +pub fn compute(s: &Session, now: i64) -> Vec { + if s.is_gemini { + return Vec::new(); + } + [ + s.rl_5h + .as_ref() + .and_then(|raw| rate_limit("5h", raw, 18_000, now)), + s.rl_7d + .as_ref() + .and_then(|raw| rate_limit("7d", raw, 604_800, now)), + ] + .into_iter() + .flatten() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::Client; + + fn session(rl_5h: Option, rl_7d: Option, is_gemini: bool) -> Session { + Session { + model: "test".into(), + used_pct: None, + tokens_limit: None, + transcript_path: String::new(), + session_id: String::new(), + rl_5h, + rl_7d, + cur_input: 0, + cur_output: 0, + cur_cwrite: 0, + cur_cread: 0, + tokens_used: 0, + cli_client: Client::ClaudeCode, + is_gemini, + } + } + + #[test] + fn golden_case() { + let now = 1_783_000_000; + let s = session( + Some(RlRaw { + used_percentage: Some(12.0), + resets_at: Some("2026-07-05T18:00:00Z".into()), + }), + Some(RlRaw { + used_percentage: Some(64.0), + resets_at: Some("2026-07-06T12:00:00Z".into()), + }), + false, + ); + + let windows = compute(&s, now); + assert_eq!(windows.len(), 2); + + let five_h = &windows[0]; + assert_eq!(five_h.label, "5h"); + assert_eq!(five_h.pct, 12); + assert_eq!(five_h.eta_secs, 274_400); + assert_eq!(five_h.bar_filled, 1); + assert_eq!(five_h.bar_empty, 9); + assert_eq!(five_h.severity, Severity::Good); + assert!(!five_h.blink); + assert_eq!(five_h.pace, Pace::None); // pct < 20 + + let seven_d = &windows[1]; + assert_eq!(seven_d.label, "7d"); + assert_eq!(seven_d.pct, 64); + assert_eq!(seven_d.eta_secs, 339_200); + assert_eq!(seven_d.bar_filled, 6); + assert_eq!(seven_d.bar_empty, 4); + assert_eq!(seven_d.severity, Severity::Caution); + assert!(!seven_d.blink); + // elapsed = 604800 - 339200 = 265600, >= window/10 (60480): pace + // = (64 * 604800) / (265600 * 100) = 1.457... -> below 1.5, above 1.25 + assert_eq!(seven_d.pace, Pace::Fast); + } + + #[test] + fn gemini_has_no_windows() { + let s = session( + Some(RlRaw { + used_percentage: Some(50.0), + resets_at: None, + }), + None, + true, + ); + assert!(compute(&s, 0).is_empty()); + } + + #[test] + fn missing_pct_is_absent() { + let s = session( + Some(RlRaw { + used_percentage: None, + resets_at: Some("2026-07-05T18:00:00Z".into()), + }), + None, + false, + ); + assert!(compute(&s, 0).is_empty()); + } + + #[test] + fn pace_thresholds_and_bar_clamp() { + assert_eq!(pace_of(50, 14_400, 18_000), Pace::VeryFast); // pace 2.5 + assert_eq!(pace_of(30, 14_000, 18_000), Pace::Fast); // pace 1.35 + assert_eq!(pace_of(10, 1_000, 18_000), Pace::None); // pct < 20 + assert_eq!(pace_of(50, 0, 18_000), Pace::None); // eta <= 0 + + assert_eq!(bar_of(0), (0, 10)); + assert_eq!(bar_of(50), (5, 5)); + assert_eq!(bar_of(100), (10, 0)); + assert_eq!(bar_of(250), (10, 0)); // clamped + } + + #[test] + fn severity_thresholds() { + assert_eq!(severity_of(10).0, Severity::Good); + assert_eq!(severity_of(30).0, Severity::Warn); + assert_eq!(severity_of(60).0, Severity::Caution); + assert_eq!(severity_of(80).0, Severity::Critical); + assert!(severity_of(95).1); // blink + } +} diff --git a/rust/src/render.rs b/rust/src/render.rs new file mode 100644 index 0000000..567ccf4 --- /dev/null +++ b/rust/src/render.rs @@ -0,0 +1,245 @@ +//! Byte-for-byte composition of the statusline lines, mirroring +//! `tokenline.sh`'s `render_statusline` and its `*_info` builders +//! (tokenline.sh:258-480). This is the only module that touches color/ANSI +//! presentation for cache/ctx/economics/rate-limits — the compute modules +//! stay pure. + +use crate::cachetimer::{self, CacheInfo}; +use crate::economics::{self, Economics}; +use crate::fmt::{color, fmt_eta, fmt_k}; +use crate::input::{Client, RawInput, Session}; +use crate::ratelimits::{self, Pace, RateLimit, Severity}; +use std::path::Path; + +/// Bold 16-color ctx value codes (tokenline.sh:287-289) — deliberately +/// distinct from the 256-color palette in `fmt::color`, so kept as inline +/// literals rather than added to that module. +const CTX_BOLD_RED: &str = "\x1b[01;31m"; +const CTX_BOLD_YELLOW: &str = "\x1b[01;33m"; +const CTX_BOLD_GREEN: &str = "\x1b[01;32m"; + +/// Extra cache-gradient tier (tokenline.sh:265) between GREEN and YELLOW; not +/// one of the named `COLOR_*` constants in bash either, so inline here too. +const CACHE_LIGHT_GREEN: &str = "\x1b[38;5;154m"; + +pub fn render(raw: RawInput, now: i64, cache_dir: &Path) -> String { + let session = Session::from_raw(raw); + let ci = cachetimer::cache_info(&session, now, cache_dir); + let econ = economics::compute(&session, &ci.ttl_label); + let rls = ratelimits::compute(&session, now); + + let mut lines = Vec::new(); + + let mut line1 = match session.cli_client { + Client::Antigravity => format!( + "{}\u{1f30c} Antigravity{} ({})", + color::CYAN, + color::RESET, + session.model + ), + Client::ClaudeCode => session.model.clone(), + }; + if let Some(ctx) = build_ctx_info(&session) { + line1.push_str(" | "); + line1.push_str(&ctx); + } + line1.push_str(" | "); + line1.push_str(&build_cache_info(&ci)); + lines.push(line1); + + if let Some(e) = econ { + lines.push(build_economics_line(&e)); + } + + if !rls.is_empty() { + lines.push(format!( + "{}{}{}", + color::DARK_GRAY, + "\u{2500}".repeat(30), + color::RESET + )); + let line_rl = rls + .iter() + .map(build_rl_segment) + .collect::>() + .join(" "); + lines.push(line_rl); + } + + lines.join("\n") +} + +/// `compute_context_info` (tokenline.sh:283-296). +fn build_ctx_info(s: &Session) -> Option { + let used_pct = s.used_pct?; + // bash `printf '%.0f'`: round half away from zero (used_pct is >= 0 here). + let pct = (used_pct + 0.5).floor() as i64; + let ctx_color = if pct >= 80 { + CTX_BOLD_RED + } else if pct >= 50 { + CTX_BOLD_YELLOW + } else { + CTX_BOLD_GREEN + }; + let gray = color::GRAY; + let reset = color::RESET; + if s.tokens_used > 0 && s.tokens_limit.is_some_and(|l| l > 0) { + let limit = s.tokens_limit.unwrap(); + Some(format!( + "{gray}ctx: {reset}{ctx_color}{}/{} ({pct}%){reset}", + fmt_k(s.tokens_used), + fmt_k(limit) + )) + } else { + Some(format!("{gray}ctx: {reset}{ctx_color}{pct}%{reset}")) + } +} + +/// `compute_cache_timer` display half (tokenline.sh:257-278). `CacheInfo` +/// only exposes 3 coarse `Warmth` buckets, which is too lossy for bash's +/// 5-tier color gradient — recompute `pct10` here from `remaining_secs` and +/// `is_1h` (which pins the ttl to 3600/300) to reproduce it exactly. +fn build_cache_info(ci: &CacheInfo) -> String { + let gray = color::GRAY; + let reset = color::RESET; + if ci.remaining_secs > 0 { + let ttl = if ci.is_1h { 3600 } else { 300 }; + let pct10 = ci.remaining_secs * 10 / ttl; + let mins = ci.remaining_secs / 60; + let secs = ci.remaining_secs % 60; + let fg = if pct10 >= 8 { + color::GREEN.to_string() + } else if pct10 >= 6 { + CACHE_LIGHT_GREEN.to_string() + } else if pct10 >= 4 { + color::YELLOW.to_string() + } else if pct10 >= 2 { + color::ORANGE.to_string() + } else if pct10 >= 1 { + color::RED.to_string() + } else { + format!("{}{}", color::RED, color::BLINK) // blinking red, tokenline.sh:269 + }; + let suffix = if pct10 < 1 { "HOT !" } else { "HOT" }; + format!( + "{gray}[{}] cache: {fg}{mins}:{secs:02} {suffix}{reset}", + ci.ttl_label + ) + } else { + // tokenline.sh:276 hardcodes BLINK before RED (reverse of the tier above). + format!( + "{gray}[{}] cache: {}{}COLD{reset}", + ci.ttl_label, + color::BLINK, + color::RED + ) + } +} + +/// `compute_turn_breakdown` display half (tokenline.sh:432-439). +fn build_economics_line(e: &Economics) -> String { + let gray = color::GRAY; + let reset = color::RESET; + let save_color = if e.saving_pct >= 90 { + color::GREEN + } else if e.saving_pct >= 70 { + color::YELLOW + } else if e.saving_pct >= 50 { + color::ORANGE + } else { + color::RED + }; + format!( + "{gray}read({}): {}{}{reset} {gray}write({}): {}{}{reset} {gray}new({}): {}{}{reset} \ + {gray}output({}): {}{}{reset} {gray}eq: {}{}{reset} {gray}saving: {save_color}{}%{reset}", + e.read_label, + color::CYAN, + fmt_k(e.read), + e.write_label, + color::YELLOW, + fmt_k(e.write), + e.new_label, + color::MAGENTA, + fmt_k(e.new), + e.output_label, + color::GREEN, + fmt_k(e.output), + color::ORANGE, + fmt_k(e.eq), + e.saving_pct, + ) +} + +/// `rl_color_for_pct` (tokenline.sh:302-310): base color, with BLINK +/// appended (RED-then-BLINK order) when `blink` is set. +fn severity_color(sev: Severity, blink: bool) -> String { + let base = match sev { + Severity::Good => color::GREEN, + Severity::Warn => color::YELLOW, + Severity::Caution => color::ORANGE, + Severity::Critical => color::RED, + }; + if blink { + format!("{base}{}", color::BLINK) + } else { + base.to_string() + } +} + +/// `rl_bar` (tokenline.sh:312-328). +fn build_bar(filled: usize, empty: usize, bar_color: &str) -> String { + format!( + "{bar_color}{}{}{}{}", + "\u{2588}".repeat(filled), + color::DARK_GRAY, + "\u{2591}".repeat(empty), + color::RESET + ) +} + +/// `rl_segment` (tokenline.sh:330-376). +fn build_rl_segment(rl: &RateLimit) -> String { + let gray = color::GRAY; + let reset = color::RESET; + let bar_color = severity_color(rl.severity, rl.blink); + let bar = build_bar(rl.bar_filled, rl.bar_empty, &bar_color); + let reset_str = if rl.eta_secs > 0 { + format!(" ({} to reset)", fmt_eta(rl.eta_secs)) + } else { + String::new() + }; + let pace_suffix = match rl.pace { + Pace::None => String::new(), + Pace::Fast => format!(" {}!{reset}", color::ORANGE), + Pace::VeryFast => format!(" {}{}!!{reset}", color::RED, color::BLINK), + }; + format!( + "{gray}{}: {reset}{bar} {bar_color}{}%{reset}{reset_str}{pace_suffix}", + rl.label, rl.pct + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fresh_dir(tag: &str) -> std::path::PathBuf { + let mut d = std::env::temp_dir(); + d.push(format!("tl-render-{}-{}", std::process::id(), tag)); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn golden_healthy_claude_byte_exact() { + let fixture = include_str!("../tests/fixtures/healthy_claude.json"); + let golden = include_str!("../tests/golden/healthy_claude.txt"); + let expected = golden.strip_suffix('\n').unwrap_or(golden); + + let raw: RawInput = serde_json::from_str(fixture).unwrap(); + let dir = fresh_dir("healthy-claude"); + let out = render(raw, 1_783_000_000, &dir); + + assert_eq!(out, expected); + } +} diff --git a/rust/tests/fixtures/healthy_claude.json b/rust/tests/fixtures/healthy_claude.json new file mode 100644 index 0000000..fe0debd --- /dev/null +++ b/rust/tests/fixtures/healthy_claude.json @@ -0,0 +1,13 @@ +{ + "model": {"display_name": "Opus 4.8"}, + "context_window": { + "used_percentage": 9.0, "context_window_size": 1000000, + "current_usage": {"input_tokens": 2, "output_tokens": 2000, + "cache_creation_input_tokens": 2200, "cache_read_input_tokens": 86600} + }, + "transcript_path": "", "session_id": "golden-healthy", + "rate_limits": { + "five_hour": {"used_percentage": 12.0, "resets_at": "2026-07-05T18:00:00Z"}, + "seven_day": {"used_percentage": 64.0, "resets_at": "2026-07-06T12:00:00Z"} + } +} diff --git a/rust/tests/golden/healthy_claude.txt b/rust/tests/golden/healthy_claude.txt new file mode 100644 index 0000000..e75b428 --- /dev/null +++ b/rust/tests/golden/healthy_claude.txt @@ -0,0 +1,4 @@ +Opus 4.8 | ctx: 88.8k/1.0M (9%) | [5m] cache: 5:00 HOT +read(0.1x): 86.6k write(1.25x): 2.2k new(1x): 2 output(5x): 2.0k eq: 21.4k saving: 78% +────────────────────────────── +5h: █░░░░░░░░░ 12% (3d4h to reset) 7d: ██████░░░░ 64% (3d22h to reset) ! diff --git a/rust/tests/never_crash.rs b/rust/tests/never_crash.rs new file mode 100644 index 0000000..5421ce6 --- /dev/null +++ b/rust/tests/never_crash.rs @@ -0,0 +1,34 @@ +//! The one hard contract of the binary: whatever the host pipes in, tokenline +//! exits 0 and never disturbs the host. These drive the real compiled binary. +use std::io::Write; +use std::process::{Command, Stdio}; + +fn run_with(stdin: &str) -> std::process::Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_tokenline")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn tokenline"); + child + .stdin + .take() + .unwrap() + .write_all(stdin.as_bytes()) + .unwrap(); + child.wait_with_output().expect("wait tokenline") +} + +#[test] +fn malformed_json_is_a_silent_noop() { + let out = run_with("this is not json"); + assert!(out.status.success(), "exit 0 on garbage"); + assert!(out.stdout.is_empty(), "no output on garbage"); +} + +#[test] +fn empty_stdin_is_a_silent_noop() { + let out = run_with(""); + assert!(out.status.success(), "exit 0 on empty stdin"); + assert!(out.stdout.is_empty(), "no output on empty stdin"); +}