Port the entire 2236-line bash statusline script to Rust. Implements all 25 sections, 3-phase layout engine (render, priority drop, flex/justify), file-based caching with flock, 9-level terminal width detection, trend sparklines, and deep-merge JSON config. Release binary: 864K with LTO. Render time: <1ms warm. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
3091 lines
100 KiB
Markdown
3091 lines
100 KiB
Markdown
# Rust Port: claude-statusline
|
|
|
|
## Why
|
|
|
|
The bash version (~2236 lines) spawns ~233 subshells per render. Each fork+exec costs ~1-3ms on macOS. Measured: **670ms average per render** against a 300ms render cycle. The perf floor is structural. A Rust port targets **<1ms warm, <5ms cold**.
|
|
|
|
## Contract
|
|
|
|
- **Input**: JSON on stdin from Claude Code (model, cost, tokens, context, workspace, version, output_style)
|
|
- **Output**: Multi-line ANSI-colored text on stdout
|
|
- **Config**: JSON deep-merged with embedded defaults. Search order: `--config <path>`, `CLAUDE_STATUSLINE_CONFIG`, `$XDG_CONFIG_HOME/claude/statusline.json`, `~/.config/claude/statusline.json`, `~/.claude/statusline.json`
|
|
- **CLI**: `--help`, `--test`, `--dump-state[=text|json]`, `--validate-config`, `--config-schema`, `--list-sections`, `--print-defaults`, `--config <path>`, `--no-cache`, `--no-shell`, `--clear-cache`, `--width <cols>`, `--color=auto|always|never`
|
|
- **Env**: `NO_COLOR`, `CLAUDE_STATUSLINE_COLOR`, `CLAUDE_STATUSLINE_WIDTH`, `CLAUDE_STATUSLINE_NO_CACHE`, `CLAUDE_STATUSLINE_NO_SHELL`
|
|
- **Cache**: File-based in `/tmp/claude-sl-{session_id}-{cache_version}-{config_hash}/` with per-key TTLs (compatible with bash version)
|
|
- **VCS**: Shell out to git/jj (not libgit2)
|
|
|
|
---
|
|
|
|
## Crate Structure: Single package with lib + bin
|
|
|
|
No workspace. Keep one package, but split core logic into `src/lib.rs`
|
|
and a thin `src/bin/claude-statusline.rs` wrapper. This keeps the deliverable
|
|
a single binary while making tests/benchmarks reuse library code.
|
|
|
|
```
|
|
Cargo.toml
|
|
build.rs # embed defaults.json + schema.json
|
|
src/
|
|
lib.rs # Core API: parse, render, layout
|
|
bin/
|
|
claude-statusline.rs # CLI parsing, stdin, orchestration
|
|
error.rs # Error enum with From impls
|
|
config.rs # Config loading, deep merge, typed structs
|
|
input.rs # InputData struct (serde from stdin JSON)
|
|
theme.rs # Theme detection (COLORFGBG, config override)
|
|
color.rs # ANSI codes, palette resolution, color_by_name()
|
|
glyph.rs # Glyph system (Nerd Font + ASCII fallback)
|
|
width.rs # Terminal width detection (ioctl + process tree walk chain), memoized
|
|
cache.rs # File-based caching, secure dir, TTL via mtime
|
|
trend.rs # Trend tracking + sparkline (8 Unicode blocks)
|
|
format.rs # human_tokens, human_duration, truncation (grapheme-safe), apply_formatting
|
|
shell.rs # exec_with_timeout, GIT_ENV, parse_git_status_v2, circuit breaker
|
|
metrics.rs # Derived metrics from input (cost velocity, token velocity, usage %, totals)
|
|
section/
|
|
mod.rs # SectionOutput{raw,ansi}, dispatch(RenderContext), registry + metadata
|
|
model.rs # [Opus 4.6] - bold
|
|
provider.rs # Bedrock/Vertex/Anthropic - dim
|
|
project.rs # dirname - cyan
|
|
vcs.rs # branch+dirty+ahead/behind - combined git status, jj shell-out
|
|
beads.rs # br status - shell-out
|
|
context_bar.rs # [====------] 58% - threshold colors, flex rebuild
|
|
context_usage.rs # 115k/200k - threshold colors
|
|
context_remaining.rs # 85k left - threshold colors
|
|
tokens_raw.rs # progressive disclosure by width tier
|
|
cache_efficiency.rs # cache:83% - dim/green/boldgreen
|
|
cost.rs # $0.42 - threshold colors, width-tier decimals
|
|
cost_velocity.rs # $0.03/m - dim
|
|
token_velocity.rs # 14.5ktok/m - dim
|
|
cost_trend.rs # sparkline - dim
|
|
context_trend.rs # sparkline - threshold colors
|
|
lines_changed.rs # +156 -23 - green/red
|
|
duration.rs # 14m - dim
|
|
tools.rs # 7 tools (Edit) - dim, progressive
|
|
turns.rs # 12 turns - dim
|
|
load.rs # load:2.1 - dim, shell-out
|
|
version.rs # v1.0.80 - dim
|
|
time.rs # 14:30 - dim
|
|
output_style.rs # learning - magenta
|
|
hostname.rs # myhost - dim
|
|
custom.rs # user commands: `exec` argv or `bash -c`, optional JSON output
|
|
layout/
|
|
mod.rs # resolve_layout, render_line, assembly
|
|
priority.rs # drop tier 3 (all at once), then tier 2
|
|
flex.rs # spacer > non-spacer; context_bar rebuild
|
|
justify.rs # spread/space-between gap distribution
|
|
defaults.json # embedded via include_str!()
|
|
schema.json # shipped alongside or embedded
|
|
```
|
|
|
|
---
|
|
|
|
## Cargo.toml
|
|
|
|
```toml
|
|
[package]
|
|
name = "claude-statusline"
|
|
version = "0.1.0"
|
|
edition = "2021"
|
|
description = "Fast, configurable status line for Claude Code"
|
|
license = "MIT"
|
|
repository = "https://github.com/tayloreernisse/claude-statusline"
|
|
|
|
[[bin]]
|
|
name = "claude-statusline"
|
|
path = "src/bin/claude-statusline.rs"
|
|
|
|
[lib]
|
|
name = "claude_statusline"
|
|
path = "src/lib.rs"
|
|
|
|
[dependencies]
|
|
serde = { version = "1", features = ["derive"] }
|
|
serde_json = "1"
|
|
md-5 = "0.10" # cache dir compat with bash (12-char hex of project path)
|
|
unicode-width = "0.2" # display width for CJK, emoji, Nerd Font glyphs
|
|
unicode-segmentation = "1" # grapheme-cluster-aware truncation
|
|
libc = "0.2" # ioctl, flock, and low-level TTY checks
|
|
serde_path_to_error = "0.1" # precise error paths for --validate-config
|
|
serde_ignored = "0.1" # warn on unknown config keys during normal runs
|
|
|
|
[dev-dependencies]
|
|
criterion = { version = "0.5", features = ["html_reports"] }
|
|
|
|
[[bench]]
|
|
name = "render"
|
|
harness = false
|
|
|
|
[profile.release]
|
|
lto = true
|
|
codegen-units = 1
|
|
strip = true
|
|
```
|
|
|
|
No clap (4 flags = manual parsing). No regex (simple string ops). No chrono (libc strftime or manual). No colored/owo-colors (10 ANSI codes as const strings).
|
|
|
|
---
|
|
|
|
## build.rs — Embed defaults and schema
|
|
|
|
```rust
|
|
fn main() {
|
|
println!("cargo:rerun-if-changed=defaults.json");
|
|
println!("cargo:rerun-if-changed=schema.json");
|
|
}
|
|
```
|
|
|
|
The actual embedding uses `include_str!()` in `config.rs`. `build.rs` only ensures rebuilds trigger on file changes. `--config-schema` and `--print-defaults` stream embedded JSON to stdout.
|
|
|
|
---
|
|
|
|
## src/error.rs
|
|
|
|
```rust
|
|
use std::fmt;
|
|
use std::io;
|
|
|
|
#[derive(Debug)]
|
|
pub enum Error {
|
|
Io(io::Error),
|
|
Json(serde_json::Error),
|
|
ConfigNotFound(String),
|
|
EmptyStdin,
|
|
}
|
|
|
|
impl fmt::Display for Error {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Io(e) => write!(f, "io: {e}"),
|
|
Self::Json(e) => write!(f, "json: {e}"),
|
|
Self::ConfigNotFound(p) => write!(f, "config not found: {p}"),
|
|
Self::EmptyStdin => write!(f, "empty stdin"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for Error {}
|
|
|
|
impl From<io::Error> for Error {
|
|
fn from(e: io::Error) -> Self { Self::Io(e) }
|
|
}
|
|
|
|
impl From<serde_json::Error> for Error {
|
|
fn from(e: serde_json::Error) -> Self { Self::Json(e) }
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## src/input.rs — Stdin JSON deserialization
|
|
|
|
All fields are `Option` with `#[serde(default)]` — Claude Code may omit any field.
|
|
|
|
```rust
|
|
use serde::Deserialize;
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct InputData {
|
|
pub model: Option<ModelInfo>,
|
|
pub cost: Option<CostInfo>,
|
|
pub context_window: Option<ContextWindow>,
|
|
pub workspace: Option<Workspace>,
|
|
pub version: Option<String>,
|
|
pub output_style: Option<OutputStyle>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ModelInfo {
|
|
pub id: Option<String>,
|
|
pub display_name: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct CostInfo {
|
|
pub total_cost_usd: Option<f64>,
|
|
pub total_duration_ms: Option<u64>,
|
|
pub total_lines_added: Option<u64>,
|
|
pub total_lines_removed: Option<u64>,
|
|
pub total_tool_uses: Option<u64>,
|
|
pub last_tool_name: Option<String>,
|
|
pub total_turns: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(default)]
|
|
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>,
|
|
pub current_usage: Option<CurrentUsage>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct CurrentUsage {
|
|
pub cache_read_input_tokens: Option<u64>,
|
|
pub cache_creation_input_tokens: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct Workspace {
|
|
pub project_dir: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct OutputStyle {
|
|
pub name: Option<String>,
|
|
}
|
|
```
|
|
|
|
### Complete stdin JSON shape
|
|
|
|
Claude Code pipes this every ~300ms:
|
|
|
|
```json
|
|
{
|
|
"model": {
|
|
"id": "claude-opus-4-6-20260101",
|
|
"display_name": "Opus 4.6"
|
|
},
|
|
"cost": {
|
|
"total_cost_usd": 0.42,
|
|
"total_duration_ms": 840000,
|
|
"total_lines_added": 156,
|
|
"total_lines_removed": 23,
|
|
"total_tool_uses": 7,
|
|
"last_tool_name": "Edit",
|
|
"total_turns": 12
|
|
},
|
|
"context_window": {
|
|
"used_percentage": 58.5,
|
|
"total_input_tokens": 115000,
|
|
"total_output_tokens": 8500,
|
|
"context_window_size": 200000,
|
|
"current_usage": {
|
|
"cache_read_input_tokens": 75000,
|
|
"cache_creation_input_tokens": 15000
|
|
}
|
|
},
|
|
"workspace": {
|
|
"project_dir": "/Users/taylor/projects/foo"
|
|
},
|
|
"version": "1.0.80",
|
|
"output_style": {
|
|
"name": "learning"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## src/config.rs — Fully typed config with deep merge
|
|
|
|
The `#[serde(flatten)]` pattern lets every section inherit `SectionBase` fields (enabled, priority, flex, min_width, prefix, suffix, pad, align, color) without repeating them.
|
|
|
|
```rust
|
|
use serde::Deserialize;
|
|
use serde_json::Value;
|
|
use std::collections::HashMap;
|
|
use std::path::Path;
|
|
|
|
const DEFAULTS_JSON: &str = include_str!("../defaults.json");
|
|
|
|
// ── Top-level Config ────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct Config {
|
|
pub version: u32,
|
|
pub global: GlobalConfig,
|
|
pub colors: ThemeColors,
|
|
pub glyphs: GlyphConfig,
|
|
pub presets: HashMap<String, Vec<Vec<String>>>,
|
|
pub layout: LayoutValue,
|
|
pub sections: Sections,
|
|
pub custom: Vec<CustomCommand>,
|
|
}
|
|
|
|
impl Default for Config {
|
|
fn default() -> Self {
|
|
serde_json::from_str(DEFAULTS_JSON).expect("embedded defaults must parse")
|
|
}
|
|
}
|
|
|
|
// ── Migration ───────────────────────────────────────────────────────────
|
|
// Migrate older config versions to current (in-memory only).
|
|
// `--validate-config` reports the original version and applied migrations.
|
|
|
|
// ── Layout: preset name or explicit array ───────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(untagged)]
|
|
pub enum LayoutValue {
|
|
Preset(String),
|
|
Custom(Vec<Vec<String>>),
|
|
}
|
|
|
|
impl Default for LayoutValue {
|
|
fn default() -> Self { Self::Preset("standard".into()) }
|
|
}
|
|
|
|
// ── Global settings ─────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct GlobalConfig {
|
|
pub separator: String,
|
|
pub justify: JustifyMode,
|
|
pub vcs: String,
|
|
pub width: Option<u16>,
|
|
pub width_margin: u16,
|
|
pub cache_dir: String,
|
|
pub cache_gc_days: u16,
|
|
pub cache_gc_interval_hours: u16,
|
|
pub cache_ttl_jitter_pct: u8,
|
|
pub responsive: bool,
|
|
pub breakpoints: Breakpoints,
|
|
pub render_budget_ms: u64,
|
|
pub theme: String,
|
|
pub color: ColorMode,
|
|
pub warn_unknown_keys: bool,
|
|
pub shell_enabled: bool,
|
|
pub shell_allowlist: Vec<String>,
|
|
pub shell_denylist: Vec<String>,
|
|
pub shell_timeout_ms: u64,
|
|
pub shell_max_output_bytes: usize,
|
|
pub shell_failure_threshold: u8,
|
|
pub shell_cooldown_ms: u64,
|
|
pub shell_env: HashMap<String, String>,
|
|
pub cache_version: u32,
|
|
pub drop_strategy: String,
|
|
pub breakpoint_hysteresis: u16,
|
|
}
|
|
|
|
impl Default for GlobalConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
separator: " | ".into(),
|
|
justify: JustifyMode::Left,
|
|
vcs: "auto".into(),
|
|
width: None,
|
|
width_margin: 4,
|
|
cache_dir: "/tmp/claude-sl-{session_id}".into(),
|
|
cache_gc_days: 7,
|
|
cache_gc_interval_hours: 24,
|
|
cache_ttl_jitter_pct: 10,
|
|
responsive: true,
|
|
breakpoints: Breakpoints::default(),
|
|
render_budget_ms: 8,
|
|
theme: "auto".into(),
|
|
color: ColorMode::Auto,
|
|
warn_unknown_keys: true,
|
|
shell_enabled: true,
|
|
shell_allowlist: Vec::new(),
|
|
shell_denylist: Vec::new(),
|
|
shell_timeout_ms: 200,
|
|
shell_max_output_bytes: 8192,
|
|
shell_failure_threshold: 3,
|
|
shell_cooldown_ms: 30_000,
|
|
shell_env: HashMap::new(),
|
|
cache_version: 1,
|
|
drop_strategy: "tiered".into(),
|
|
breakpoint_hysteresis: 2,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum JustifyMode {
|
|
#[default]
|
|
Left,
|
|
Spread,
|
|
SpaceBetween,
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum ColorMode {
|
|
#[default]
|
|
Auto,
|
|
Always,
|
|
Never,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct Breakpoints {
|
|
pub narrow: u16,
|
|
pub medium: u16,
|
|
}
|
|
|
|
impl Default for Breakpoints {
|
|
fn default() -> Self { Self { narrow: 60, medium: 100 } }
|
|
}
|
|
|
|
// ── Color palettes ──────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ThemeColors {
|
|
pub dark: HashMap<String, String>,
|
|
pub light: HashMap<String, String>,
|
|
}
|
|
|
|
// ── Glyph config ────────────────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct GlyphConfig {
|
|
pub enabled: bool,
|
|
pub set: HashMap<String, String>,
|
|
pub fallback: HashMap<String, String>,
|
|
}
|
|
|
|
// ── Shared section base (flattened into each section) ───────────────────
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct SectionBase {
|
|
pub enabled: bool,
|
|
pub priority: u8,
|
|
pub flex: bool,
|
|
pub min_width: Option<u16>,
|
|
pub prefix: Option<String>,
|
|
pub suffix: Option<String>,
|
|
pub pad: Option<u16>,
|
|
pub align: Option<String>,
|
|
pub color: Option<String>,
|
|
}
|
|
|
|
impl Default for SectionBase {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
priority: 2,
|
|
flex: false,
|
|
min_width: None,
|
|
prefix: None,
|
|
suffix: None,
|
|
pad: None,
|
|
align: None,
|
|
color: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Per-section typed configs ───────────────────────────────────────────
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct Sections {
|
|
pub model: SectionBase,
|
|
pub provider: SectionBase,
|
|
pub project: ProjectSection,
|
|
pub vcs: VcsSection,
|
|
pub beads: BeadsSection,
|
|
pub context_bar: ContextBarSection,
|
|
pub context_usage: ContextUsageSection,
|
|
pub context_remaining: ContextRemainingSection,
|
|
pub tokens_raw: TokensRawSection,
|
|
pub cache_efficiency: SectionBase,
|
|
pub cost: CostSection,
|
|
pub cost_velocity: SectionBase,
|
|
pub token_velocity: SectionBase,
|
|
pub cost_trend: TrendSection,
|
|
pub context_trend: ContextTrendSection,
|
|
pub lines_changed: SectionBase,
|
|
pub duration: SectionBase,
|
|
pub tools: ToolsSection,
|
|
pub turns: CachedSection,
|
|
pub load: CachedSection,
|
|
pub version: SectionBase,
|
|
pub time: TimeSection,
|
|
pub output_style: SectionBase,
|
|
pub hostname: SectionBase,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ProjectSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub truncate: TruncateConfig,
|
|
}
|
|
|
|
impl Default for ProjectSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase { priority: 1, ..Default::default() },
|
|
truncate: TruncateConfig { enabled: true, max: 30, style: "middle".into() },
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct TruncateConfig {
|
|
pub enabled: bool,
|
|
pub max: usize,
|
|
pub style: String,
|
|
}
|
|
|
|
impl Default for TruncateConfig {
|
|
fn default() -> Self { Self { enabled: false, max: 0, style: "right".into() } }
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct VcsSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub prefer: String,
|
|
pub show_ahead_behind: bool,
|
|
pub show_dirty: bool,
|
|
pub untracked: String,
|
|
pub submodules: bool,
|
|
pub fast_mode: bool,
|
|
pub truncate: TruncateConfig,
|
|
pub ttl: VcsTtl,
|
|
}
|
|
|
|
impl Default for VcsSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase { priority: 1, min_width: Some(8), ..Default::default() },
|
|
prefer: "auto".into(),
|
|
show_ahead_behind: true,
|
|
show_dirty: true,
|
|
untracked: "normal".into(),
|
|
submodules: false,
|
|
fast_mode: false,
|
|
truncate: TruncateConfig { enabled: true, max: 25, style: "right".into() },
|
|
ttl: VcsTtl::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct VcsTtl {
|
|
pub branch: u64,
|
|
pub dirty: u64,
|
|
pub ahead_behind: u64,
|
|
}
|
|
|
|
impl Default for VcsTtl {
|
|
fn default() -> Self { Self { branch: 3, dirty: 5, ahead_behind: 30 } }
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct BeadsSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub show_wip: bool,
|
|
pub show_wip_count: bool,
|
|
pub show_ready_count: bool,
|
|
pub show_open_count: bool,
|
|
pub show_closed_count: bool,
|
|
pub ttl: u64,
|
|
}
|
|
|
|
impl Default for BeadsSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase { priority: 3, ..Default::default() },
|
|
show_wip: true, show_wip_count: true,
|
|
show_ready_count: true, show_open_count: true, show_closed_count: true,
|
|
ttl: 30,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ContextBarSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub bar_width: u16,
|
|
pub thresholds: Thresholds,
|
|
}
|
|
|
|
impl Default for ContextBarSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase {
|
|
priority: 1, flex: true, min_width: Some(15), ..Default::default()
|
|
},
|
|
bar_width: 10,
|
|
thresholds: Thresholds { warn: 50.0, danger: 70.0, critical: 85.0 },
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct Thresholds {
|
|
pub warn: f64,
|
|
pub danger: f64,
|
|
pub critical: f64,
|
|
}
|
|
|
|
impl Default for Thresholds {
|
|
fn default() -> Self { Self { warn: 50.0, danger: 70.0, critical: 85.0 } }
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ContextUsageSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub capacity: u64,
|
|
pub thresholds: Thresholds,
|
|
}
|
|
|
|
impl Default for ContextUsageSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase { enabled: false, priority: 2, ..Default::default() },
|
|
capacity: 200_000,
|
|
thresholds: Thresholds::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ContextRemainingSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub format: String,
|
|
pub thresholds: Thresholds,
|
|
}
|
|
|
|
impl Default for ContextRemainingSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase { enabled: false, priority: 2, ..Default::default() },
|
|
format: "{remaining} left".into(),
|
|
thresholds: Thresholds::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct TokensRawSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub format: String,
|
|
}
|
|
|
|
impl Default for TokensRawSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase { priority: 3, ..Default::default() },
|
|
format: "{input} in/{output} out".into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct CostSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub thresholds: Thresholds,
|
|
}
|
|
|
|
impl Default for CostSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase { priority: 1, ..Default::default() },
|
|
thresholds: Thresholds { warn: 5.0, danger: 8.0, critical: 10.0 },
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct TrendSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub width: u8,
|
|
}
|
|
|
|
impl Default for TrendSection {
|
|
fn default() -> Self {
|
|
Self { base: SectionBase { priority: 3, ..Default::default() }, width: 8 }
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ContextTrendSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub width: u8,
|
|
pub thresholds: Thresholds,
|
|
}
|
|
|
|
impl Default for ContextTrendSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase { priority: 3, ..Default::default() },
|
|
width: 8,
|
|
thresholds: Thresholds::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ToolsSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub show_last_name: bool,
|
|
pub ttl: u64,
|
|
}
|
|
|
|
impl Default for ToolsSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase { priority: 2, min_width: Some(6), ..Default::default() },
|
|
show_last_name: true,
|
|
ttl: 2,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct CachedSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub ttl: u64,
|
|
}
|
|
|
|
impl Default for CachedSection {
|
|
fn default() -> Self {
|
|
Self { base: SectionBase { priority: 3, ..Default::default() }, ttl: 10 }
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct TimeSection {
|
|
#[serde(flatten)]
|
|
pub base: SectionBase,
|
|
pub format: String,
|
|
}
|
|
|
|
impl Default for TimeSection {
|
|
fn default() -> Self {
|
|
Self {
|
|
base: SectionBase { enabled: false, priority: 3, ..Default::default() },
|
|
format: "%H:%M".into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Custom command sections ─────────────────────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct CustomCommand {
|
|
pub id: String,
|
|
#[serde(default)]
|
|
pub command: Option<String>,
|
|
#[serde(default)]
|
|
pub exec: Option<Vec<String>>,
|
|
#[serde(default)]
|
|
pub format: Option<String>,
|
|
#[serde(default)]
|
|
pub label: Option<String>,
|
|
#[serde(default = "default_custom_ttl")]
|
|
pub ttl: u64,
|
|
#[serde(default = "default_priority")]
|
|
pub priority: u8,
|
|
#[serde(default)]
|
|
pub flex: bool,
|
|
#[serde(default)]
|
|
pub min_width: Option<u16>,
|
|
#[serde(default)]
|
|
pub color: Option<CustomColor>,
|
|
#[serde(default)]
|
|
pub default_color: Option<String>,
|
|
#[serde(default)]
|
|
pub prefix: Option<String>,
|
|
#[serde(default)]
|
|
pub suffix: Option<String>,
|
|
#[serde(default)]
|
|
pub pad: Option<u16>,
|
|
#[serde(default)]
|
|
pub align: Option<String>,
|
|
}
|
|
|
|
fn default_custom_ttl() -> u64 { 30 }
|
|
fn default_priority() -> u8 { 2 }
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct CustomColor {
|
|
#[serde(rename = "match", default)]
|
|
pub match_map: HashMap<String, String>,
|
|
}
|
|
|
|
// ── Deep merge ──────────────────────────────────────────────────────────
|
|
|
|
/// Recursive JSON merge: user values win, arrays replaced entirely.
|
|
pub fn deep_merge(base: &mut Value, patch: &Value) {
|
|
match (base, patch) {
|
|
(Value::Object(base_map), Value::Object(patch_map)) => {
|
|
for (k, v) in patch_map {
|
|
let entry = base_map.entry(k.clone()).or_insert(Value::Null);
|
|
deep_merge(entry, v);
|
|
}
|
|
}
|
|
(base, patch) => {
|
|
*base = patch.clone();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Config loading ──────────────────────────────────────────────────────
|
|
|
|
/// Load config: embedded defaults deep-merged with user overrides.
|
|
/// Returns (Config, Vec<String>) where the Vec contains unknown-key warnings.
|
|
pub fn load_config(explicit_path: Option<&str>) -> Result<(Config, Vec<String>), crate::Error> {
|
|
let mut base: Value = serde_json::from_str(DEFAULTS_JSON)?;
|
|
|
|
let user_path = explicit_path
|
|
.map(|p| std::path::PathBuf::from(p))
|
|
.or_else(|| std::env::var("CLAUDE_STATUSLINE_CONFIG").ok().map(Into::into))
|
|
.or_else(xdg_config_path)
|
|
.or_else(dot_config_path)
|
|
.unwrap_or_else(|| {
|
|
let mut p = dirs_home().unwrap_or_default();
|
|
p.push(".claude/statusline.json");
|
|
p
|
|
});
|
|
|
|
if user_path.exists() {
|
|
let user_json: Value = serde_json::from_str(&std::fs::read_to_string(&user_path)?)?;
|
|
deep_merge(&mut base, &user_json);
|
|
} else if explicit_path.is_some() {
|
|
return Err(crate::Error::ConfigNotFound(user_path.display().to_string()));
|
|
}
|
|
|
|
// Deserialize with unknown-key capture
|
|
let mut warnings = Vec::new();
|
|
let config: Config = serde_ignored::deserialize(
|
|
base,
|
|
|path| warnings.push(format!("unknown config key: {path}")),
|
|
)?;
|
|
|
|
Ok((config, warnings))
|
|
}
|
|
|
|
fn xdg_config_path() -> Option<std::path::PathBuf> {
|
|
let val = std::env::var("XDG_CONFIG_HOME").ok()?;
|
|
let mut p = std::path::PathBuf::from(val);
|
|
p.push("claude/statusline.json");
|
|
if p.exists() { Some(p) } else { None }
|
|
}
|
|
|
|
fn dot_config_path() -> Option<std::path::PathBuf> {
|
|
let mut p = dirs_home()?;
|
|
p.push(".config/claude/statusline.json");
|
|
if p.exists() { Some(p) } else { None }
|
|
}
|
|
|
|
fn dirs_home() -> Option<std::path::PathBuf> {
|
|
std::env::var("HOME").ok().map(Into::into)
|
|
}
|
|
```
|
|
|
|
### Config system rules
|
|
|
|
1. Embedded `defaults.json` (compiled in via `include_str!()`)
|
|
2. User config from `--config <path>` / `$CLAUDE_STATUSLINE_CONFIG` / `$XDG_CONFIG_HOME/claude/statusline.json` / `~/.config/claude/statusline.json` / `~/.claude/statusline.json`
|
|
3. Deep merge: recursive `serde_json::Value` merge (user wins, arrays replaced entirely)
|
|
4. Migrate older config versions to current schema (in-memory only)
|
|
5. Deserialize merged JSON into typed `Config` struct
|
|
6. Capture unknown keys via `serde_ignored` and emit warnings by default. `global.warn_unknown_keys` toggles warnings; `--validate-config` uses `serde_path_to_error` for precise field paths and reports original version + applied migrations.
|
|
7. Color mode: `global.color` (auto|always|never). `NO_COLOR` forces never. `auto` disables color when stdout is not a TTY or `TERM=dumb`.
|
|
|
|
---
|
|
|
|
## src/theme.rs — COLORFGBG detection
|
|
|
|
```rust
|
|
use crate::config::Config;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Theme {
|
|
Dark,
|
|
Light,
|
|
}
|
|
|
|
impl Theme {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::Dark => "dark",
|
|
Self::Light => "light",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Detection priority:
|
|
/// 1. Config override (global.theme = "dark" or "light")
|
|
/// 2. COLORFGBG env var: parse bg from "fg;bg", bg 9-15 = light, 0-8 = dark
|
|
/// 3. Default: dark
|
|
pub fn detect_theme(config: &Config) -> Theme {
|
|
match config.global.theme.as_str() {
|
|
"dark" => return Theme::Dark,
|
|
"light" => return Theme::Light,
|
|
_ => {}
|
|
}
|
|
|
|
if let Ok(val) = std::env::var("COLORFGBG") {
|
|
if let Some(bg_str) = val.rsplit(';').next() {
|
|
if let Ok(bg) = bg_str.parse::<u8>() {
|
|
return if bg > 8 && bg < 16 { Theme::Light } else { Theme::Dark };
|
|
}
|
|
}
|
|
}
|
|
|
|
Theme::Dark
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## src/color.rs — ANSI constants + resolve_color
|
|
|
|
10 ANSI escape codes as `const` strings. No external crate.
|
|
|
|
```rust
|
|
use crate::config::ThemeColors;
|
|
use crate::theme::Theme;
|
|
|
|
pub const RESET: &str = "\x1b[0m";
|
|
pub const BOLD: &str = "\x1b[1m";
|
|
pub const DIM: &str = "\x1b[2m";
|
|
pub const RED: &str = "\x1b[31m";
|
|
pub const GREEN: &str = "\x1b[32m";
|
|
pub const YELLOW: &str = "\x1b[33m";
|
|
pub const BLUE: &str = "\x1b[34m";
|
|
pub const MAGENTA: &str = "\x1b[35m";
|
|
pub const CYAN: &str = "\x1b[36m";
|
|
pub const WHITE: &str = "\x1b[37m";
|
|
|
|
/// Resolve a color name to ANSI escape sequence(s).
|
|
///
|
|
/// Supports:
|
|
/// - Palette references: `"p:success"` -> look up in theme palette, resolve recursively
|
|
/// - Compound styles: `"red bold"` -> concatenated ANSI codes
|
|
/// - Single names: `"green"` -> direct ANSI code
|
|
/// - Unknown: returns RESET
|
|
pub fn resolve_color(name: &str, theme: Theme, palette: &ThemeColors) -> String {
|
|
// Handle palette reference
|
|
if let Some(key) = name.strip_prefix("p:") {
|
|
let map = match theme {
|
|
Theme::Dark => &palette.dark,
|
|
Theme::Light => &palette.light,
|
|
};
|
|
if let Some(resolved) = map.get(key) {
|
|
return resolve_color(resolved, theme, palette);
|
|
}
|
|
return RESET.to_string();
|
|
}
|
|
|
|
// Handle compound styles ("red bold")
|
|
let mut result = String::new();
|
|
for part in name.split_whitespace() {
|
|
result.push_str(match part {
|
|
"red" => RED,
|
|
"green" => GREEN,
|
|
"yellow" => YELLOW,
|
|
"blue" => BLUE,
|
|
"magenta" => MAGENTA,
|
|
"cyan" => CYAN,
|
|
"white" => WHITE,
|
|
"dim" => DIM,
|
|
"bold" => BOLD,
|
|
_ => "",
|
|
});
|
|
}
|
|
|
|
if result.is_empty() { RESET.to_string() } else { result }
|
|
}
|
|
|
|
/// When color is disabled, returns empty strings instead of ANSI codes.
|
|
/// Determined by: `NO_COLOR` env, `--color=never`, or stdout is not a TTY.
|
|
pub fn should_use_color(cli_color: Option<&str>, config_color: &crate::config::ColorMode) -> bool {
|
|
// NO_COLOR takes precedence (https://no-color.org/)
|
|
if std::env::var("NO_COLOR").is_ok() {
|
|
return false;
|
|
}
|
|
|
|
// CLI --color flag overrides config
|
|
if let Some(flag) = cli_color {
|
|
return match flag {
|
|
"always" => true,
|
|
"never" => false,
|
|
_ => atty_stdout(),
|
|
};
|
|
}
|
|
|
|
match config_color {
|
|
crate::config::ColorMode::Always => true,
|
|
crate::config::ColorMode::Never => false,
|
|
crate::config::ColorMode::Auto => {
|
|
atty_stdout() && std::env::var("TERM").map_or(true, |t| t != "dumb")
|
|
}
|
|
}
|
|
}
|
|
|
|
fn atty_stdout() -> bool {
|
|
unsafe { libc::isatty(libc::STDOUT_FILENO) != 0 }
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## src/glyph.rs — Nerd Font + ASCII fallback
|
|
|
|
```rust
|
|
use crate::config::GlyphConfig;
|
|
|
|
/// Look up a named glyph. Returns Nerd Font icon when enabled, ASCII fallback otherwise.
|
|
pub fn glyph<'a>(name: &str, config: &'a GlyphConfig) -> &'a str {
|
|
if config.enabled {
|
|
if let Some(val) = config.set.get(name) {
|
|
if !val.is_empty() {
|
|
return val;
|
|
}
|
|
}
|
|
}
|
|
config.fallback.get(name).map(|s| s.as_str()).unwrap_or("")
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## src/width.rs — Full detection chain with memoization
|
|
|
|
```rust
|
|
use std::sync::Mutex;
|
|
use std::time::{Duration, Instant};
|
|
|
|
static CACHED_WIDTH: Mutex<Option<(u16, Instant)>> = Mutex::new(None);
|
|
const WIDTH_TTL: Duration = Duration::from_secs(1);
|
|
|
|
/// Detect terminal width. Memoized for 1 second across renders.
|
|
/// Priority: cli_width > env > config > ioctl > process tree > stty > COLUMNS > tput > 120
|
|
pub fn detect_width(cli_width: Option<u16>, config_width: Option<u16>, config_margin: u16) -> u16 {
|
|
// Check memo first
|
|
if let Ok(guard) = CACHED_WIDTH.lock() {
|
|
if let Some((w, ts)) = *guard {
|
|
if ts.elapsed() < WIDTH_TTL {
|
|
return w;
|
|
}
|
|
}
|
|
}
|
|
|
|
let raw = detect_raw(cli_width, config_width);
|
|
let effective = raw.saturating_sub(config_margin);
|
|
let effective = effective.max(40); // minimum sane width
|
|
|
|
// Store in memo
|
|
if let Ok(mut guard) = CACHED_WIDTH.lock() {
|
|
*guard = Some((effective, Instant::now()));
|
|
}
|
|
|
|
effective
|
|
}
|
|
|
|
fn detect_raw(cli_width: Option<u16>, config_width: Option<u16>) -> u16 {
|
|
// 1. --width CLI flag
|
|
if let Some(w) = cli_width {
|
|
if w > 0 { return w; }
|
|
}
|
|
|
|
// 2. CLAUDE_STATUSLINE_WIDTH env var
|
|
if let Ok(val) = std::env::var("CLAUDE_STATUSLINE_WIDTH") {
|
|
if let Ok(w) = val.parse::<u16>() {
|
|
if w > 0 { return w; }
|
|
}
|
|
}
|
|
|
|
// 3. Config override
|
|
if let Some(w) = config_width {
|
|
if w > 0 { return w; }
|
|
}
|
|
|
|
// 4. ioctl(TIOCGWINSZ) on stdout
|
|
if let Some(w) = ioctl_width(libc::STDOUT_FILENO) {
|
|
if w > 0 { return w; }
|
|
}
|
|
|
|
// 5. Process tree walk: find ancestor with real TTY
|
|
if let Some(w) = process_tree_width() {
|
|
if w > 0 { return w; }
|
|
}
|
|
|
|
// 6. stty size < /dev/tty
|
|
if let Some(w) = stty_dev_tty() {
|
|
if w > 0 { return w; }
|
|
}
|
|
|
|
// 7. COLUMNS env var
|
|
if let Ok(val) = std::env::var("COLUMNS") {
|
|
if let Ok(w) = val.parse::<u16>() {
|
|
if w > 0 { return w; }
|
|
}
|
|
}
|
|
|
|
// 8. tput cols
|
|
if let Some(w) = tput_cols() {
|
|
if w > 0 { return w; }
|
|
}
|
|
|
|
// 9. Fallback
|
|
120
|
|
}
|
|
|
|
fn ioctl_width(fd: i32) -> Option<u16> {
|
|
#[repr(C)]
|
|
struct Winsize {
|
|
ws_row: u16,
|
|
ws_col: u16,
|
|
ws_xpixel: u16,
|
|
ws_ypixel: u16,
|
|
}
|
|
|
|
let mut ws = Winsize { ws_row: 0, ws_col: 0, ws_xpixel: 0, ws_ypixel: 0 };
|
|
|
|
// TIOCGWINSZ value differs by platform
|
|
#[cfg(target_os = "macos")]
|
|
const TIOCGWINSZ: libc::c_ulong = 0x40087468;
|
|
#[cfg(target_os = "linux")]
|
|
const TIOCGWINSZ: libc::c_ulong = 0x5413;
|
|
|
|
let ret = unsafe { libc::ioctl(fd, TIOCGWINSZ, &mut ws) };
|
|
if ret == 0 && ws.ws_col > 0 { Some(ws.ws_col) } else { None }
|
|
}
|
|
|
|
/// Walk process tree from current PID, find ancestor with a real TTY,
|
|
/// then query its width via stty.
|
|
fn process_tree_width() -> Option<u16> {
|
|
let mut pid = std::process::id();
|
|
|
|
while pid > 1 {
|
|
// Read TTY from /dev/fd or ps
|
|
let output = std::process::Command::new("ps")
|
|
.args(["-o", "tty=", "-p", &pid.to_string()])
|
|
.output()
|
|
.ok()?;
|
|
|
|
let tty = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if !tty.is_empty() && tty != "??" && tty != "-" {
|
|
let dev_path = format!("/dev/{tty}");
|
|
if let Ok(out) = std::process::Command::new("stty")
|
|
.arg("size")
|
|
.stdin(std::fs::File::open(&dev_path).ok()?)
|
|
.output()
|
|
{
|
|
let s = String::from_utf8_lossy(&out.stdout);
|
|
if let Some(cols_str) = s.split_whitespace().nth(1) {
|
|
if let Ok(w) = cols_str.parse::<u16>() {
|
|
return Some(w);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Walk to parent
|
|
let ppid_out = std::process::Command::new("ps")
|
|
.args(["-o", "ppid=", "-p", &pid.to_string()])
|
|
.output()
|
|
.ok()?;
|
|
let ppid_str = String::from_utf8_lossy(&ppid_out.stdout).trim().to_string();
|
|
pid = ppid_str.parse().ok()?;
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn stty_dev_tty() -> Option<u16> {
|
|
let tty = std::fs::File::open("/dev/tty").ok()?;
|
|
let out = std::process::Command::new("stty")
|
|
.arg("size")
|
|
.stdin(tty)
|
|
.output()
|
|
.ok()?;
|
|
let s = String::from_utf8_lossy(&out.stdout);
|
|
s.split_whitespace().nth(1)?.parse().ok()
|
|
}
|
|
|
|
fn tput_cols() -> Option<u16> {
|
|
let out = std::process::Command::new("tput").arg("cols").output().ok()?;
|
|
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
|
|
}
|
|
|
|
// ── Width tiers for progressive disclosure ──────────────────────────────
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum WidthTier {
|
|
Narrow,
|
|
Medium,
|
|
Wide,
|
|
}
|
|
|
|
pub fn width_tier(width: u16, narrow_bp: u16, medium_bp: u16) -> WidthTier {
|
|
if width < narrow_bp {
|
|
WidthTier::Narrow
|
|
} else if width < medium_bp {
|
|
WidthTier::Medium
|
|
} else {
|
|
WidthTier::Wide
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## src/cache.rs — Secure dir, per-key TTL, flock, atomic writes
|
|
|
|
```rust
|
|
use std::fs;
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{Duration, SystemTime};
|
|
|
|
pub struct Cache {
|
|
dir: Option<PathBuf>,
|
|
}
|
|
|
|
impl Cache {
|
|
/// Create cache with secure directory. Returns disabled cache on failure.
|
|
pub fn new(template: &str, session_id: &str) -> Self {
|
|
let dir_str = template.replace("{session_id}", session_id);
|
|
let dir = PathBuf::from(&dir_str);
|
|
|
|
if !dir.exists() {
|
|
if fs::create_dir_all(&dir).is_err() {
|
|
return Self { dir: None };
|
|
}
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
|
|
}
|
|
}
|
|
|
|
// Security: verify ownership, not a symlink, not world-writable
|
|
if !verify_cache_dir(&dir) {
|
|
return Self { dir: None };
|
|
}
|
|
|
|
Self { dir: Some(dir) }
|
|
}
|
|
|
|
pub fn dir(&self) -> Option<&Path> {
|
|
self.dir.as_deref()
|
|
}
|
|
|
|
/// Get cached value if fresher than TTL seconds.
|
|
pub fn get(&self, key: &str, ttl: Duration) -> Option<String> {
|
|
let path = self.key_path(key)?;
|
|
let meta = fs::metadata(&path).ok()?;
|
|
let age = meta.modified().ok()?.elapsed().ok()?;
|
|
if age < ttl {
|
|
fs::read_to_string(&path).ok()
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Get stale cached value (ignores TTL). Used as fallback on command failure.
|
|
pub fn get_stale(&self, key: &str) -> Option<String> {
|
|
let path = self.key_path(key)?;
|
|
fs::read_to_string(&path).ok()
|
|
}
|
|
|
|
/// Atomic write: write to .tmp then rename (prevents partial reads).
|
|
/// Uses flock with LOCK_NB — skips cache on contention rather than blocking.
|
|
pub fn set(&self, key: &str, value: &str) -> Option<()> {
|
|
let path = self.key_path(key)?;
|
|
let tmp = path.with_extension("tmp");
|
|
|
|
// Try non-blocking flock
|
|
let lock_path = path.with_extension("lock");
|
|
let lock_file = fs::File::create(&lock_path).ok()?;
|
|
if !try_flock(&lock_file) {
|
|
return None; // contention — skip cache write
|
|
}
|
|
|
|
let mut f = fs::File::create(&tmp).ok()?;
|
|
f.write_all(value.as_bytes()).ok()?;
|
|
fs::rename(&tmp, &path).ok()?;
|
|
|
|
unlock(&lock_file);
|
|
Some(())
|
|
}
|
|
|
|
fn key_path(&self, key: &str) -> Option<PathBuf> {
|
|
let dir = self.dir.as_ref()?;
|
|
let safe_key: String = key.chars()
|
|
.map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' })
|
|
.collect();
|
|
Some(dir.join(safe_key))
|
|
}
|
|
}
|
|
|
|
fn verify_cache_dir(dir: &Path) -> bool {
|
|
// Must exist, be a directory, not a symlink
|
|
let meta = match fs::symlink_metadata(dir) {
|
|
Ok(m) => m,
|
|
Err(_) => return false,
|
|
};
|
|
if !meta.is_dir() || meta.file_type().is_symlink() {
|
|
return false;
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::MetadataExt;
|
|
// Must be owned by current user
|
|
if meta.uid() != unsafe { libc::getuid() } {
|
|
return false;
|
|
}
|
|
// Must not be world-writable
|
|
if meta.mode() & 0o002 != 0 {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
fn try_flock(file: &fs::File) -> bool {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::io::AsRawFd;
|
|
let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
|
ret == 0
|
|
}
|
|
#[cfg(not(unix))]
|
|
{ true }
|
|
}
|
|
|
|
fn unlock(file: &fs::File) {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::io::AsRawFd;
|
|
unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN); }
|
|
}
|
|
}
|
|
|
|
/// Session ID: first 12 chars of MD5 hex of project_dir.
|
|
/// Same algorithm as bash for cache compatibility during migration.
|
|
pub fn session_id(project_dir: &str) -> String {
|
|
use md5::{Md5, Digest};
|
|
let hash = Md5::digest(project_dir.as_bytes());
|
|
format!("{:x}", hash)[..12].to_string()
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## src/trend.rs — Append with throttle, sparkline with flat-series guard
|
|
|
|
```rust
|
|
use crate::cache::Cache;
|
|
use std::time::Duration;
|
|
|
|
const SPARKLINE_CHARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
|
|
|
/// Append a value to a trend file. Throttled to at most once per `interval`.
|
|
/// Returns the full comma-separated series (for immediate sparkline rendering).
|
|
pub fn append(cache: &Cache, key: &str, value: i64, max_points: usize, interval: Duration) -> Option<String> {
|
|
let trend_key = format!("trend_{key}");
|
|
|
|
// Check throttle: skip if last write was within interval
|
|
if let Some(existing) = cache.get(&trend_key, interval) {
|
|
// Not yet time to append — return existing series
|
|
return Some(existing);
|
|
}
|
|
|
|
// Read current series (ignoring TTL)
|
|
let mut series: Vec<i64> = cache
|
|
.get_stale(&trend_key)
|
|
.unwrap_or_default()
|
|
.split(',')
|
|
.filter_map(|s| s.trim().parse().ok())
|
|
.collect();
|
|
|
|
// Skip if value unchanged from last point
|
|
if series.last() == Some(&value) {
|
|
// Still update the file mtime so throttle window resets
|
|
let csv = series.iter().map(|v| v.to_string()).collect::<Vec<_>>().join(",");
|
|
cache.set(&trend_key, &csv);
|
|
return Some(csv);
|
|
}
|
|
|
|
series.push(value);
|
|
|
|
// Trim from left to max_points
|
|
if series.len() > max_points {
|
|
series = series[series.len() - max_points..].to_vec();
|
|
}
|
|
|
|
let csv = series.iter().map(|v| v.to_string()).collect::<Vec<_>>().join(",");
|
|
cache.set(&trend_key, &csv);
|
|
Some(csv)
|
|
}
|
|
|
|
/// Render a sparkline from comma-separated values.
|
|
/// 8 Unicode block chars, normalized to min/max range.
|
|
/// Flat-series guard: when min == max, render mid-height blocks (▄).
|
|
pub fn sparkline(csv: &str, width: usize) -> String {
|
|
let vals: Vec<i64> = csv.split(',')
|
|
.filter_map(|s| s.trim().parse().ok())
|
|
.collect();
|
|
|
|
if vals.is_empty() {
|
|
return String::new();
|
|
}
|
|
|
|
let min = *vals.iter().min().unwrap();
|
|
let max = *vals.iter().max().unwrap();
|
|
|
|
let count = vals.len().min(width);
|
|
|
|
if max == min {
|
|
// Flat series: mid-height block for all points
|
|
return std::iter::repeat('▄').take(count).collect();
|
|
}
|
|
|
|
let range = (max - min) as f64;
|
|
vals.iter()
|
|
.take(width)
|
|
.map(|&v| {
|
|
let idx = (((v - min) as f64 / range) * 7.0) as usize;
|
|
SPARKLINE_CHARS[idx.min(7)]
|
|
})
|
|
.collect()
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## src/format.rs — Formatting utilities
|
|
|
|
```rust
|
|
use unicode_segmentation::UnicodeSegmentation;
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
use crate::color;
|
|
use crate::config::SectionBase;
|
|
use crate::theme::Theme;
|
|
|
|
/// Display width using unicode-width (handles CJK, emoji, Nerd Font glyphs).
|
|
pub fn display_width(s: &str) -> usize {
|
|
UnicodeWidthStr::width(s)
|
|
}
|
|
|
|
/// Format token count for human readability: 1500 -> "1.5k", 2000000 -> "2.0M"
|
|
pub fn human_tokens(n: u64) -> String {
|
|
if n >= 1_000_000 {
|
|
format!("{:.1}M", n as f64 / 1_000_000.0)
|
|
} else if n >= 1_000 {
|
|
format!("{:.1}k", n as f64 / 1_000.0)
|
|
} else {
|
|
n.to_string()
|
|
}
|
|
}
|
|
|
|
/// Format duration: 840000ms -> "14m", 7200000ms -> "2h0m", 45000ms -> "45s"
|
|
pub fn human_duration(ms: u64) -> String {
|
|
let secs = ms / 1000;
|
|
if secs >= 3600 {
|
|
format!("{}h{}m", secs / 3600, (secs % 3600) / 60)
|
|
} else if secs >= 60 {
|
|
format!("{}m", secs / 60)
|
|
} else {
|
|
format!("{secs}s")
|
|
}
|
|
}
|
|
|
|
/// Grapheme-cluster-safe truncation. Never splits a grapheme.
|
|
pub fn truncate(s: &str, max: usize, style: &str) -> String {
|
|
let w = display_width(s);
|
|
if w <= max {
|
|
return s.to_string();
|
|
}
|
|
|
|
let graphemes: Vec<&str> = s.graphemes(true).collect();
|
|
match style {
|
|
"middle" => truncate_middle(&graphemes, max),
|
|
"left" => truncate_left(&graphemes, max),
|
|
_ => truncate_right(&graphemes, max),
|
|
}
|
|
}
|
|
|
|
fn truncate_right(graphemes: &[&str], max: usize) -> String {
|
|
let mut result = String::new();
|
|
let mut w = 0;
|
|
for &g in graphemes {
|
|
let gw = UnicodeWidthStr::width(g);
|
|
if w + gw + 1 > max { break; } // +1 for ellipsis
|
|
result.push_str(g);
|
|
w += gw;
|
|
}
|
|
result.push('…');
|
|
result
|
|
}
|
|
|
|
fn truncate_middle(graphemes: &[&str], max: usize) -> String {
|
|
let half = (max - 1) / 2; // -1 for ellipsis
|
|
let mut left = String::new();
|
|
let mut left_w = 0;
|
|
for &g in graphemes {
|
|
let gw = UnicodeWidthStr::width(g);
|
|
if left_w + gw > half { break; }
|
|
left.push_str(g);
|
|
left_w += gw;
|
|
}
|
|
|
|
let right_budget = max - 1 - left_w;
|
|
let mut right_graphemes = Vec::new();
|
|
let mut right_w = 0;
|
|
for &g in graphemes.iter().rev() {
|
|
let gw = UnicodeWidthStr::width(g);
|
|
if right_w + gw > right_budget { break; }
|
|
right_graphemes.push(g);
|
|
right_w += gw;
|
|
}
|
|
right_graphemes.reverse();
|
|
|
|
format!("{left}…{}", right_graphemes.join(""))
|
|
}
|
|
|
|
fn truncate_left(graphemes: &[&str], max: usize) -> String {
|
|
let budget = max - 1; // -1 for ellipsis
|
|
let mut parts = Vec::new();
|
|
let mut w = 0;
|
|
for &g in graphemes.iter().rev() {
|
|
let gw = UnicodeWidthStr::width(g);
|
|
if w + gw > budget { break; }
|
|
parts.push(g);
|
|
w += gw;
|
|
}
|
|
parts.reverse();
|
|
format!("…{}", parts.join(""))
|
|
}
|
|
|
|
/// Apply per-section formatting: prefix, suffix, color override, pad+align.
|
|
///
|
|
/// Color override re-wraps `raw` text (discards section's internal ANSI),
|
|
/// matching bash behavior where `apply_formatting()` rebuilds the ANSI string.
|
|
pub fn apply_formatting(
|
|
raw: &mut String,
|
|
ansi: &mut String,
|
|
base: &SectionBase,
|
|
theme: Theme,
|
|
palette: &crate::config::ThemeColors,
|
|
) {
|
|
// 1. Prefix / suffix
|
|
if let Some(ref pfx) = base.prefix {
|
|
*raw = format!("{pfx}{raw}");
|
|
*ansi = format!("{pfx}{ansi}");
|
|
}
|
|
if let Some(ref sfx) = base.suffix {
|
|
raw.push_str(sfx);
|
|
ansi.push_str(sfx);
|
|
}
|
|
|
|
// 2. Color override — re-wrap raw text
|
|
if let Some(ref color_name) = base.color {
|
|
let c = color::resolve_color(color_name, theme, palette);
|
|
*ansi = format!("{c}{raw}{}", color::RESET);
|
|
}
|
|
|
|
// 3. Pad + align
|
|
if let Some(pad) = base.pad {
|
|
let pad = pad as usize;
|
|
let raw_w = display_width(raw);
|
|
if pad > raw_w {
|
|
let needed = pad - raw_w;
|
|
let spaces: String = " ".repeat(needed);
|
|
let align = base.align.as_deref().unwrap_or("left");
|
|
match align {
|
|
"right" => {
|
|
*raw = format!("{spaces}{raw}");
|
|
*ansi = format!("{spaces}{ansi}");
|
|
}
|
|
"center" => {
|
|
let left = needed / 2;
|
|
let right = needed - left;
|
|
*raw = format!("{}{raw}{}", " ".repeat(left), " ".repeat(right));
|
|
*ansi = format!("{}{ansi}{}", " ".repeat(left), " ".repeat(right));
|
|
}
|
|
_ => {
|
|
raw.push_str(&spaces);
|
|
ansi.push_str(&spaces);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## src/shell.rs — exec_with_timeout, GIT_ENV, parse_git_status_v2
|
|
|
|
```rust
|
|
use std::process::{Command, Stdio};
|
|
use std::time::{Duration, Instant};
|
|
use std::thread;
|
|
|
|
/// Stable env for all git commands: no lock contention, no prompts, no locale variance.
|
|
const GIT_ENV: &[(&str, &str)] = &[
|
|
("GIT_OPTIONAL_LOCKS", "0"),
|
|
("GIT_TERMINAL_PROMPT", "0"),
|
|
("LC_ALL", "C"),
|
|
];
|
|
|
|
/// Execute a command with a polling timeout. No extra crate needed.
|
|
/// Returns None on timeout or error.
|
|
pub fn exec_with_timeout(
|
|
program: &str,
|
|
args: &[&str],
|
|
dir: Option<&str>,
|
|
timeout: Duration,
|
|
) -> Option<String> {
|
|
let mut cmd = Command::new(program);
|
|
cmd.args(args)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null());
|
|
|
|
if let Some(d) = dir {
|
|
cmd.current_dir(d);
|
|
}
|
|
|
|
// Apply GIT_ENV for git commands
|
|
if program == "git" {
|
|
for (k, v) in GIT_ENV {
|
|
cmd.env(k, v);
|
|
}
|
|
}
|
|
|
|
let mut child = cmd.spawn().ok()?;
|
|
let start = Instant::now();
|
|
|
|
loop {
|
|
match child.try_wait() {
|
|
Ok(Some(status)) => {
|
|
if !status.success() {
|
|
return None;
|
|
}
|
|
let output = child.wait_with_output().ok()?;
|
|
return Some(String::from_utf8_lossy(&output.stdout).trim().to_string());
|
|
}
|
|
Ok(None) => {
|
|
if start.elapsed() >= timeout {
|
|
let _ = child.kill();
|
|
return None;
|
|
}
|
|
thread::sleep(Duration::from_millis(5));
|
|
}
|
|
Err(_) => return None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Parsed result from `git status --porcelain=v2 --branch`.
|
|
/// Single call returns branch, dirty, and ahead/behind.
|
|
#[derive(Debug, Default)]
|
|
pub struct GitStatusV2 {
|
|
pub branch: Option<String>,
|
|
pub ahead: u32,
|
|
pub behind: u32,
|
|
pub is_dirty: bool,
|
|
}
|
|
|
|
/// Parse combined `git status --porcelain=v2 --branch` output.
|
|
/// Three cache TTLs are preserved by caching sub-results independently,
|
|
/// but all three are populated from a single execution when any one expires.
|
|
pub fn parse_git_status_v2(output: &str) -> GitStatusV2 {
|
|
let mut result = GitStatusV2::default();
|
|
|
|
for line in output.lines() {
|
|
if let Some(rest) = line.strip_prefix("# branch.head ") {
|
|
result.branch = Some(rest.to_string());
|
|
} else if let Some(rest) = line.strip_prefix("# branch.ab ") {
|
|
// Format: "+3 -1"
|
|
for part in rest.split_whitespace() {
|
|
if let Some(n) = part.strip_prefix('+') {
|
|
result.ahead = n.parse().unwrap_or(0);
|
|
} else if let Some(n) = part.strip_prefix('-') {
|
|
result.behind = n.parse().unwrap_or(0);
|
|
}
|
|
}
|
|
} else if line.starts_with('1') || line.starts_with('2') || line.starts_with('?') || line.starts_with('u') {
|
|
// Porcelain v2: 1=changed, 2=renamed, ?=untracked, u=unmerged
|
|
result.is_dirty = true;
|
|
}
|
|
}
|
|
|
|
result
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## src/section/mod.rs — Section dispatch and registry
|
|
|
|
Function pointers (`fn(&RenderContext) -> Option<SectionOutput>`) — no traits.
|
|
|
|
```rust
|
|
use crate::config::Config;
|
|
use crate::cache::Cache;
|
|
use crate::input::InputData;
|
|
use crate::theme::Theme;
|
|
use crate::width::WidthTier;
|
|
|
|
use std::path::Path;
|
|
|
|
/// What every section renderer returns.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SectionOutput {
|
|
pub raw: String, // plain text (for width calculation)
|
|
pub ansi: String, // ANSI-colored text (for display)
|
|
}
|
|
|
|
/// Type alias for section render functions.
|
|
pub type RenderFn = fn(&RenderContext) -> Option<SectionOutput>;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum VcsType {
|
|
Git,
|
|
Jj,
|
|
None,
|
|
}
|
|
|
|
/// Context passed to every section renderer.
|
|
pub struct RenderContext<'a> {
|
|
pub input: &'a InputData,
|
|
pub config: &'a Config,
|
|
pub theme: Theme,
|
|
pub width_tier: WidthTier,
|
|
pub term_width: u16,
|
|
pub vcs_type: VcsType,
|
|
pub project_dir: &'a Path,
|
|
pub cache: &'a Cache,
|
|
pub glyphs_enabled: bool,
|
|
pub color_enabled: bool,
|
|
pub metrics: ComputedMetrics,
|
|
}
|
|
|
|
/// Metadata for layout planning (before rendering).
|
|
/// Single source of truth for section IDs, validation, and CLI introspection.
|
|
pub struct SectionDescriptor {
|
|
pub id: &'static str,
|
|
pub render: RenderFn,
|
|
pub priority: u8,
|
|
pub is_spacer: bool,
|
|
pub is_flex: bool,
|
|
pub estimated_width: u16,
|
|
pub shell_out: bool,
|
|
}
|
|
|
|
/// Build the registry of all built-in sections.
|
|
/// Maps section ID to its render function.
|
|
pub fn registry() -> Vec<(&'static str, RenderFn)> {
|
|
vec![
|
|
("model", super::section::model::render),
|
|
("provider", super::section::provider::render),
|
|
("project", super::section::project::render),
|
|
("vcs", super::section::vcs::render),
|
|
("beads", super::section::beads::render),
|
|
("context_bar", super::section::context_bar::render),
|
|
("context_usage", super::section::context_usage::render),
|
|
("context_remaining", super::section::context_remaining::render),
|
|
("tokens_raw", super::section::tokens_raw::render),
|
|
("cache_efficiency", super::section::cache_efficiency::render),
|
|
("cost", super::section::cost::render),
|
|
("cost_velocity", super::section::cost_velocity::render),
|
|
("token_velocity", super::section::token_velocity::render),
|
|
("cost_trend", super::section::cost_trend::render),
|
|
("context_trend", super::section::context_trend::render),
|
|
("lines_changed", super::section::lines_changed::render),
|
|
("duration", super::section::duration::render),
|
|
("tools", super::section::tools::render),
|
|
("turns", super::section::turns::render),
|
|
("load", super::section::load::render),
|
|
("version", super::section::version::render),
|
|
("time", super::section::time::render),
|
|
("output_style", super::section::output_style::render),
|
|
("hostname", super::section::hostname::render),
|
|
]
|
|
}
|
|
|
|
/// Dispatch: look up section by ID and render it.
|
|
/// Returns None if section is disabled, missing data, or unknown.
|
|
pub fn render_section(id: &str, ctx: &RenderContext) -> Option<SectionOutput> {
|
|
if is_spacer(id) {
|
|
return Some(SectionOutput { raw: " ".into(), ansi: " ".into() });
|
|
}
|
|
|
|
// Try built-in
|
|
for (name, render_fn) in registry() {
|
|
if name == id {
|
|
return render_fn(ctx);
|
|
}
|
|
}
|
|
|
|
// Try custom command
|
|
super::section::custom::render(id, ctx)
|
|
}
|
|
|
|
pub fn is_spacer(id: &str) -> bool {
|
|
id == "spacer" || id.starts_with("_spacer")
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Representative Sections (4 of 24)
|
|
|
|
### section/model.rs — Pure data, no shell-out
|
|
|
|
```rust
|
|
use crate::color;
|
|
use crate::section::{RenderContext, SectionOutput};
|
|
|
|
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
|
if !ctx.config.sections.model.enabled { return None; }
|
|
|
|
let model = ctx.input.model.as_ref()?;
|
|
let id = model.id.as_deref().unwrap_or("?");
|
|
let id_lower = id.to_ascii_lowercase();
|
|
|
|
let base_name = if id_lower.contains("opus") { "Opus" }
|
|
else if id_lower.contains("sonnet") { "Sonnet" }
|
|
else if id_lower.contains("haiku") { "Haiku" }
|
|
else { return Some(simple_output(model.display_name.as_deref().unwrap_or(id), ctx)) };
|
|
|
|
// Extract version: "opus-4-6" or "4-6-opus" -> "4.6"
|
|
let version = extract_version(&id_lower, base_name.to_ascii_lowercase().as_str());
|
|
|
|
let name = match version {
|
|
Some(v) => format!("{base_name} {v}"),
|
|
None => base_name.to_string(),
|
|
};
|
|
|
|
let raw = format!("[{name}]");
|
|
let ansi = if ctx.color_enabled {
|
|
format!("{}[{name}]{}", color::BOLD, color::RESET)
|
|
} else {
|
|
raw.clone()
|
|
};
|
|
|
|
Some(SectionOutput { raw, ansi })
|
|
}
|
|
|
|
fn extract_version(id: &str, family: &str) -> Option<String> {
|
|
// Pattern: "opus-4-6" or "4-6-opus"
|
|
let parts: Vec<&str> = id.split('-').collect();
|
|
for window in parts.windows(3) {
|
|
if window[0] == family {
|
|
if let (Ok(a), Ok(b)) = (window[1].parse::<u8>(), window[2].parse::<u8>()) {
|
|
return Some(format!("{a}.{b}"));
|
|
}
|
|
}
|
|
if window[2] == family {
|
|
if let (Ok(a), Ok(b)) = (window[0].parse::<u8>(), window[1].parse::<u8>()) {
|
|
return Some(format!("{a}.{b}"));
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn simple_output(name: &str, ctx: &RenderContext) -> SectionOutput {
|
|
let raw = format!("[{name}]");
|
|
let ansi = if ctx.color_enabled {
|
|
format!("{}[{name}]{}", color::BOLD, color::RESET)
|
|
} else {
|
|
raw.clone()
|
|
};
|
|
SectionOutput { raw, ansi }
|
|
}
|
|
```
|
|
|
|
### section/cost.rs — Threshold colors, width-tier decimals
|
|
|
|
```rust
|
|
use crate::color;
|
|
use crate::section::{RenderContext, SectionOutput};
|
|
use crate::width::WidthTier;
|
|
|
|
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
|
if !ctx.config.sections.cost.base.enabled { return None; }
|
|
|
|
let cost_val = ctx.input.cost.as_ref()?.total_cost_usd?;
|
|
|
|
let decimals = match ctx.width_tier {
|
|
WidthTier::Narrow => 0,
|
|
WidthTier::Medium => 2,
|
|
WidthTier::Wide => 4,
|
|
};
|
|
|
|
let cost_str = format!("{cost_val:.decimals$}");
|
|
let raw = format!("${cost_str}");
|
|
|
|
let thresh = &ctx.config.sections.cost.thresholds;
|
|
let color_code = if cost_val >= thresh.critical {
|
|
format!("{}{}", color::RED, color::BOLD)
|
|
} else if cost_val >= thresh.danger {
|
|
color::RED.to_string()
|
|
} else if cost_val >= thresh.warn {
|
|
color::YELLOW.to_string()
|
|
} else {
|
|
color::GREEN.to_string()
|
|
};
|
|
|
|
let ansi = if ctx.color_enabled {
|
|
format!("{color_code}${cost_str}{}", color::RESET)
|
|
} else {
|
|
raw.clone()
|
|
};
|
|
|
|
Some(SectionOutput { raw, ansi })
|
|
}
|
|
```
|
|
|
|
### section/context_bar.rs — Flex-expandable, threshold colors
|
|
|
|
```rust
|
|
use crate::color;
|
|
use crate::section::{RenderContext, SectionOutput};
|
|
|
|
/// Render context bar at a given bar_width. Called both at initial render
|
|
/// and during flex expansion (with wider bar_width).
|
|
pub fn render_at_width(ctx: &RenderContext, bar_width: u16) -> Option<SectionOutput> {
|
|
let pct = ctx.input.context_window.as_ref()?.used_percentage?;
|
|
let pct_int = pct.round() as u16;
|
|
|
|
let filled = (pct_int as u32 * bar_width as u32 / 100) as usize;
|
|
let empty = bar_width as usize - filled;
|
|
|
|
let bar: String = "=".repeat(filled) + &"-".repeat(empty);
|
|
let raw = format!("[{bar}] {pct_int}%");
|
|
|
|
let thresh = &ctx.config.sections.context_bar.thresholds;
|
|
let color_code = threshold_color(pct, thresh);
|
|
|
|
let ansi = if ctx.color_enabled {
|
|
format!("{color_code}[{bar}] {pct_int}%{}", color::RESET)
|
|
} else {
|
|
raw.clone()
|
|
};
|
|
|
|
Some(SectionOutput { raw, ansi })
|
|
}
|
|
|
|
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
|
if !ctx.config.sections.context_bar.base.enabled { return None; }
|
|
render_at_width(ctx, ctx.config.sections.context_bar.bar_width)
|
|
}
|
|
|
|
fn threshold_color(pct: f64, thresh: &crate::config::Thresholds) -> String {
|
|
if pct >= thresh.critical {
|
|
format!("{}{}", color::RED, color::BOLD)
|
|
} else if pct >= thresh.danger {
|
|
color::RED.to_string()
|
|
} else if pct >= thresh.warn {
|
|
color::YELLOW.to_string()
|
|
} else {
|
|
color::GREEN.to_string()
|
|
}
|
|
}
|
|
```
|
|
|
|
### section/vcs.rs — Shell-out with combined git status
|
|
|
|
```rust
|
|
use crate::color;
|
|
use crate::glyph;
|
|
use crate::section::{RenderContext, SectionOutput, VcsType};
|
|
use crate::shell::{self, GitStatusV2};
|
|
use crate::width::WidthTier;
|
|
use std::time::Duration;
|
|
|
|
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
|
if !ctx.config.sections.vcs.base.enabled { return None; }
|
|
if ctx.vcs_type == VcsType::None { return None; }
|
|
|
|
let dir = ctx.project_dir.to_str()?;
|
|
let ttl = &ctx.config.sections.vcs.ttl;
|
|
let glyphs = &ctx.config.glyphs;
|
|
|
|
match ctx.vcs_type {
|
|
VcsType::Git => render_git(ctx, dir, ttl, glyphs),
|
|
VcsType::Jj => render_jj(ctx, dir, ttl, glyphs),
|
|
VcsType::None => None,
|
|
}
|
|
}
|
|
|
|
fn render_git(
|
|
ctx: &RenderContext,
|
|
dir: &str,
|
|
ttl: &crate::config::VcsTtl,
|
|
glyphs: &crate::config::GlyphConfig,
|
|
) -> Option<SectionOutput> {
|
|
// Try combined git status (populates all three sub-caches at once)
|
|
let branch_ttl = Duration::from_secs(ttl.branch);
|
|
let dirty_ttl = Duration::from_secs(ttl.dirty);
|
|
let ab_ttl = Duration::from_secs(ttl.ahead_behind);
|
|
let timeout = Duration::from_millis(200);
|
|
|
|
// Check if any sub-cache is expired
|
|
let branch_cached = ctx.cache.get("vcs_branch", branch_ttl);
|
|
let dirty_cached = ctx.cache.get("vcs_dirty", dirty_ttl);
|
|
let ab_cached = ctx.cache.get("vcs_ab", ab_ttl);
|
|
|
|
let status = if branch_cached.is_none() || dirty_cached.is_none() || ab_cached.is_none() {
|
|
// Run combined command, populate all sub-caches
|
|
let output = shell::exec_with_timeout(
|
|
"git", &["-C", dir, "status", "--porcelain=v2", "--branch"],
|
|
None, timeout,
|
|
);
|
|
match output {
|
|
Some(ref out) => {
|
|
let s = shell::parse_git_status_v2(out);
|
|
if let Some(ref b) = s.branch {
|
|
ctx.cache.set("vcs_branch", b);
|
|
}
|
|
ctx.cache.set("vcs_dirty", if s.is_dirty { "1" } else { "" });
|
|
ctx.cache.set("vcs_ab", &format!("{} {}", s.ahead, s.behind));
|
|
s
|
|
}
|
|
None => {
|
|
// Fall back to stale cache
|
|
GitStatusV2 {
|
|
branch: branch_cached.or_else(|| ctx.cache.get_stale("vcs_branch")),
|
|
is_dirty: dirty_cached.or_else(|| ctx.cache.get_stale("vcs_dirty"))
|
|
.map_or(false, |v| !v.is_empty()),
|
|
ahead: 0,
|
|
behind: 0,
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
GitStatusV2 {
|
|
branch: branch_cached,
|
|
is_dirty: dirty_cached.map_or(false, |v| !v.is_empty()),
|
|
ahead: ab_cached.as_ref()
|
|
.and_then(|s| s.split_whitespace().next()?.parse().ok()).unwrap_or(0),
|
|
behind: ab_cached.as_ref()
|
|
.and_then(|s| s.split_whitespace().nth(1)?.parse().ok()).unwrap_or(0),
|
|
}
|
|
};
|
|
|
|
let branch = status.branch.as_deref().unwrap_or("?");
|
|
let branch_glyph = glyph::glyph("branch", glyphs);
|
|
let dirty_glyph = if status.is_dirty && ctx.config.sections.vcs.show_dirty {
|
|
glyph::glyph("dirty", glyphs)
|
|
} else { "" };
|
|
|
|
let mut raw = format!("{branch_glyph}{branch}{dirty_glyph}");
|
|
let mut ansi = if ctx.color_enabled {
|
|
let mut s = format!("{}{branch_glyph}{branch}{}", color::GREEN, color::RESET);
|
|
if !dirty_glyph.is_empty() {
|
|
s.push_str(&format!("{}{dirty_glyph}{}", color::YELLOW, color::RESET));
|
|
}
|
|
s
|
|
} else {
|
|
raw.clone()
|
|
};
|
|
|
|
// Ahead/behind: medium+ width only
|
|
if ctx.config.sections.vcs.show_ahead_behind
|
|
&& ctx.width_tier != WidthTier::Narrow
|
|
&& (status.ahead > 0 || status.behind > 0)
|
|
{
|
|
let ahead_g = glyph::glyph("ahead", glyphs);
|
|
let behind_g = glyph::glyph("behind", glyphs);
|
|
let mut ab = String::new();
|
|
if status.ahead > 0 { ab.push_str(&format!("{ahead_g}{}", status.ahead)); }
|
|
if status.behind > 0 { ab.push_str(&format!("{behind_g}{}", status.behind)); }
|
|
raw.push_str(&format!(" {ab}"));
|
|
if ctx.color_enabled {
|
|
ansi.push_str(&format!(" {}{ab}{}", color::DIM, color::RESET));
|
|
} else {
|
|
ansi = raw.clone();
|
|
}
|
|
}
|
|
|
|
Some(SectionOutput { raw, ansi })
|
|
}
|
|
|
|
fn render_jj(
|
|
ctx: &RenderContext,
|
|
_dir: &str,
|
|
ttl: &crate::config::VcsTtl,
|
|
glyphs: &crate::config::GlyphConfig,
|
|
) -> Option<SectionOutput> {
|
|
let branch_ttl = Duration::from_secs(ttl.branch);
|
|
let timeout = Duration::from_millis(200);
|
|
|
|
let branch = ctx.cache.get("vcs_branch", branch_ttl).or_else(|| {
|
|
let out = shell::exec_with_timeout(
|
|
"jj",
|
|
&["log", "-r", "@", "--no-graph",
|
|
"-T", "if(bookmarks, bookmarks.join(\",\"), change_id.shortest(8))",
|
|
"--color=never"],
|
|
None, timeout,
|
|
)?;
|
|
ctx.cache.set("vcs_branch", &out);
|
|
Some(out)
|
|
})?;
|
|
|
|
let branch_glyph = glyph::glyph("branch", glyphs);
|
|
let raw = format!("{branch_glyph}{branch}");
|
|
let ansi = if ctx.color_enabled {
|
|
format!("{}{branch_glyph}{branch}{}", color::GREEN, color::RESET)
|
|
} else {
|
|
raw.clone()
|
|
};
|
|
|
|
// jj has no upstream concept — ahead/behind hardcoded to 0/0
|
|
Some(SectionOutput { raw, ansi })
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## src/layout/mod.rs — Full render pipeline
|
|
|
|
Three phases: Plan → Render survivors → Reflow.
|
|
|
|
```rust
|
|
use crate::config::{Config, JustifyMode, LayoutValue};
|
|
use crate::format;
|
|
use crate::section::{self, RenderContext, SectionOutput};
|
|
|
|
/// A section that survived priority drops and has rendered output.
|
|
pub struct ActiveSection {
|
|
pub id: String,
|
|
pub output: SectionOutput,
|
|
pub priority: u8,
|
|
pub is_spacer: bool,
|
|
pub is_flex: bool,
|
|
}
|
|
|
|
/// Resolve layout: preset lookup with optional responsive override.
|
|
pub fn resolve_layout(config: &Config, term_width: u16) -> Vec<Vec<String>> {
|
|
match &config.layout {
|
|
LayoutValue::Preset(name) => {
|
|
let effective = if config.global.responsive {
|
|
responsive_preset(term_width, &config.global.breakpoints)
|
|
} else {
|
|
name.as_str()
|
|
};
|
|
config.presets.get(effective)
|
|
.or_else(|| config.presets.get(name.as_str()))
|
|
.cloned()
|
|
.unwrap_or_else(|| vec![vec!["model".into(), "project".into()]])
|
|
}
|
|
LayoutValue::Custom(lines) => lines.clone(),
|
|
}
|
|
}
|
|
|
|
fn responsive_preset(width: u16, bp: &crate::config::Breakpoints) -> &'static str {
|
|
if width < bp.narrow { "dense" }
|
|
else if width < bp.medium { "standard" }
|
|
else { "verbose" }
|
|
}
|
|
|
|
/// Render a single layout line.
|
|
/// Returns the final ANSI string, or None if all sections were empty.
|
|
pub fn render_line(
|
|
section_ids: &[String],
|
|
ctx: &RenderContext,
|
|
separator: &str,
|
|
) -> Option<String> {
|
|
// Phase 1: Render all sections, collect active ones
|
|
let mut active: Vec<ActiveSection> = Vec::new();
|
|
|
|
for id in section_ids {
|
|
let output = section::render_section(id, ctx)?;
|
|
if output.raw.is_empty() && !section::is_spacer(id) {
|
|
continue;
|
|
}
|
|
|
|
let (priority, is_flex) = section_meta(id, ctx.config);
|
|
active.push(ActiveSection {
|
|
id: id.clone(),
|
|
output,
|
|
priority,
|
|
is_spacer: section::is_spacer(id),
|
|
is_flex,
|
|
});
|
|
}
|
|
|
|
if active.is_empty() || active.iter().all(|s| s.is_spacer) {
|
|
return None;
|
|
}
|
|
|
|
// Phase 2: Priority drop if overflowing
|
|
let mut active = super::layout::priority::priority_drop(
|
|
active, ctx.term_width, separator,
|
|
);
|
|
|
|
// Phase 3: Flex expand or justify
|
|
let line = if ctx.config.global.justify != JustifyMode::Left
|
|
&& !active.iter().any(|s| s.is_spacer)
|
|
&& active.len() > 1
|
|
{
|
|
super::layout::justify::justify(&active, ctx.term_width, separator, ctx.config.global.justify)
|
|
} else {
|
|
super::layout::flex::flex_expand(&mut active, ctx, separator)?;
|
|
assemble_left(&active, separator, ctx.color_enabled)
|
|
};
|
|
|
|
Some(line)
|
|
}
|
|
|
|
/// Left-aligned assembly with separator dimming and spacer suppression.
|
|
fn assemble_left(active: &[ActiveSection], separator: &str, color_enabled: bool) -> String {
|
|
let mut output = String::new();
|
|
let mut prev_is_spacer = false;
|
|
|
|
for (i, sec) in active.iter().enumerate() {
|
|
if i > 0 && !prev_is_spacer && !sec.is_spacer {
|
|
if color_enabled {
|
|
output.push_str(&format!("{}{separator}{}", crate::color::DIM, crate::color::RESET));
|
|
} else {
|
|
output.push_str(separator);
|
|
}
|
|
}
|
|
output.push_str(&sec.output.ansi);
|
|
prev_is_spacer = sec.is_spacer;
|
|
}
|
|
|
|
output
|
|
}
|
|
|
|
fn section_meta(id: &str, config: &Config) -> (u8, bool) {
|
|
if section::is_spacer(id) {
|
|
return (1, true);
|
|
}
|
|
// Look up from config (simplified — real impl uses per-section base)
|
|
(2, false) // placeholder; real impl reads config.sections.{id}.base
|
|
}
|
|
|
|
/// Full render: resolve layout, render each line, join with newlines.
|
|
pub fn render_all(ctx: &RenderContext) -> String {
|
|
let layout = resolve_layout(ctx.config, ctx.term_width);
|
|
let separator = &ctx.config.global.separator;
|
|
|
|
let lines: Vec<String> = layout.iter()
|
|
.filter_map(|line_ids| render_line(line_ids, ctx, separator))
|
|
.collect();
|
|
|
|
lines.join("\n")
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## src/layout/priority.rs — Priority drop
|
|
|
|
```rust
|
|
use crate::layout::ActiveSection;
|
|
use crate::format;
|
|
|
|
/// Drop priority 3 sections (all at once), then priority 2, until line fits.
|
|
/// Priority 1 sections never drop.
|
|
pub fn priority_drop(
|
|
mut active: Vec<ActiveSection>,
|
|
term_width: u16,
|
|
separator: &str,
|
|
) -> Vec<ActiveSection> {
|
|
if line_width(&active, separator) <= term_width as usize {
|
|
return active;
|
|
}
|
|
|
|
// Drop all priority 3
|
|
active.retain(|s| s.priority < 3);
|
|
if line_width(&active, separator) <= term_width as usize {
|
|
return active;
|
|
}
|
|
|
|
// Drop all priority 2
|
|
active.retain(|s| s.priority < 2);
|
|
active
|
|
}
|
|
|
|
/// Calculate total display width including separators.
|
|
/// Spacers suppress adjacent separators on both sides.
|
|
fn line_width(active: &[ActiveSection], separator: &str) -> usize {
|
|
let sep_w = format::display_width(separator);
|
|
let mut total = 0;
|
|
|
|
for (i, sec) in active.iter().enumerate() {
|
|
if i > 0 {
|
|
let prev = &active[i - 1];
|
|
if !prev.is_spacer && !sec.is_spacer {
|
|
total += sep_w;
|
|
}
|
|
}
|
|
total += format::display_width(&sec.output.raw);
|
|
}
|
|
|
|
total
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## src/layout/flex.rs — Flex expansion with context_bar rebuild
|
|
|
|
```rust
|
|
use crate::layout::ActiveSection;
|
|
use crate::section::RenderContext;
|
|
use crate::format;
|
|
|
|
/// Expand the winning flex section to fill remaining terminal width.
|
|
///
|
|
/// Rules:
|
|
/// - Spacers take priority over non-spacer flex sections
|
|
/// - Only one flex section wins per line
|
|
/// - Spacer: fill with spaces
|
|
/// - context_bar: rebuild bar with wider width (recalculate filled/empty chars)
|
|
/// - Other: pad with trailing spaces
|
|
pub fn flex_expand(
|
|
active: &mut Vec<ActiveSection>,
|
|
ctx: &RenderContext,
|
|
separator: &str,
|
|
) -> Option<()> {
|
|
let current_width = line_width(active, separator);
|
|
let term_width = ctx.term_width as usize;
|
|
|
|
if current_width >= term_width {
|
|
return Some(());
|
|
}
|
|
|
|
// Find winning flex section: spacer wins over non-spacer
|
|
let mut flex_idx: Option<usize> = None;
|
|
for (i, sec) in active.iter().enumerate() {
|
|
if !sec.is_flex { continue; }
|
|
match flex_idx {
|
|
None => flex_idx = Some(i),
|
|
Some(prev) => {
|
|
if sec.is_spacer && !active[prev].is_spacer {
|
|
flex_idx = Some(i);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let idx = flex_idx?;
|
|
let extra = term_width - current_width;
|
|
|
|
if active[idx].is_spacer {
|
|
let padding = " ".repeat(extra + 1);
|
|
active[idx].output = crate::section::SectionOutput {
|
|
raw: padding.clone(),
|
|
ansi: padding,
|
|
};
|
|
} else if active[idx].id == "context_bar" {
|
|
// Rebuild context_bar with wider bar_width
|
|
let cur_bar_width = ctx.config.sections.context_bar.bar_width;
|
|
let new_bar_width = cur_bar_width + extra as u16;
|
|
if let Some(mut output) = crate::section::context_bar::render_at_width(ctx, new_bar_width) {
|
|
// Re-apply formatting after flex rebuild
|
|
let base = &ctx.config.sections.context_bar.base;
|
|
format::apply_formatting(
|
|
&mut output.raw, &mut output.ansi,
|
|
base, ctx.theme, &ctx.config.colors,
|
|
);
|
|
active[idx].output = output;
|
|
}
|
|
} else {
|
|
let padding = " ".repeat(extra);
|
|
active[idx].output.raw.push_str(&padding);
|
|
active[idx].output.ansi.push_str(&padding);
|
|
}
|
|
|
|
Some(())
|
|
}
|
|
|
|
fn line_width(active: &[ActiveSection], separator: &str) -> usize {
|
|
let sep_w = format::display_width(separator);
|
|
let mut total = 0;
|
|
for (i, sec) in active.iter().enumerate() {
|
|
if i > 0 {
|
|
let this_gap = sep_w + format::display_width(&sec.output.raw);
|
|
total += this_gap;
|
|
} else {
|
|
total += format::display_width(&sec.output.raw);
|
|
}
|
|
}
|
|
total
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## src/layout/justify.rs — Spread/space-between gap math
|
|
|
|
```rust
|
|
use crate::color;
|
|
use crate::config::JustifyMode;
|
|
use crate::format;
|
|
use crate::layout::ActiveSection;
|
|
|
|
/// Distribute extra space evenly across gaps between sections.
|
|
/// Center the separator core (e.g., `|` from ` | `) within each gap.
|
|
/// Remainder chars distributed left-to-right.
|
|
pub fn justify(
|
|
active: &[ActiveSection],
|
|
term_width: u16,
|
|
separator: &str,
|
|
mode: JustifyMode,
|
|
) -> String {
|
|
let content_width: usize = active.iter()
|
|
.map(|s| format::display_width(&s.output.raw))
|
|
.sum();
|
|
|
|
let num_gaps = active.len().saturating_sub(1);
|
|
if num_gaps == 0 {
|
|
return active.first().map(|s| s.output.ansi.clone()).unwrap_or_default();
|
|
}
|
|
|
|
let available = (term_width as usize).saturating_sub(content_width);
|
|
let gap_width = available / num_gaps;
|
|
let gap_remainder = available % num_gaps;
|
|
|
|
// Extract separator core (non-space chars, e.g. "|" from " | ")
|
|
let sep_core = separator.trim();
|
|
let sep_core_len = format::display_width(sep_core);
|
|
|
|
let mut output = String::new();
|
|
for (i, sec) in active.iter().enumerate() {
|
|
if i > 0 {
|
|
let this_gap = gap_width + if i - 1 < gap_remainder { 1 } else { 0 };
|
|
let gap_str = build_gap(sep_core, sep_core_len, this_gap);
|
|
output.push_str(&format!("{}{gap_str}{}", color::DIM, color::RESET));
|
|
}
|
|
output.push_str(&sec.output.ansi);
|
|
}
|
|
|
|
output
|
|
}
|
|
|
|
/// Center the separator core within a gap of `total` columns.
|
|
fn build_gap(core: &str, core_len: usize, total: usize) -> String {
|
|
if core.is_empty() || core_len == 0 {
|
|
return " ".repeat(total);
|
|
}
|
|
|
|
let pad_total = total.saturating_sub(core_len);
|
|
let pad_left = pad_total / 2;
|
|
let pad_right = pad_total - pad_left;
|
|
|
|
format!("{}{core}{}", " ".repeat(pad_left), " ".repeat(pad_right))
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## src/bin/claude-statusline.rs — CLI entry point
|
|
|
|
```rust
|
|
use claude_statusline::{config, input, theme, width, cache, color, section};
|
|
use claude_statusline::section::RenderContext;
|
|
use std::io::Read;
|
|
|
|
fn main() {
|
|
let args: Vec<String> = std::env::args().collect();
|
|
|
|
// Parse CLI flags (no clap — only 4 flags)
|
|
if args.iter().any(|a| a == "--help" || a == "-h") {
|
|
print_help();
|
|
return;
|
|
}
|
|
if args.iter().any(|a| a == "--config-schema") {
|
|
println!("{}", include_str!("../../schema.json"));
|
|
return;
|
|
}
|
|
if args.iter().any(|a| a == "--print-defaults") {
|
|
println!("{}", include_str!("../../defaults.json"));
|
|
return;
|
|
}
|
|
if args.iter().any(|a| a == "--list-sections") {
|
|
for (id, _) in section::registry() {
|
|
println!("{id}");
|
|
}
|
|
return;
|
|
}
|
|
|
|
let cli_color = args.iter()
|
|
.find_map(|a| a.strip_prefix("--color="))
|
|
.map(|s| s.to_string());
|
|
|
|
let config_path = args.iter()
|
|
.position(|a| a == "--config")
|
|
.and_then(|i| args.get(i + 1))
|
|
.map(|s| s.as_str());
|
|
|
|
let cli_width = args.iter()
|
|
.find_map(|a| a.strip_prefix("--width=").or_else(|| {
|
|
args.iter().position(|x| x == "--width")
|
|
.and_then(|i| args.get(i + 1))
|
|
.map(|s| s.as_str())
|
|
}))
|
|
.and_then(|s| s.parse::<u16>().ok());
|
|
|
|
let no_cache = args.iter().any(|a| a == "--no-cache")
|
|
|| std::env::var("CLAUDE_STATUSLINE_NO_CACHE").is_ok();
|
|
let no_shell = args.iter().any(|a| a == "--no-shell")
|
|
|| std::env::var("CLAUDE_STATUSLINE_NO_SHELL").is_ok();
|
|
let clear_cache = args.iter().any(|a| a == "--clear-cache");
|
|
|
|
let is_test = args.iter().any(|a| a == "--test");
|
|
let dump_state = args.iter().find_map(|a| {
|
|
if a == "--dump-state" { Some("text") }
|
|
else { a.strip_prefix("--dump-state=") }
|
|
});
|
|
let validate_config = args.iter().any(|a| a == "--validate-config");
|
|
|
|
// Load config
|
|
let (config, warnings) = match config::load_config(config_path) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
eprintln!("claude-statusline: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
|
|
if validate_config {
|
|
if warnings.is_empty() {
|
|
println!("config valid");
|
|
std::process::exit(0);
|
|
} else {
|
|
for w in &warnings {
|
|
eprintln!("{w}");
|
|
}
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
// Warn on unknown keys (non-fatal)
|
|
if config.global.warn_unknown_keys {
|
|
for w in &warnings {
|
|
eprintln!("claude-statusline: {w}");
|
|
}
|
|
}
|
|
|
|
// Read stdin JSON
|
|
let input_data: input::InputData = if is_test {
|
|
serde_json::from_str(include_str!("../../test_data.json"))
|
|
.unwrap_or_default()
|
|
} else {
|
|
let mut buf = String::new();
|
|
if std::io::stdin().read_to_string(&mut buf).is_err() || buf.is_empty() {
|
|
return; // empty stdin -> exit 0, no output
|
|
}
|
|
match serde_json::from_str(&buf) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
eprintln!("claude-statusline: stdin: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Detect environment
|
|
let detected_theme = theme::detect_theme(&config);
|
|
let term_width = width::detect_width(cli_width, config.global.width, config.global.width_margin);
|
|
let tier = width::width_tier(
|
|
term_width,
|
|
config.global.breakpoints.narrow,
|
|
config.global.breakpoints.medium,
|
|
);
|
|
|
|
let project_dir = input_data.workspace.as_ref()
|
|
.and_then(|w| w.project_dir.as_deref())
|
|
.unwrap_or(".");
|
|
|
|
let session = cache::session_id(project_dir);
|
|
let cache = cache::Cache::new(&config.global.cache_dir, &session);
|
|
|
|
let color_enabled = color::should_use_color(
|
|
cli_color.as_deref(),
|
|
&config.global.color,
|
|
);
|
|
|
|
let vcs_type = detect_vcs(project_dir, &config);
|
|
|
|
// Handle --dump-state
|
|
if let Some(format) = dump_state {
|
|
dump_state_output(format, &config, term_width, tier, detected_theme, vcs_type, project_dir, &session);
|
|
return;
|
|
}
|
|
|
|
// Build render context
|
|
let project_path = std::path::Path::new(project_dir);
|
|
let ctx = RenderContext {
|
|
input: &input_data,
|
|
config: &config,
|
|
theme: detected_theme,
|
|
width_tier: tier,
|
|
term_width,
|
|
vcs_type,
|
|
project_dir: project_path,
|
|
cache: &cache,
|
|
glyphs_enabled: config.glyphs.enabled,
|
|
color_enabled,
|
|
};
|
|
|
|
// Render and output
|
|
let output = claude_statusline::layout::render_all(&ctx);
|
|
print!("{output}");
|
|
}
|
|
|
|
fn detect_vcs(dir: &str, config: &config::Config) -> section::VcsType {
|
|
let prefer = config.sections.vcs.prefer.as_str();
|
|
let path = std::path::Path::new(dir);
|
|
|
|
match prefer {
|
|
"jj" => {
|
|
if path.join(".jj").is_dir() { section::VcsType::Jj }
|
|
else { section::VcsType::None }
|
|
}
|
|
"git" => {
|
|
if path.join(".git").is_dir() { section::VcsType::Git }
|
|
else { section::VcsType::None }
|
|
}
|
|
_ => {
|
|
if path.join(".jj").is_dir() { section::VcsType::Jj }
|
|
else if path.join(".git").is_dir() { section::VcsType::Git }
|
|
else { section::VcsType::None }
|
|
}
|
|
}
|
|
}
|
|
|
|
fn dump_state_output(
|
|
format: &str,
|
|
config: &config::Config,
|
|
term_width: u16,
|
|
tier: width::WidthTier,
|
|
theme: theme::Theme,
|
|
vcs: section::VcsType,
|
|
project_dir: &str,
|
|
session_id: &str,
|
|
) {
|
|
// JSON output with comprehensive debug info
|
|
let json = serde_json::json!({
|
|
"terminal": {
|
|
"effective_width": term_width,
|
|
"width_margin": config.global.width_margin,
|
|
"width_tier": format!("{tier:?}"),
|
|
},
|
|
"theme": theme.as_str(),
|
|
"vcs": format!("{vcs:?}"),
|
|
"layout": {
|
|
"justify": format!("{:?}", config.global.justify),
|
|
"separator": &config.global.separator,
|
|
},
|
|
"paths": {
|
|
"project_dir": project_dir,
|
|
"cache_dir": config.global.cache_dir.replace("{session_id}", session_id),
|
|
},
|
|
"session_id": session_id,
|
|
});
|
|
|
|
match format {
|
|
"json" => println!("{}", serde_json::to_string_pretty(&json).unwrap()),
|
|
_ => println!("{json:#}"),
|
|
}
|
|
}
|
|
|
|
fn print_help() {
|
|
println!("claude-statusline — Fast, configurable status line for Claude Code
|
|
|
|
USAGE:
|
|
claude-statusline Read JSON from stdin, output status line
|
|
claude-statusline --test Render with mock data to validate config
|
|
claude-statusline --dump-state[=text|json] Output internal state for debugging
|
|
claude-statusline --validate-config Validate config (exit 0=ok, 1=errors)
|
|
claude-statusline --config-schema Print schema JSON to stdout
|
|
claude-statusline --print-defaults Print defaults JSON to stdout
|
|
claude-statusline --list-sections List all section IDs
|
|
claude-statusline --config <path> Use alternate config file
|
|
claude-statusline --no-cache Disable caching for this run
|
|
claude-statusline --no-shell Disable all shell-outs (serve stale cache)
|
|
claude-statusline --clear-cache Remove cache directory and exit
|
|
claude-statusline --width <cols> Force terminal width
|
|
claude-statusline --color=auto|always|never Override color detection
|
|
claude-statusline --help Show this help");
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 24 Built-in Sections
|
|
|
|
| Section | Color | Shell-out | Cached | Width-tier varies |
|
|
|---------|-------|-----------|--------|-------------------|
|
|
| model | bold | no | no | no |
|
|
| provider | dim | no | no | no |
|
|
| project | cyan | no | no | no (truncation only) |
|
|
| vcs | green/yellow/dim | git/jj | yes (3s/5s/30s) | yes (ahead/behind hidden narrow) |
|
|
| beads | yellow/green/blue/dim | br | yes (30s) | yes (counts hidden narrow) |
|
|
| context_bar | threshold | no | no | no (flex-expandable) |
|
|
| context_usage | threshold/dim | no | no | no |
|
|
| context_remaining | threshold/dim | no | no | no |
|
|
| tokens_raw | dim | no | no | yes (labels/decimals) |
|
|
| cache_efficiency | dim/green/boldgreen | no | no | no |
|
|
| cost | threshold | no | no | yes (decimal places) |
|
|
| cost_velocity | dim | no | no | no |
|
|
| token_velocity | dim | no | no | yes (suffix) |
|
|
| cost_trend | dim | no | trend file | no |
|
|
| context_trend | threshold | no | trend file | no |
|
|
| lines_changed | green/red | no | no | no |
|
|
| duration | dim | no | no | no |
|
|
| tools | dim | no | no | yes (label/last name) |
|
|
| turns | dim | no | no | no |
|
|
| load | dim | sysctl/proc | yes (10s) | no |
|
|
| version | dim | no | no | no |
|
|
| time | dim | date | no | no |
|
|
| output_style | magenta | no | no | no |
|
|
| hostname | dim | hostname | no | no |
|
|
| spacer | n/a | no | no | n/a (virtual, flex) |
|
|
| custom | configurable | bash -c | yes (user TTL) | no |
|
|
|
|
### Shell-out optimization
|
|
|
|
**Combined git call**: The bash version forks three separate git processes per render (branch, dirty, ahead/behind), each with independent cache TTLs. The Rust port combines these into a single `git status --porcelain=v2 --branch` call (with `-uno` when `untracked = "no"` and `-c status.submoduleSummary=false` when `submodules = false`; `fast_mode` skips untracked and submodules regardless) that returns branch name, ahead/behind counts, and dirty status in one fork. The three cache TTLs are preserved by caching the parsed sub-results independently (branch for 3s, dirty for 5s, ahead/behind for 30s), but all three are populated from a single execution when any one expires.
|
|
|
|
**Parallel shell-outs on cache miss**: When multiple shell-out sections have expired caches in the same render (e.g., vcs + load + beads), execute them in parallel using `std::thread::scope`. Cache hits are >90% of renders, so this only matters for the occasional cold render.
|
|
|
|
```rust
|
|
// Parallel shell-outs using std::thread::scope (borrows from calling scope, no 'static)
|
|
std::thread::scope(|s| {
|
|
let vcs_handle = s.spawn(|| {
|
|
shell::exec_with_timeout("git", &["-C", dir, "status", "--porcelain=v2", "--branch"],
|
|
None, Duration::from_millis(200))
|
|
});
|
|
let load_handle = s.spawn(|| {
|
|
shell::exec_with_timeout("sysctl", &["-n", "vm.loadavg"], None, Duration::from_millis(100))
|
|
});
|
|
let beads_handle = s.spawn(|| {
|
|
shell::exec_with_timeout("br", &["status", "--json"], None, Duration::from_millis(200))
|
|
});
|
|
|
|
let vcs_out = vcs_handle.join().ok().flatten();
|
|
let load_out = load_handle.join().ok().flatten();
|
|
let beads_out = beads_handle.join().ok().flatten();
|
|
// ... cache results ...
|
|
});
|
|
```
|
|
|
|
**Lazy shell-outs**: Layout planning happens before invoking shell-outs. Sections that are dropped by width or preset never execute their commands.
|
|
|
|
**Command timeouts + stable env**: All shell-outs use `global.shell_timeout_ms` (default: 200ms), cap output to `global.shell_max_output_bytes` (default: 8192), and merge `global.shell_env` with per-command env. Set `GIT_OPTIONAL_LOCKS=0`, `GIT_TERMINAL_PROMPT=0`, and `LC_ALL=C` to avoid lock contention, prompts, and locale-dependent parsing. On timeout or error, return stale cache if available.
|
|
|
|
**Allow/deny list**: If `global.shell_enabled = false` (or `--no-shell` / `CLAUDE_STATUSLINE_NO_SHELL`), or a command is not in `shell_allowlist` / is in `shell_denylist`, skip execution and return stale cache if present.
|
|
|
|
**Circuit breaker**: Track consecutive failures per command key (timeout or non-zero exit). If failures exceed `shell_failure_threshold` (default: 3), open a cooldown window (`shell_cooldown_ms`, default: 30s) and serve stale cache or `None` without executing the command. Resets on success or cooldown expiry.
|
|
|
|
---
|
|
|
|
## Layout Engine Algorithms
|
|
|
|
### Priority Drop
|
|
When total width exceeds terminal width:
|
|
If `drop_strategy = "tiered"` (default):
|
|
1. Remove ALL priority 3 sections at once. Recalculate.
|
|
2. If still too wide, remove ALL priority 2 sections at once.
|
|
3. Priority 1 sections never drop.
|
|
|
|
If `drop_strategy = "gradual"`:
|
|
1. Drop sections one-by-one by (priority desc, width cost desc, rightmost first), recompute after each removal.
|
|
2. Priority 1 sections never drop.
|
|
|
|
### Flex Expansion
|
|
When total width is less than terminal width and a flex section exists:
|
|
- Spacers take priority over non-spacer flex sections
|
|
- Only one flex section wins per line
|
|
- **Spacer**: Fill with spaces
|
|
- **context_bar**: Rebuild bar with wider width (recalculate filled/empty chars)
|
|
- **Other**: Pad with trailing spaces
|
|
|
|
### Justify Modes
|
|
- **left** (default): Fixed separators, left-packed. Flex expansion applies.
|
|
- **spread / space-between**: Distribute extra space evenly across gaps. Center separator core (e.g., `|`) within each gap. Remainder chars distributed left-to-right. Bypassed if any spacer is present.
|
|
|
|
### Separator Handling
|
|
- Default separator: ` | ` (configurable)
|
|
- Spacers suppress adjacent separators on both sides
|
|
- Separators rendered dim
|
|
- SEP_CORE = non-space portion (e.g., `|` from ` | `)
|
|
|
|
### Responsive Layout
|
|
When `global.responsive = true` and layout is a preset name (string):
|
|
- width < 60: use "dense" preset (1 line)
|
|
- 60-99: use "standard" preset (2 lines)
|
|
- >= 100: use "verbose" preset (3 lines)
|
|
|
|
Use `breakpoint_hysteresis` (default: 2) to avoid toggling presets if width fluctuates within ±hysteresis columns of a breakpoint.
|
|
|
|
Explicit array layouts ignore responsive mode.
|
|
|
|
### Render Pipeline (lazy)
|
|
1. Resolve layout preset -> ordered section ids by line
|
|
2. Build a RenderPlan using cached widths or fixed estimates per section
|
|
3. Start render budget timer; render only included sections (shell-outs skipped for dropped sections)
|
|
4. Recompute widths from real outputs; if overflow remains, apply one bounded reflow (priority drop + flex rebuild) to avoid oscillation
|
|
5. If `global.render_budget_ms` (default: 8ms) is exceeded, drop remaining lowest-priority sections and emit partial output
|
|
|
|
---
|
|
|
|
## Architectural Decisions
|
|
|
|
| Decision | Choice | Rationale |
|
|
|----------|--------|-----------|
|
|
| Section system | Registry of `SectionDescriptor { id, render_fn, estimated_width, shell_out }` | Single source of truth for IDs, validation, CLI introspection |
|
|
| Config types | Fully typed structs with `#[serde(flatten)]` for shared SectionBase | Compile-time safety, IDE completion, no stringly-typed lookups |
|
|
| Layout pipeline | 3-phase: Plan → Render survivors → Reflow | Lazy: dropped sections never shell-out |
|
|
| Color override | `apply_formatting` re-wraps `raw` text (discards section's internal ANSI) | Matches bash behavior where user color overrides trump section defaults |
|
|
| Shell-out timeout | Polling `try_wait()` + 5ms sleep | No extra crate, bounded latency |
|
|
| Parallel shell-outs | `std::thread::scope` | Borrows from calling scope, no `'static` requirement |
|
|
| Width memoization | `Mutex<Option<(u16, Instant)>>` with 1s TTL | Avoids repeating process tree walk on 2-3 consecutive renders |
|
|
| Cache concurrency | `flock(LOCK_EX \| LOCK_NB)` | Skip cache on contention rather than blocking render |
|
|
| Render budget | `global.render_budget_ms` (default: 8ms) with graceful degradation | Prevents UI stalls from cascading shell-out delays |
|
|
| Shell circuit breaker | Per-command failure tracking with cooldown window | Stops hammering failing commands; serves stale cache instead |
|
|
| Config discovery | XDG + dot-config + legacy `~/.claude` fallback chain | Modern tooling compat without breaking existing users |
|
|
|
|
---
|
|
|
|
## Width Detection Priority Chain
|
|
|
|
1. `--width <cols>` CLI flag
|
|
2. `CLAUDE_STATUSLINE_WIDTH` env var
|
|
3. `global.width` config (explicit override)
|
|
4. `ioctl(TIOCGWINSZ)` on stdout fd (zero-cost syscall, correct when stdout is a real TTY)
|
|
5. Process tree walk: start at PID, walk ppid chain, find TTY, `stty size < /dev/{tty}` (handles multiplexed terminals where stdout is a pipe)
|
|
6. `stty size < /dev/tty`
|
|
7. `$COLUMNS` env var
|
|
8. `tput cols`
|
|
9. Fallback: 120
|
|
|
|
Final width = detected - `global.width_margin` (default: 4)
|
|
|
|
**Memoization**: Cache detected width for 1 second. Since renders happen every ~300ms, this avoids repeating the process tree walk on 2-3 consecutive renders. In-memory only (a `(u16, Instant)` tuple), not file-based.
|
|
|
|
Width tiers (for progressive disclosure within sections):
|
|
- narrow: < breakpoints.narrow (default 60)
|
|
- medium: < breakpoints.medium (default 100)
|
|
- wide: >= breakpoints.medium
|
|
|
|
---
|
|
|
|
## Cache System
|
|
|
|
### Session ID
|
|
MD5 of `workspace.project_dir`, truncated to 12 hex chars. Same algorithm as bash for cache sharing during migration.
|
|
|
|
### Cache Namespace
|
|
`{session_id}-{cache_version}-{config_hash}` to avoid stale data across upgrades or config edits.
|
|
|
|
### Directory
|
|
Template: `/tmp/claude-sl-{session_id}-{cache_version}-{config_hash}/` (configurable). Created with `chmod 700`. Ownership verified (not a symlink, owned by current user, not world-writable). Caching disabled if suspicious.
|
|
|
|
### Cache GC
|
|
- At most once per `cache_gc_interval_hours` (default: 24), scan `/tmp/claude-sl-*` and delete dirs older than `cache_gc_days` (default: 7), owned by current user, not symlinks.
|
|
- Use a lock file in `/tmp` to avoid concurrent GC across renders.
|
|
- GC runs asynchronously after render output is written, never blocking the status line.
|
|
|
|
### Per-key Caching
|
|
- File per key: `$CACHE_DIR/$sanitized_key`
|
|
- TTL checked via file mtime vs current time
|
|
- **TTL jitter**: Add a random +/- `cache_ttl_jitter_pct` (default: 10%) to TTL to desynchronize expiration across concurrent renders
|
|
- **Atomic writes**: Write to `$key.tmp`, then `rename()` to `$key` (prevents partial reads if process is killed mid-write)
|
|
- **Stale fallback**: On command failure, return previous cached value if it exists (prevents status line flicker during transient errors like git lock contention)
|
|
- Key sanitization: `[^a-zA-Z0-9_-]` -> `_`
|
|
- **Concurrency guard**: Use per-key lock files with `flock` (short wait, then no-cache) to prevent interleaved writes across concurrent renders. Trend appends use the same lock.
|
|
|
|
### Trend Tracking
|
|
- Append-only comma-separated files: `$CACHE_DIR/trend_{key}`
|
|
- **Write throttling**: Append at most once per 5 seconds (configurable). This is for sparkline data quality — 8 points at 5s intervals covers 40s of history (meaningful trends), vs 8 points at 300ms covering 2.4s (noise).
|
|
- Skip append if value unchanged from last point
|
|
- Max N points (default 8), trim from left
|
|
- Sparkline: 8 Unicode block chars (▁▂▃▄▅▆▇█), normalized to min/max range
|
|
- **Flat-series guard**: When min == max, render mid-height blocks (▄) for all points (prevents divide-by-zero in normalization)
|
|
|
|
---
|
|
|
|
## Implementation Phases
|
|
|
|
### Phase 1: Skeleton + Pure Sections
|
|
- `main.rs`: CLI flags, stdin read, `--test` with mock data, `--help`
|
|
- `config.rs`: Load embedded defaults, merge user config (recursive serde_json::Value merge)
|
|
- `input.rs`: Deserialize stdin JSON
|
|
- `color.rs`: ANSI constants, `color_by_name()` with compound styles and palette refs
|
|
- `theme.rs`: COLORFGBG parsing
|
|
- `format.rs`: `human_tokens()`, `human_duration()`, truncation (right/middle/left, grapheme-cluster-safe using `unicode-segmentation`)
|
|
- `metrics.rs`: Compute derived values once (cost velocity, token velocity, usage %, totals), reused by all sections
|
|
- Port 17 pure sections (no shell-outs):
|
|
model, provider, project, context_bar, context_usage, context_remaining, tokens_raw, cache_efficiency,
|
|
cost, cost_velocity, token_velocity, lines_changed, duration, tools, turns,
|
|
version, output_style
|
|
- `section/mod.rs`: Dispatch, `apply_formatting()`, `apply_truncation()`
|
|
- **Verify**: `--test` produces correct output for all pure sections
|
|
|
|
**Exit criteria**:
|
|
- `claude-statusline --test` succeeds and produces stable output
|
|
- Config load + deep merge + defaults verified with unit tests
|
|
|
|
**Non-goals**:
|
|
- No shell-outs
|
|
- No cache system yet
|
|
|
|
### Phase 2: Layout Engine
|
|
- `layout/mod.rs`: `resolve_layout()` (preset lookup, responsive override)
|
|
- `layout/priority.rs`: Drop tier 3 all at once, then tier 2. Never tier 1.
|
|
- `layout/flex.rs`: Spacer expansion, context_bar rebuild, generic padding
|
|
- `layout/justify.rs`: Spread/space-between gap math with centered separator core
|
|
- Width tier calculation (narrow < 60, medium 60-99, wide >= 100)
|
|
- Separator handling (spacers suppress adjacent separators)
|
|
- All width calculations use `unicode-width::UnicodeWidthStr::width()` on raw text, not `.len()` or `.chars().count()`. Correctly handles CJK (2 cells), Nerd Font glyphs (1 or 2 cells), and zero-width joiners.
|
|
- **Verify**: Parity test - same stdin JSON at 80/120/175 cols = same raw output as bash
|
|
|
|
**Exit criteria**:
|
|
- Parity tests pass for layout at 3 widths
|
|
- Width tiering and drop strategy behave deterministically
|
|
|
|
### Phase 3: Width Detection + Cache + Shell-out Sections
|
|
- `width.rs`: Full priority chain (cli > env > config > ioctl > process tree walk > stty > COLUMNS > tput > 120), 1s in-memory memoization
|
|
- `cache.rs`: Secure dir (symlink + ownership + world-writable checks), per-key TTL via mtime, atomic write-rename, stale fallback on command failure
|
|
- `trend.rs`: Append-only files, sparkline, write throttling (5s), flat-series guard
|
|
- `glyph.rs`: Nerd Font + ASCII fallback
|
|
- Port shell-out sections: vcs (combined `git status --porcelain=v2 --branch` + jj), beads, load, hostname, time, custom commands
|
|
- Parallel shell-out execution on cache miss (`std::thread::scope`)
|
|
- **Verify**: Full parity with bash version including VCS and cache
|
|
|
|
**Exit criteria**:
|
|
- All shell-outs respect timeout and cache
|
|
- Render time under budget in warm runs
|
|
|
|
### Phase 4: Polish + Validation
|
|
- `--dump-state` (enhanced: width detection source, per-section render timing in microseconds, priority drop reasons, cache hit/miss per key, trend throttle state; supports `text` and `json` output)
|
|
- `--validate-config` (strict deserialize with path-aware errors via `serde_path_to_error` for unknown keys, type mismatches, deprecated fields; exit 0 = valid, exit 1 = errors)
|
|
- `--config-schema` (print schema JSON to stdout)
|
|
- `--print-defaults` (print defaults JSON to stdout)
|
|
- `--list-sections` (list all registered section IDs with metadata)
|
|
- Comprehensive snapshot tests
|
|
|
|
**Exit criteria**:
|
|
- Snapshot tests stable across 3 widths
|
|
- `--validate-config` errors are actionable
|
|
|
|
### Phase 5: Distribution (post-MVP)
|
|
- Cross-compile: aarch64-apple-darwin, x86_64-apple-darwin, x86_64-unknown-linux-gnu
|
|
- GitHub Actions: CI (fmt, clippy, test) + Release (build, tar, checksums)
|
|
- Homebrew tap formula
|
|
- Update install.sh to prefer Rust binary
|
|
- Update README
|
|
|
|
---
|
|
|
|
## Error Handling
|
|
|
|
- **Startup**: Fail fast on explicit config path not found or stdin parse error
|
|
- **Sections**: Return `Option<SectionOutput>` - missing data = None = section skipped; unknown section IDs become validation errors via registry
|
|
- **Shell-outs**: Disabled (`--no-shell`), circuit-open, or denied by allow/deny list = skip and return stale cache if present; otherwise command failure or timeout = None unless stale cache is present
|
|
- **Cache**: Creation failure = disable caching, run commands directly
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
1. **Unit tests**: Per-module (config merge, format helpers, color resolution, sparkline, priority drop, flex, justify)
|
|
2. **Snapshot tests**: Known JSON input -> expected raw output at specific widths
|
|
3. **Parity tests**: Run both bash and Rust with same input, diff raw output (ANSI stripped)
|
|
4. **Benchmarks**: `criterion` measuring parse-to-stdout. Target: <1ms warm
|
|
5. **Config validation tests**: `--validate-config` rejects unknown keys and type mismatches
|
|
|
|
---
|
|
|
|
## Verification
|
|
|
|
```bash
|
|
# Smoke test
|
|
echo '{"model":{"id":"claude-opus-4-6"},"cost":{"total_cost_usd":0.42}}' | claude-statusline
|
|
|
|
# Parity with bash
|
|
echo "$TEST_JSON" | bash statusline.sh 2>/dev/null > /tmp/bash_out.txt
|
|
echo "$TEST_JSON" | claude-statusline > /tmp/rust_out.txt
|
|
diff <(sed 's/\x1b\[[0-9;]*m//g' /tmp/bash_out.txt) <(sed 's/\x1b\[[0-9;]*m//g' /tmp/rust_out.txt)
|
|
|
|
# Performance comparison
|
|
hyperfine --warmup 3 'echo "$TEST_JSON" | claude-statusline' 'echo "$TEST_JSON" | bash statusline.sh'
|
|
```
|
|
|
|
---
|
|
|
|
## Distribution
|
|
|
|
- `cargo install claude-statusline` (crates.io)
|
|
- `brew install tayloreernisse/tap/claude-statusline` (Homebrew tap, pre-built)
|
|
- GitHub Releases (direct download, macOS arm64+x86_64, Linux x86_64)
|
|
|
|
---
|
|
|
|
## Switchover
|
|
|
|
Drop-in replacement. Change `~/.claude/settings.json`:
|
|
```json
|
|
{ "statusLine": "claude-statusline" }
|
|
```
|
|
Bash version remains available as fallback. Cache directories are compatible.
|
|
|
|
---
|
|
|
|
## Edge Cases to Port Correctly
|
|
|
|
1. Empty stdin -> exit 0, no output
|
|
2. `null` JSON values -> treated as missing (not the string "null")
|
|
3. Config deep merge: nested objects merged recursively, arrays replaced entirely
|
|
4. Separator core extraction: trim leading/trailing spaces from separator
|
|
5. context_bar flex rebuild: must re-apply `apply_formatting()` after
|
|
6. jj ahead/behind: hardcoded to 0/0 (jj has no upstream concept)
|
|
7. Tool name truncation: >12 chars -> 11 chars + `~` (must use display width, not char count)
|
|
8. Beads parts joined with ` | ` (literal, not global separator)
|
|
9. `stat -f %m` (macOS) vs `stat -c %Y` (Linux) for mtime — Rust uses `std::fs::metadata().modified()` (portable)
|
|
10. Session ID: first 12 chars of MD5 hex of project_dir
|
|
11. Truncation must split on grapheme cluster boundaries (not bytes or chars)
|
|
12. Stale cache fallback: on shell-out failure, return previous cached value if exists
|
|
13. Trend sparkline flat series: min == max -> mid-height block (▄) for all points
|
|
14. ioctl(TIOCGWINSZ) returns 0 when stdout is a pipe -- must fall through to process tree walk
|
|
15. Combined `git status --porcelain=v2 --branch` may fail on repos with no commits -- fall back to individual commands
|
|
16. Circuit breaker must track failures per command key in-memory (not cached) so it resets on process restart
|
|
17. Cache GC must only delete dirs owned by current user and must not follow symlinks
|
|
18. XDG config discovery: only use XDG/dot-config paths if the file actually exists (don't create empty files)
|
|
19. Render budget: partial output must still be valid ANSI (no unclosed escape sequences) |