diff --git a/.gitignore b/.gitignore index ea8c4bf..10f3a10 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ /target + +# 含真实会话数据的本地样本 +/samples/real-status.json diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e1597e1 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,384 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[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 = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[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 = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[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 = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "statusline" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "serde_json", +] + +[[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 = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..55ff5be --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "statusline" +version = "0.1.0" +edition = "2024" + +[dependencies] +chrono = "0.4.45" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" diff --git a/samples/example-status.json b/samples/example-status.json new file mode 100644 index 0000000..c2e5a17 --- /dev/null +++ b/samples/example-status.json @@ -0,0 +1,53 @@ +{ + "hook_event_name": "Status", + "session_id": "abc123...", + "transcript_path": "/path/to/transcript.json", + "cwd": "/current/working/directory", + "model": { + "id": "claude-opus-4-6[1m]", + "display_name": "Opus 4.6 (1M context)" + }, + "workspace": { + "current_dir": "/current/working/directory", + "project_dir": "/original/project/directory", + "added_dirs": [] + }, + "version": "2.1.80", + "output_style": { + "name": "default" + }, + "cost": { + "total_cost_usd": 0.01234, + "total_duration_ms": 45000, + "total_api_duration_ms": 2300, + "total_lines_added": 156, + "total_lines_removed": 23 + }, + "context_window": { + "total_input_tokens": 50113, + "total_output_tokens": 10462, + "context_window_size": 1000000, + "current_usage": { + "input_tokens": 8500, + "output_tokens": 1200, + "cache_creation_input_tokens": 5000, + "cache_read_input_tokens": 2000 + }, + "used_percentage": 8, + "remaining_percentage": 92 + }, + "exceeds_200k_tokens": false, + "rate_limits": { + "five_hour": { + "used_percentage": 42, + "resets_at": 1774020000 + }, + "seven_day": { + "used_percentage": 15, + "resets_at": 1774540000 + } + }, + "vim": { + "mode": "NORMAL" + } +} diff --git a/src/color.rs b/src/color.rs new file mode 100644 index 0000000..d051de4 --- /dev/null +++ b/src/color.rs @@ -0,0 +1,40 @@ +//! ANSI 颜色工具(24-bit truecolor 前景色)。 +//! +//! 终端配置 colorLevel:3,支持 truecolor。每个组件按需调用 [`fg`] 上色。 + +/// 用 truecolor 前景色包裹文本,结尾复位颜色。 +pub fn fg(text: &str, r: u8, g: u8, b: u8) -> String { + format!("\x1b[38;2;{r};{g};{b}m{text}\x1b[39m") +} + +/// 热度渐变色:`t` 从 0→1 走 绿 → 黄 → 橙 → 红,颜色偏柔和。 +/// `t` 超出 [0,1] 自动夹取。 +pub fn heat(t: f64) -> (u8, u8, u8) { + // 多段锚点:位置 + RGB(已降亮度、轻微去饱和,避免刺眼)。 + const STOPS: [(f64, (u8, u8, u8)); 4] = [ + (0.00, (120, 215, 125)), // 柔绿 + (0.34, (230, 220, 110)), // 柔黄 + (0.67, (235, 165, 95)), // 柔橙 + (1.00, (228, 105, 105)), // 柔红 + ]; + + let t = t.clamp(0.0, 1.0); + // 找到 t 落在的那一段。 + let mut i = 0; + while i + 1 < STOPS.len() && t > STOPS[i + 1].0 { + i += 1; + } + let (t0, c0) = STOPS[i]; + let (t1, c1) = STOPS[(i + 1).min(STOPS.len() - 1)]; + let k = if (t1 - t0).abs() < f64::EPSILON { + 0.0 + } else { + (t - t0) / (t1 - t0) + }; + (lerp(c0.0, c1.0, k), lerp(c0.1, c1.1, k), lerp(c0.2, c1.2, k)) +} + +/// 在 a、b 之间按比例 k∈[0,1] 线性插值。 +fn lerp(a: u8, b: u8, k: f64) -> u8 { + (a as f64 + (b as f64 - a as f64) * k).round() as u8 +} diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..524f930 --- /dev/null +++ b/src/input.rs @@ -0,0 +1,29 @@ +//! 输入层:读取 Claude Code 通过 stdin 管道传入的状态文本。 +//! +//! 这一层只关心「怎么把字节拿进来」,不负责解析 JSON——解析是 status 层的职责。 + +use std::io::{self, IsTerminal, Read}; + +/// stdin 的读取结果,用类型区分两种运行场景。 +#[derive(Debug)] +pub enum Input { + /// 从管道读到了状态文本(Claude Code 调用时的情形)。 + Piped(String), + /// stdin 连着终端、没有管道输入,应进入交互模式。 + Interactive, +} + +/// 读取标准输入。 +/// +/// - stdin 是 TTY → [`Input::Interactive`] +/// - 有管道输入 → [`Input::Piped`](可能为空串,是否为空交给调用方判断) +pub fn read() -> io::Result { + let stdin = io::stdin(); + if stdin.is_terminal() { + return Ok(Input::Interactive); + } + + let mut buf = String::new(); + stdin.lock().read_to_string(&mut buf)?; + Ok(Input::Piped(buf)) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..06c1731 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,38 @@ +mod color; +mod input; +mod preview; +mod sources; +mod status; +mod widgets; + +use input::Input; + +fn main() { + // `statusline test` 显示所有组件预览后退出;否则走默认的状态栏渲染。 + if std::env::args().nth(1).as_deref() == Some("test") { + preview::run(); + return; + } + + match input::read() { + Ok(Input::Piped(text)) => match status::parse(&text) { + Ok(status) => { + println!("{}", widgets::render(&status)); + // 第二行空占位:盲文空白 U+2800,看着是空的但属可打印字符, + // 不会被 Claude 当成空白行裁掉。 + println!("\u{2800}"); + } + Err(e) => { + eprintln!("invalid status JSON: {e}"); + std::process::exit(1); + } + }, + Ok(Input::Interactive) => { + eprintln!("no piped input (interactive mode not implemented yet)"); + } + Err(e) => { + eprintln!("failed to read stdin: {e}"); + std::process::exit(1); + } + } +} diff --git a/src/preview.rs b/src/preview.rs new file mode 100644 index 0000000..ae9a006 --- /dev/null +++ b/src/preview.rs @@ -0,0 +1,125 @@ +//! `statusline test <目标>` 子命令:在终端预览各组件效果。 + +use crate::sources::git::GitInfo; +use crate::sources::transcript::Speed; +use crate::widgets; + +/// `test`:依次显示所有组件的预览。 +pub fn run() { + header("usage"); + usage(); + header("git"); + git(); + header("speed"); + speed(); + header("model"); + model(); + header("rate"); + rate(); + header("dir"); + dir(); +} + +/// 打印分节标题。 +fn header(name: &str) { + println!("\n=== {name} ==="); +} + +/// 预览上下文百分比 1% → 100% 的渐变色,每行 10 个。 +fn usage() { + for pct in 1..=100 { + print!("{} ", widgets::usage_color(pct as f64)); + if pct % 10 == 0 { + println!(); + } + } +} + +/// 用合成数据预览 git 段的所有显示情况与配色。 +fn git() { + // 非仓库时 render 直接返回 "-",这里单列。 + println!("{} -", pad("非仓库", 12)); + + let samples = [ + ("干净", GitInfo { branch: "main".into(), changed_files: 0, insertions: 0, deletions: 0 }), + ("仅改文件", GitInfo { branch: "main".into(), changed_files: 3, insertions: 0, deletions: 0 }), + ("改文件+增", GitInfo { branch: "dev".into(), changed_files: 3, insertions: 128, deletions: 0 }), + ("改文件+删", GitInfo { branch: "hotfix".into(), changed_files: 2, insertions: 0, deletions: 42 }), + ("增删都有", GitInfo { branch: "feature/login".into(), changed_files: 5, insertions: 128, deletions: 17 }), + ]; + for (label, info) in &samples { + println!("{} {}", pad(label, 12), widgets::git_preview(info)); + } +} + +/// 用合成数据预览 token 速度的各种情况。 +fn speed() { + let samples = [ + ("正常", Speed { input_per_sec: Some(2.7), output_per_sec: Some(134.4) }), + ("高速(k)", Speed { input_per_sec: Some(1234.0), output_per_sec: Some(3456.0) }), + ("缺输入", Speed { input_per_sec: None, output_per_sec: Some(88.5) }), + ("无数据", Speed::EMPTY), + ]; + for (label, speed) in &samples { + println!("{} {}", pad(label, 12), widgets::speed_preview(speed)); + } +} + +/// 预览模型(思考) 合并格式。 +fn model() { + println!("思考等级(模型固定 Opus 4.8):"); + for (label, level) in [ + ("low", "low"), + ("medium", "medium"), + ("high", "high"), + ("xhigh", "xhigh"), + ("缺失/未知", ""), + ] { + println!(" {} {}", pad(label, 10), widgets::model_preview("Opus 4.8 (1M context)", level)); + } + + println!("模型名(思考固定 high):"); + for (label, name) in [ + ("Opus", "Opus 4.8 (1M context)"), + ("Sonnet", "Sonnet 4.6"), + ("Haiku", "Haiku 4.5"), + ("Fable", "Fable 5"), + ("缺失", "-"), + ] { + println!(" {} {}", pad(label, 10), widgets::model_preview(name, "high")); + } +} + +/// 用合成数据预览额度的各种情况。 +fn rate() { + let samples = [ + ("正常", Some(10.0), Some(40.0), Some(6300i64)), + ("接近满", Some(92.0), Some(78.0), Some(900)), + ("无倒计时", Some(10.0), Some(40.0), None), + ("缺数据", None, None, None), + ]; + for (label, five, seven, secs) in samples { + println!("{} {}", pad(label, 10), widgets::rate_preview(five, seven, secs)); + } +} + +/// 用合成数据预览目录显示。 +fn dir() { + let samples = [ + ("项目根", "/path/to/project", true), + ("子目录", "/path/to/project/src/widgets", false), + ("非项目", "/some/other/dir", false), + ]; + for (label, path, at_root) in samples { + println!("{} {}", pad(label, 10), widgets::dir_preview(path, at_root)); + } +} + +/// 把文本按显示列宽右侧补空格对齐(中文字符算 2 列)。 +fn pad(text: &str, width: usize) -> String { + let shown: usize = text + .chars() + .map(|c| if (c as u32) >= 0x1100 { 2 } else { 1 }) + .sum(); + format!("{text}{}", " ".repeat(width.saturating_sub(shown))) +} diff --git a/src/sources/git.rs b/src/sources/git.rs new file mode 100644 index 0000000..eedc1db --- /dev/null +++ b/src/sources/git.rs @@ -0,0 +1,77 @@ +//! Git 相关信息:通过执行 `git` 命令获取。 + +use std::process::Command; + +/// 一个仓库的状态汇总。 +pub struct GitInfo { + pub branch: String, + /// 改动文件数(含未跟踪),来自 `git status --porcelain`。 + pub changed_files: usize, + /// 工作区相对 HEAD 的增、删行数,来自 `git diff HEAD --numstat`。 + pub insertions: u64, + pub deletions: u64, +} + +/// 汇总当前仓库状态。不在 git 仓库 / detached HEAD / 没装 git 时返回 None。 +pub fn info(cwd: Option<&str>) -> Option { + let branch = branch(cwd)?; + let (insertions, deletions) = diff_lines(cwd); + Some(GitInfo { + branch, + changed_files: changed_files(cwd), + insertions, + deletions, + }) +} + +/// 当前分支名。不在仓库 / detached HEAD / 没装 git 时返回 None。 +fn branch(cwd: Option<&str>) -> Option { + let out = git(cwd, &["branch", "--show-current"])?; + let name = out.trim().to_string(); + if name.is_empty() { + None + } else { + Some(name) + } +} + +/// 改动文件数:`git status --porcelain` 的非空行数。 +fn changed_files(cwd: Option<&str>) -> usize { + git(cwd, &["status", "--porcelain"]) + .map(|o| o.lines().filter(|l| !l.trim().is_empty()).count()) + .unwrap_or(0) +} + +/// 增删行数:累加 `git diff HEAD --numstat` 每行的前两列。 +fn diff_lines(cwd: Option<&str>) -> (u64, u64) { + let Some(out) = git(cwd, &["diff", "HEAD", "--numstat"]) else { + return (0, 0); + }; + let mut ins = 0; + let mut del = 0; + for line in out.lines() { + let mut cols = line.split('\t'); + if let (Some(a), Some(b)) = (cols.next(), cols.next()) { + // 二进制文件这两列是 "-",parse 失败按 0 处理。 + ins += a.parse::().unwrap_or(0); + del += b.parse::().unwrap_or(0); + } + } + (ins, del) +} + +/// 在指定目录跑一条 git 命令,成功则返回 stdout。 +fn git(cwd: Option<&str>, args: &[&str]) -> Option { + let mut cmd = Command::new("git"); + if let Some(dir) = cwd { + cmd.args(["-C", dir]); + } + cmd.args(args); + + let out = cmd.output().ok()?; + if out.status.success() { + Some(String::from_utf8_lossy(&out.stdout).into_owned()) + } else { + None + } +} diff --git a/src/sources/mod.rs b/src/sources/mod.rs new file mode 100644 index 0000000..06ee4be --- /dev/null +++ b/src/sources/mod.rs @@ -0,0 +1,4 @@ +//! 数据获取层:从外部来源(git 命令、转录文件)取状态栏需要的数据。 + +pub mod git; +pub mod transcript; diff --git a/src/sources/transcript.rs b/src/sources/transcript.rs new file mode 100644 index 0000000..14403ab --- /dev/null +++ b/src/sources/transcript.rs @@ -0,0 +1,168 @@ +//! 从转录 JSONL 计算 token 速度。 +//! +//! 速度 = token 数 ÷ 实际生成时长,只统计**最近 N 次请求**(滑动窗口)。 +//! "实际生成时长" = 每次请求 [上一条 user 时间戳 → 这条 assistant 时间戳] 区间, +//! 合并重叠后求和(避免子代理并发时重复计时)。 + +use std::fs; + +use chrono::DateTime; +use serde::Deserialize; + +/// 回看最近多少次请求。 +const WINDOW: usize = 8; + +/// 一次 token 速度结果,单位 token/秒;无数据为 None。 +pub struct Speed { + pub input_per_sec: Option, + pub output_per_sec: Option, +} + +impl Speed { + pub const EMPTY: Speed = Speed { + input_per_sec: None, + output_per_sec: None, + }; +} + +/// 转录文件里关心的字段,其余忽略。 +#[derive(Deserialize)] +struct Line { + #[serde(rename = "type")] + kind: Option, + timestamp: Option, + #[serde(rename = "isApiErrorMessage")] + is_api_error: Option, + message: Option, +} + +#[derive(Deserialize)] +struct Message { + usage: Option, +} + +#[derive(Deserialize)] +struct Usage { + #[serde(default)] + input_tokens: u64, + #[serde(default)] + cache_creation_input_tokens: u64, + #[serde(default)] + cache_read_input_tokens: u64, + #[serde(default)] + output_tokens: u64, +} + +impl Usage { + /// 真实处理的输入 token = 新输入 + 缓存创建 + 缓存读取。 + fn total_input(&self) -> u64 { + self.input_tokens + self.cache_creation_input_tokens + self.cache_read_input_tokens + } +} + +/// 一次请求(一条带 usage 的 assistant 消息)。 +struct Request { + input: u64, + output: u64, + /// 生成时间区间 (start_ms, end_ms),缺时间戳时为 None。 + interval: Option<(i64, i64)>, +} + +/// 计算最近 [`WINDOW`] 次请求的输入/输出速度。 +pub fn speed(path: &str) -> Speed { + let Ok(content) = fs::read_to_string(path) else { + return Speed::EMPTY; + }; + + let mut requests: Vec = Vec::new(); + let mut last_user_ms: Option = None; + + for line in content.lines() { + let Ok(data) = serde_json::from_str::(line) else { + continue; + }; + if data.is_api_error == Some(true) { + continue; + } + let ts_ms = data.timestamp.as_deref().and_then(parse_ms); + + match data.kind.as_deref() { + Some("user") => { + if let Some(ms) = ts_ms { + last_user_ms = Some(ms); + } + } + Some("assistant") => { + let Some(usage) = data.message.and_then(|m| m.usage) else { + continue; + }; + let interval = match (last_user_ms, ts_ms) { + (Some(start), Some(end)) if end > start => Some((start, end)), + _ => None, + }; + requests.push(Request { + input: usage.total_input(), + output: usage.output_tokens, + interval, + }); + } + _ => {} + } + } + + // 取最近 WINDOW 次请求。 + let start = requests.len().saturating_sub(WINDOW); + let window = &requests[start..]; + if window.is_empty() { + return Speed::EMPTY; + } + + let mut input = 0; + let mut output = 0; + let mut intervals: Vec<(i64, i64)> = Vec::new(); + for r in window { + input += r.input; + output += r.output; + if let Some(iv) = r.interval { + intervals.push(iv); + } + } + + let duration_ms = merged_duration_ms(intervals); + if duration_ms == 0 { + return Speed::EMPTY; + } + + let secs = duration_ms as f64 / 1000.0; + Speed { + input_per_sec: Some(input as f64 / secs), + output_per_sec: Some(output as f64 / secs), + } +} + +/// 解析 RFC3339 时间戳为毫秒。 +fn parse_ms(ts: &str) -> Option { + DateTime::parse_from_rfc3339(ts) + .ok() + .map(|dt| dt.timestamp_millis()) +} + +/// 合并重叠区间后,求总时长(毫秒)。 +fn merged_duration_ms(mut intervals: Vec<(i64, i64)>) -> i64 { + if intervals.is_empty() { + return 0; + } + intervals.sort_by_key(|iv| iv.0); + + let mut total = 0; + let (mut cur_start, mut cur_end) = intervals[0]; + for &(s, e) in &intervals[1..] { + if s <= cur_end { + cur_end = cur_end.max(e); + } else { + total += cur_end - cur_start; + (cur_start, cur_end) = (s, e); + } + } + total + (cur_end - cur_start) +} diff --git a/src/status.rs b/src/status.rs new file mode 100644 index 0000000..66a923d --- /dev/null +++ b/src/status.rs @@ -0,0 +1,83 @@ +//! Claude Code 通过 stdin 传入的状态快照(`hook_event_name: "Status"`)。 +//! +//! 只建模状态栏实际用得到的字段;JSON 里的其它字段被 serde 自动忽略。 +//! 字段普遍 `Option`,因为这是外部数据,任何一项都可能缺失。 + +use serde::Deserialize; + +/// 一次状态栏刷新所对应的状态快照。 +#[derive(Debug, Clone, Deserialize)] +pub struct Status { + /// 当前模型(`model` 组件)。 + pub model: Option, + /// 思考强度(`effort.level`,如 high / xhigh)。 + pub effort: Option, + /// 上下文窗口用量(`context-percentage` 组件)。 + pub context_window: Option, + /// 额度限制与重置时间(`reset-timer` 组件)。 + pub rate_limits: Option, + /// 转录文件路径,后续读 jsonl 算 token/speed 用。 + pub transcript_path: Option, + /// 当前工作目录(git 定位等备用)。 + pub cwd: Option, + /// 工作区目录(`dir` 组件:当前目录 / 项目根目录)。 + pub workspace: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Workspace { + pub current_dir: Option, + pub project_dir: Option, +} + +/// `model` 字段:可能是裸字符串,也可能是 `{ id, display_name }` 对象。 +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum Model { + Name(String), + Detailed { + id: Option, + display_name: Option, + }, +} + +impl Model { + /// 用于显示的名字:优先 `display_name`,退而求其次用 `id`,再不行用裸字符串。 + pub fn display_name(&self) -> Option<&str> { + match self { + Model::Name(s) => Some(s), + Model::Detailed { id, display_name } => display_name.as_deref().or(id.as_deref()), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Effort { + pub level: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ContextWindow { + pub used_percentage: Option, + pub total_input_tokens: Option, + pub total_output_tokens: Option, + pub context_window_size: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RateLimits { + pub five_hour: Option, + pub seven_day: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RateLimitPeriod { + pub used_percentage: Option, + /// 重置时刻,Unix 时间戳(秒)。 + pub resets_at: Option, +} + +/// 把状态 JSON 文本解析成 [`Status`]。 +pub fn parse(text: &str) -> Result { + serde_json::from_str(text) +} diff --git a/src/widgets/context.rs b/src/widgets/context.rs new file mode 100644 index 0000000..dc2b484 --- /dev/null +++ b/src/widgets/context.rs @@ -0,0 +1,36 @@ +//! 上下文 token 已使用百分比,颜色随用量从绿渐变到红(80% 即纯红)。 + +use crate::color; +use crate::status::{ContextWindow, Status}; + +/// 80% 时到纯红,之后保持红。 +const RED_AT: f64 = 80.0; + +/// 如 `8.9%`(带渐变色);数据缺失时 `--%`。 +pub fn render(status: &Status) -> String { + let Some(cw) = status.context_window.as_ref() else { + return "--%".to_string(); + }; + // 优先用 token 数自算(带真实小数),拿不到再退回 Claude 给的整数百分比。 + match precise_pct(cw).or(cw.used_percentage) { + Some(pct) => colored(pct), + None => "--%".to_string(), + } +} + +/// 用 token 数 ÷ 窗口大小算精确百分比;字段缺失时 None。 +fn precise_pct(cw: &ContextWindow) -> Option { + let size = cw.context_window_size?; + if size == 0 { + return None; + } + let used = cw.total_input_tokens? + cw.total_output_tokens.unwrap_or(0); + Some(used as f64 / size as f64 * 100.0) +} + +/// 把百分比按渐变色上色,如 `8.9%`。供渲染与预览命令共用。 +pub fn colored(pct: f64) -> String { + let t = pct.min(RED_AT) / RED_AT; + let (r, g, b) = color::heat(t); + color::fg(&format!("{pct:.1}%"), r, g, b) +} diff --git a/src/widgets/dir.rs b/src/widgets/dir.rs new file mode 100644 index 0000000..d44f030 --- /dev/null +++ b/src/widgets/dir.rs @@ -0,0 +1,42 @@ +//! 当前目录:在项目根则只显示目录名,否则显示末两级 `parent/current`。 +//! +//! 是否在根目录靠 `workspace.current_dir == project_dir` 判断,不依赖 git。 + +use crate::color; +use crate::status::Status; + +/// 固定配色:柔白/浅蓝灰。 +const COLOR: (u8, u8, u8) = (195, 200, 210); + +/// 在项目根显示 `name`,否则 `parent/current`;无数据时 `-`。 +pub fn render(status: &Status) -> String { + let ws = status.workspace.as_ref(); + let current = ws + .and_then(|w| w.current_dir.as_deref()) + .or(status.cwd.as_deref()); + let Some(current) = current else { + return "-".to_string(); + }; + let project = ws.and_then(|w| w.project_dir.as_deref()); + let at_root = project.is_some_and(|p| same_path(p, current)); + + format_dir(current, at_root) +} + +/// 在根目录取末级目录名,否则取末两级,并上色。供渲染与 `test` 预览共用。 +pub fn format_dir(path: &str, at_root: bool) -> String { + let comps: Vec<&str> = path.split(['/', '\\']).filter(|s| !s.is_empty()).collect(); + let text = match comps.as_slice() { + [] => return "-".to_string(), + [.., parent, last] if !at_root => format!("{parent}/{last}"), + [.., last] => last.to_string(), + }; + let (r, g, b) = COLOR; + color::fg(&text, r, g, b) +} + +/// 路径比较:统一分隔符、去尾部斜杠。 +fn same_path(a: &str, b: &str) -> bool { + let norm = |s: &str| s.replace('\\', "/").trim_end_matches('/').to_string(); + norm(a) == norm(b) +} diff --git a/src/widgets/git.rs b/src/widgets/git.rs new file mode 100644 index 0000000..48f4ecd --- /dev/null +++ b/src/widgets/git.rs @@ -0,0 +1,45 @@ +//! Git 段:分支 + 改动文件数 + 增删行数。 +//! +//! 配色:分支=柔紫,脏标记/文件数=柔黄,+增=柔绿,-删=柔红。 + +use crate::color; +use crate::sources::git::{self, GitInfo}; +use crate::status::Status; + +const BRANCH: (u8, u8, u8) = (175, 150, 215); // 柔紫 +const DIRTY: (u8, u8, u8) = (230, 200, 110); // 柔黄 +const ADD: (u8, u8, u8) = (120, 215, 125); // 柔绿 +const DEL: (u8, u8, u8) = (228, 105, 105); // 柔红 + +/// 如 `master ~3 +45 -12`;干净时只显示分支名;不在仓库时 `-`。 +pub fn render(status: &Status) -> String { + match git::info(status.cwd.as_deref()) { + Some(info) => format_info(&info), + None => "-".to_string(), + } +} + +/// 把 git 信息上色拼成字符串。供渲染与 `test git` 预览共用。 +pub fn format_info(info: &GitInfo) -> String { + let (r, g, b) = BRANCH; + let mut s = color::fg(&info.branch, r, g, b); + + // 有改动:附柔黄的改动文件数。 + if info.changed_files > 0 { + let (r, g, b) = DIRTY; + s.push(' '); + s.push_str(&color::fg(&format!("~{}", info.changed_files), r, g, b)); + } + + if info.insertions > 0 { + let (r, g, b) = ADD; + s.push(' '); + s.push_str(&color::fg(&format!("+{}", info.insertions), r, g, b)); + } + if info.deletions > 0 { + let (r, g, b) = DEL; + s.push(' '); + s.push_str(&color::fg(&format!("-{}", info.deletions), r, g, b)); + } + s +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs new file mode 100644 index 0000000..99fd780 --- /dev/null +++ b/src/widgets/mod.rs @@ -0,0 +1,64 @@ +//! 渲染层:按固定顺序把各组件拼成状态栏。 +//! +//! 一个组件一个文件。新增组件 = 新建文件 + 在此声明并塞进 `render` 的 `parts`。 + +mod context; +mod dir; +mod git; +mod model; +mod rate; +mod speed; + +use crate::status::Status; + +/// 组件之间的分隔符:加粗的 `|`,灰色。 +/// `1m`=加粗,`38;2;130;130;130`=灰前景,结尾 `22m`/`39m` 复位加粗与前景。 +const SEP: &str = "\x1b[1m\x1b[38;2;130;130;130m | \x1b[22m\x1b[39m"; + +/// 组内小分隔符:暗灰 `·`(rate 的倒计时用)。 +const DOT: &str = "\x1b[38;2;90;90;90m · \x1b[39m"; + +/// 渲染整条状态栏。 +pub fn render(status: &Status) -> String { + // 模型与百分比之间不用 `|`,留两个空格即可。 + let model_pct = format!("{} {}", model::render(status), context::render(status)); + + let parts = [ + model_pct, + dir::render(status), + git::render(status), + speed::render(status), + rate::render(status), + ]; + parts.join(SEP) +} + +/// 给定已用百分比,返回带渐变色的字符串(供 `test usage` 预览复用)。 +pub fn usage_color(pct: f64) -> String { + context::colored(pct) +} + +/// 把 git 信息上色(供 `test git` 预览复用)。 +pub fn git_preview(info: &crate::sources::git::GitInfo) -> String { + git::format_info(info) +} + +/// 把速度格式化(供 `test speed` 预览复用)。 +pub fn speed_preview(speed: &crate::sources::transcript::Speed) -> String { + speed::display(speed) +} + +/// 模型(思考) 合并格式化(供 `test model` 预览复用)。 +pub fn model_preview(name: &str, level: &str) -> String { + model::combined(name, level) +} + +/// 额度格式化(供 `test rate` 预览复用)。 +pub fn rate_preview(five: Option, seven: Option, countdown_secs: Option) -> String { + rate::display(five, seven, countdown_secs) +} + +/// 目录格式化(供 `test` 预览复用)。 +pub fn dir_preview(path: &str, at_root: bool) -> String { + dir::format_dir(path, at_root) +} diff --git a/src/widgets/model.rs b/src/widgets/model.rs new file mode 100644 index 0000000..48fa2a9 --- /dev/null +++ b/src/widgets/model.rs @@ -0,0 +1,64 @@ +//! 思考 + 模型,如 `H opus-4.8`,等级按等级配色、模型名柔蓝。 + +use crate::color; +use crate::status::Status; + +/// 固定配色:柔蓝。 +const COLOR: (u8, u8, u8) = (130, 170, 230); + +/// 如 `H opus-4.8`;思考缺失时只剩 `opus-4.8`;模型缺失时 `-`。 +pub fn render(status: &Status) -> String { + let name = status + .model + .as_ref() + .and_then(|m| m.display_name()) + .unwrap_or("-"); + let level = status + .effort + .as_ref() + .and_then(|e| e.level.as_deref()) + .unwrap_or(""); + combined(name, level) +} + +/// 把思考等级与模型名拼成 `H opus-4.8` 并上色: +/// 等级按等级配色在前,模型名柔蓝在后。供渲染与 `test` 预览共用。 +pub fn combined(name: &str, level: &str) -> String { + let (r, g, b) = COLOR; + let model = color::fg(&name_format(name), r, g, b); + + let think = thinking_format(level); + if think == "-" { + return model; + } + let (r, g, b) = thinking_color(level); + format!("{} {model}", color::fg(think, r, g, b)) +} + +/// 模型名规范化:去 ` (...)` 后缀 → 小写 → 空格转 `-`,如 `Opus 4.8 (1M context)` → `opus-4.8`。 +fn name_format(name: &str) -> String { + let stripped = name.split(" (").next().unwrap_or(name).trim(); + stripped.to_lowercase().replace(' ', "-") +} + +/// 思考等级简写:low→L, medium→M, high→H, xhigh→XH;缺失/未知 `-`。 +fn thinking_format(level: &str) -> &'static str { + match level { + "low" => "L", + "medium" => "M", + "high" => "H", + "xhigh" => "XH", + _ => "-", + } +} + +/// 思考等级配色:越高越「热」。L 绿 → M 黄 → H 橙 → XH 红。 +fn thinking_color(level: &str) -> (u8, u8, u8) { + match level { + "low" => (120, 200, 140), // 柔绿 + "medium" => (220, 210, 110), // 柔黄 + "high" => (235, 165, 95), // 柔橙 + "xhigh" => (228, 105, 105), // 柔红 + _ => (150, 150, 150), // 灰(兜底,正常走不到) + } +} diff --git a/src/widgets/rate.rs b/src/widgets/rate.rs new file mode 100644 index 0000000..687cc92 --- /dev/null +++ b/src/widgets/rate.rs @@ -0,0 +1,63 @@ +//! 额度:5h 已用 / 7 天已用 + 5h 刷新倒计时,如 `10% / 40% 1h45min`。 +//! 百分比按用量绿→红渐变(80% 到红)。 + +use chrono::Local; + +use crate::color; +use crate::status::Status; + +/// 百分比 80% 到纯红,之后保持红(与上下文一致)。 +const RED_AT: f64 = 80.0; + +/// 5h 窗口秒数,用于倒计时渐变。 +const FIVE_HOUR_SECS: f64 = 5.0 * 3600.0; + +/// 如 `10.0% / 40.0% 1h45min`;无数据时 `-`。 +pub fn render(status: &Status) -> String { + let Some(rl) = status.rate_limits.as_ref() else { + return "-".to_string(); + }; + + let five = rl.five_hour.as_ref().and_then(|p| p.used_percentage); + let seven = rl.seven_day.as_ref().and_then(|p| p.used_percentage); + let secs = rl + .five_hour + .as_ref() + .and_then(|p| p.resets_at) + .map(|reset| reset - Local::now().timestamp()); + + display(five, seven, secs) +} + +/// 把 5h 已用、7 天已用、5h 剩余秒数格式化。供渲染与 `test rate` 预览共用。 +pub fn display(five: Option, seven: Option, countdown_secs: Option) -> String { + let mut s = format!("{} / {}", pct(five), pct(seven)); + if let Some(secs) = countdown_secs { + // 越接近刷新(剩余越少)越红:t = 已过 / 5h。 + let t = (FIVE_HOUR_SECS - secs as f64) / FIVE_HOUR_SECS; + let (r, g, b) = color::heat(t); + s.push_str(super::DOT); + s.push_str(&color::fg(&countdown(secs), r, g, b)); + } + s +} + +/// 整数百分比,按用量绿→红渐变;缺失时 `--%`。 +fn pct(value: Option) -> String { + match value { + Some(p) => { + let t = p.min(RED_AT) / RED_AT; + let (r, g, b) = color::heat(t); + color::fg(&format!("{}%", p.round() as i64), r, g, b) + } + None => "--%".to_string(), + } +} + +/// 秒数 → `1:45`(时:分,分钟补零)。 +fn countdown(secs: i64) -> String { + let secs = secs.max(0); + let h = secs / 3600; + let m = (secs % 3600) / 60; + format!("{h}:{m:02}") +} diff --git a/src/widgets/speed.rs b/src/widgets/speed.rs new file mode 100644 index 0000000..0049aa0 --- /dev/null +++ b/src/widgets/speed.rs @@ -0,0 +1,44 @@ +//! token 速度:`⇅ 输入/输出 t/s`,柔青色。 + +use crate::color; +use crate::sources::transcript::{self, Speed}; +use crate::status::Status; + +/// 输入 / 输出箭头。 +const UP: &str = "↑"; +const DOWN: &str = "↓"; +/// 固定配色:柔青。 +const COLOR: (u8, u8, u8) = (110, 195, 200); + +/// 读取转录、算速度并格式化,如 `↑2.7 ↓134.4 t/s`。 +pub fn render(status: &Status) -> String { + display(&compute(status)) +} + +/// 把速度格式化成 `↑输入 ↓输出 t/s` 并上色。供渲染与 `test token` 预览共用。 +pub fn display(speed: &Speed) -> String { + let text = format!( + "{UP} {} {DOWN} {} t/s", + fmt(speed.input_per_sec), + fmt(speed.output_per_sec) + ); + let (r, g, b) = COLOR; + color::fg(&text, r, g, b) +} + +/// 读取转录算速度;无转录路径时返回空。 +fn compute(status: &Status) -> Speed { + match status.transcript_path.as_deref() { + Some(path) => transcript::speed(path), + None => Speed::EMPTY, + } +} + +/// 只返回数字部分:`42.5`,≥1000 显示 `1.2k`,无数据显示 `—`。单位由调用方统一加。 +fn fmt(per_sec: Option) -> String { + match per_sec { + None => "—".to_string(), + Some(v) if v >= 1000.0 => format!("{:.1}k", v / 1000.0), + Some(v) => format!("{v:.1}"), + } +}