feat: implement Rust statusline for Claude Code

- parse status JSON from stdin and render a single status line
- widgets: model+thinking, context %, directory, git, token speed, rate limits
- coloring: green-to-red gradient plus fixed per-widget colors
- `test` subcommand previews all widgets with synthetic data
- braille-blank placeholder on the second line
This commit is contained in:
2026-06-25 18:57:39 +08:00
Unverified
parent 10101c6627
commit 640e1348aa
19 changed files with 1371 additions and 0 deletions
+3
View File
@@ -1 +1,4 @@
/target
# 含真实会话数据的本地样本
/samples/real-status.json
Generated
+384
View File
@@ -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"
+9
View File
@@ -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"
+53
View File
@@ -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"
}
}
+40
View File
@@ -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
}
+29
View File
@@ -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<Input> {
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))
}
+38
View File
@@ -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);
}
}
}
+125
View File
@@ -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)))
}
+77
View File
@@ -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<GitInfo> {
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<String> {
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::<u64>().unwrap_or(0);
del += b.parse::<u64>().unwrap_or(0);
}
}
(ins, del)
}
/// 在指定目录跑一条 git 命令,成功则返回 stdout。
fn git(cwd: Option<&str>, args: &[&str]) -> Option<String> {
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
}
}
+4
View File
@@ -0,0 +1,4 @@
//! 数据获取层:从外部来源(git 命令、转录文件)取状态栏需要的数据。
pub mod git;
pub mod transcript;
+168
View File
@@ -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<f64>,
pub output_per_sec: Option<f64>,
}
impl Speed {
pub const EMPTY: Speed = Speed {
input_per_sec: None,
output_per_sec: None,
};
}
/// 转录文件里关心的字段,其余忽略。
#[derive(Deserialize)]
struct Line {
#[serde(rename = "type")]
kind: Option<String>,
timestamp: Option<String>,
#[serde(rename = "isApiErrorMessage")]
is_api_error: Option<bool>,
message: Option<Message>,
}
#[derive(Deserialize)]
struct Message {
usage: Option<Usage>,
}
#[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<Request> = Vec::new();
let mut last_user_ms: Option<i64> = None;
for line in content.lines() {
let Ok(data) = serde_json::from_str::<Line>(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<i64> {
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)
}
+83
View File
@@ -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<Model>,
/// 思考强度(`effort.level`,如 high / xhigh)。
pub effort: Option<Effort>,
/// 上下文窗口用量(`context-percentage` 组件)。
pub context_window: Option<ContextWindow>,
/// 额度限制与重置时间(`reset-timer` 组件)。
pub rate_limits: Option<RateLimits>,
/// 转录文件路径,后续读 jsonl 算 token/speed 用。
pub transcript_path: Option<String>,
/// 当前工作目录(git 定位等备用)。
pub cwd: Option<String>,
/// 工作区目录(`dir` 组件:当前目录 / 项目根目录)。
pub workspace: Option<Workspace>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Workspace {
pub current_dir: Option<String>,
pub project_dir: Option<String>,
}
/// `model` 字段:可能是裸字符串,也可能是 `{ id, display_name }` 对象。
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum Model {
Name(String),
Detailed {
id: Option<String>,
display_name: Option<String>,
},
}
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<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ContextWindow {
pub used_percentage: Option<f64>,
pub total_input_tokens: Option<u64>,
pub total_output_tokens: Option<u64>,
pub context_window_size: Option<u64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RateLimits {
pub five_hour: Option<RateLimitPeriod>,
pub seven_day: Option<RateLimitPeriod>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RateLimitPeriod {
pub used_percentage: Option<f64>,
/// 重置时刻,Unix 时间戳(秒)。
pub resets_at: Option<i64>,
}
/// 把状态 JSON 文本解析成 [`Status`]。
pub fn parse(text: &str) -> Result<Status, serde_json::Error> {
serde_json::from_str(text)
}
+36
View File
@@ -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<f64> {
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)
}
+42
View File
@@ -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)
}
+45
View File
@@ -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
}
+64
View File
@@ -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<f64>, seven: Option<f64>, countdown_secs: Option<i64>) -> String {
rate::display(five, seven, countdown_secs)
}
/// 目录格式化(供 `test` 预览复用)。
pub fn dir_preview(path: &str, at_root: bool) -> String {
dir::format_dir(path, at_root)
}
+64
View File
@@ -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), // 灰(兜底,正常走不到)
}
}
+63
View File
@@ -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<f64>, seven: Option<f64>, countdown_secs: Option<i64>) -> 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<f64>) -> 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}")
}
+44
View File
@@ -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<f64>) -> String {
match per_sec {
None => "".to_string(),
Some(v) if v >= 1000.0 => format!("{:.1}k", v / 1000.0),
Some(v) => format!("{v:.1}"),
}
}