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}"),
+ }
+}