feat: complete Rust port of claude-statusline
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>
This commit is contained in:
288
src/bin/claude-statusline.rs
Normal file
288
src/bin/claude-statusline.rs
Normal file
@@ -0,0 +1,288 @@
|
||||
use claude_statusline::section::RenderContext;
|
||||
use claude_statusline::{cache, color, config, input, layout, metrics, section, theme, width};
|
||||
use std::io::Read;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
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: Option<u16> = args.iter().find_map(|a| {
|
||||
a.strip_prefix("--width=")
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| {
|
||||
args.iter()
|
||||
.position(|x| x == "--width")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.and_then(|s| s.parse().ok())
|
||||
})
|
||||
});
|
||||
|
||||
let is_test = args.iter().any(|a| a == "--test");
|
||||
let validate_config = args.iter().any(|a| a == "--validate-config");
|
||||
let dump_state = args.iter().find_map(|a| {
|
||||
if a == "--dump-state" {
|
||||
Some("text")
|
||||
} else {
|
||||
a.strip_prefix("--dump-state=")
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
test_input()
|
||||
} else {
|
||||
let mut buf = String::new();
|
||||
if std::io::stdin().read_to_string(&mut buf).is_err() || buf.is_empty() {
|
||||
return;
|
||||
}
|
||||
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 computed_metrics = metrics::ComputedMetrics::from_input(&input_data);
|
||||
|
||||
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,
|
||||
metrics: computed_metrics,
|
||||
};
|
||||
|
||||
let output = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
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,
|
||||
) {
|
||||
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 test_input() -> input::InputData {
|
||||
serde_json::from_str(
|
||||
r#"{
|
||||
"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"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"claude-statusline \u{2014} 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"
|
||||
);
|
||||
}
|
||||
154
src/cache.rs
Normal file
154
src/cache.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
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.
|
||||
pub fn get(&self, key: &str, ttl: Duration) -> Option<String> {
|
||||
let path = self.key_path(key)?;
|
||||
let meta = fs::metadata(&path).ok()?;
|
||||
let modified = meta.modified().ok()?;
|
||||
let age = SystemTime::now().duration_since(modified).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 {
|
||||
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))]
|
||||
{
|
||||
let _ = file;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn unlock(file: &fs::File) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::io::AsRawFd;
|
||||
unsafe {
|
||||
libc::flock(file.as_raw_fd(), libc::LOCK_UN);
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = file;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::{Digest, Md5};
|
||||
let hash = Md5::digest(project_dir.as_bytes());
|
||||
format!("{:x}", hash)[..12].to_string()
|
||||
}
|
||||
76
src/color.rs
Normal file
76
src/color.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
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).
|
||||
pub fn resolve_color(name: &str, theme: Theme, palette: &ThemeColors) -> String {
|
||||
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();
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine whether color output should be used.
|
||||
pub fn should_use_color(cli_color: Option<&str>, config_color: &crate::config::ColorMode) -> bool {
|
||||
if std::env::var("NO_COLOR").is_ok() {
|
||||
return false;
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
721
src/config.rs
Normal file
721
src/config.rs
Normal file
@@ -0,0 +1,721 @@
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const DEFAULTS_JSON: &str = include_str!("../defaults.json");
|
||||
|
||||
// ── Top-level Config ────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
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(std::path::PathBuf::from)
|
||||
.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(),
|
||||
));
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
35
src/error.rs
Normal file
35
src/error.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
177
src/format.rs
Normal file
177
src/format.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
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 {
|
||||
// Strip ANSI escape sequences before measuring
|
||||
let stripped = strip_ansi(s);
|
||||
UnicodeWidthStr::width(stripped.as_str())
|
||||
}
|
||||
|
||||
fn strip_ansi(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut in_escape = false;
|
||||
for c in s.chars() {
|
||||
if in_escape {
|
||||
if c.is_ascii_alphabetic() {
|
||||
in_escape = false;
|
||||
}
|
||||
} else if c == '\x1b' {
|
||||
in_escape = true;
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Format token count: 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.
|
||||
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;
|
||||
}
|
||||
result.push_str(g);
|
||||
w += gw;
|
||||
}
|
||||
result.push('\u{2026}');
|
||||
result
|
||||
}
|
||||
|
||||
fn truncate_middle(graphemes: &[&str], max: usize) -> String {
|
||||
let half = (max - 1) / 2;
|
||||
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}\u{2026}{}", right_graphemes.join(""))
|
||||
}
|
||||
|
||||
fn truncate_left(graphemes: &[&str], max: usize) -> String {
|
||||
let budget = max - 1;
|
||||
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!("\u{2026}{}", parts.join(""))
|
||||
}
|
||||
|
||||
/// Apply per-section formatting: prefix, suffix, color override, pad+align.
|
||||
pub fn apply_formatting(
|
||||
raw: &mut String,
|
||||
ansi: &mut String,
|
||||
base: &SectionBase,
|
||||
theme: Theme,
|
||||
palette: &crate::config::ThemeColors,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
|
||||
if let Some(ref color_name) = base.color {
|
||||
let c = color::resolve_color(color_name, theme, palette);
|
||||
*ansi = format!("{c}{raw}{}", color::RESET);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/glyph.rs
Normal file
13
src/glyph.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
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("")
|
||||
}
|
||||
60
src/input.rs
Normal file
60
src/input.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
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>,
|
||||
}
|
||||
82
src/layout/flex.rs
Normal file
82
src/layout/flex.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use crate::format;
|
||||
use crate::layout::ActiveSection;
|
||||
use crate::section::{self, RenderContext};
|
||||
|
||||
/// 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
|
||||
/// - Other: pad with trailing spaces
|
||||
pub fn flex_expand(active: &mut [ActiveSection], ctx: &RenderContext, separator: &str) {
|
||||
let current_width = line_width(active, separator);
|
||||
let term_width = ctx.term_width as usize;
|
||||
|
||||
if current_width >= term_width {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 Some(idx) = flex_idx else { return };
|
||||
let extra = term_width - current_width;
|
||||
|
||||
if active[idx].is_spacer {
|
||||
let padding = " ".repeat(extra + 1);
|
||||
active[idx].output = 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) = 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);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
63
src/layout/justify.rs
Normal file
63
src/layout/justify.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
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 + usize::from(i - 1 < gap_remainder);
|
||||
let gap_str = build_gap(sep_core, sep_core_len, this_gap);
|
||||
output.push_str(&format!(
|
||||
"{}{gap_str}{}",
|
||||
crate::color::DIM,
|
||||
crate::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))
|
||||
}
|
||||
177
src/layout/mod.rs
Normal file
177
src/layout/mod.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
pub mod flex;
|
||||
pub mod justify;
|
||||
pub mod priority;
|
||||
|
||||
use crate::config::{Config, JustifyMode, LayoutValue};
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")
|
||||
}
|
||||
|
||||
/// Render a single layout line.
|
||||
/// Three phases: render all -> priority drop -> flex/justify.
|
||||
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 {
|
||||
if let Some(output) = section::render_section(id, ctx) {
|
||||
if output.raw.is_empty() && !section::is_spacer(id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (prio, is_flex) = section_meta(id, ctx.config);
|
||||
active.push(ActiveSection {
|
||||
id: id.clone(),
|
||||
output,
|
||||
priority: prio,
|
||||
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 = 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
|
||||
{
|
||||
justify::justify(
|
||||
&active,
|
||||
ctx.term_width,
|
||||
separator,
|
||||
ctx.config.global.justify,
|
||||
)
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
/// Look up section priority and flex from config.
|
||||
fn section_meta(id: &str, config: &Config) -> (u8, bool) {
|
||||
if section::is_spacer(id) {
|
||||
return (1, true);
|
||||
}
|
||||
|
||||
macro_rules! meta_base {
|
||||
($section:expr) => {
|
||||
($section.priority, $section.flex)
|
||||
};
|
||||
}
|
||||
macro_rules! meta_flat {
|
||||
($section:expr) => {
|
||||
($section.base.priority, $section.base.flex)
|
||||
};
|
||||
}
|
||||
|
||||
match id {
|
||||
"model" => meta_base!(config.sections.model),
|
||||
"provider" => meta_base!(config.sections.provider),
|
||||
"project" => meta_flat!(config.sections.project),
|
||||
"vcs" => meta_flat!(config.sections.vcs),
|
||||
"beads" => meta_flat!(config.sections.beads),
|
||||
"context_bar" => meta_flat!(config.sections.context_bar),
|
||||
"context_usage" => meta_flat!(config.sections.context_usage),
|
||||
"context_remaining" => meta_flat!(config.sections.context_remaining),
|
||||
"tokens_raw" => meta_flat!(config.sections.tokens_raw),
|
||||
"cache_efficiency" => meta_base!(config.sections.cache_efficiency),
|
||||
"cost" => meta_flat!(config.sections.cost),
|
||||
"cost_velocity" => meta_base!(config.sections.cost_velocity),
|
||||
"token_velocity" => meta_base!(config.sections.token_velocity),
|
||||
"cost_trend" => meta_flat!(config.sections.cost_trend),
|
||||
"context_trend" => meta_flat!(config.sections.context_trend),
|
||||
"lines_changed" => meta_base!(config.sections.lines_changed),
|
||||
"duration" => meta_base!(config.sections.duration),
|
||||
"tools" => meta_flat!(config.sections.tools),
|
||||
"turns" => meta_flat!(config.sections.turns),
|
||||
"load" => meta_flat!(config.sections.load),
|
||||
"version" => meta_base!(config.sections.version),
|
||||
"time" => meta_flat!(config.sections.time),
|
||||
"output_style" => meta_base!(config.sections.output_style),
|
||||
"hostname" => meta_base!(config.sections.hostname),
|
||||
_ => (2, false), // custom sections default
|
||||
}
|
||||
}
|
||||
43
src/layout/priority.rs
Normal file
43
src/layout/priority.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use crate::format;
|
||||
use crate::layout::ActiveSection;
|
||||
|
||||
/// 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
|
||||
}
|
||||
15
src/lib.rs
Normal file
15
src/lib.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
pub mod error;
|
||||
pub use error::Error;
|
||||
pub mod cache;
|
||||
pub mod color;
|
||||
pub mod config;
|
||||
pub mod format;
|
||||
pub mod glyph;
|
||||
pub mod input;
|
||||
pub mod layout;
|
||||
pub mod metrics;
|
||||
pub mod section;
|
||||
pub mod shell;
|
||||
pub mod theme;
|
||||
pub mod trend;
|
||||
pub mod width;
|
||||
47
src/metrics.rs
Normal file
47
src/metrics.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use crate::input::InputData;
|
||||
|
||||
/// Derived metrics computed once from InputData, reused by all sections.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ComputedMetrics {
|
||||
pub total_tokens: u64,
|
||||
pub usage_pct: f64,
|
||||
pub cost_velocity: Option<f64>,
|
||||
pub token_velocity: Option<f64>,
|
||||
pub cache_efficiency_pct: Option<f64>,
|
||||
}
|
||||
|
||||
impl ComputedMetrics {
|
||||
pub fn from_input(input: &InputData) -> Self {
|
||||
let mut m = Self::default();
|
||||
|
||||
if let Some(ref cw) = input.context_window {
|
||||
let input_tok = cw.total_input_tokens.unwrap_or(0);
|
||||
let output_tok = cw.total_output_tokens.unwrap_or(0);
|
||||
m.total_tokens = input_tok + output_tok;
|
||||
m.usage_pct = cw.used_percentage.unwrap_or(0.0);
|
||||
|
||||
if let Some(ref usage) = cw.current_usage {
|
||||
let cache_read = usage.cache_read_input_tokens.unwrap_or(0);
|
||||
let cache_create = usage.cache_creation_input_tokens.unwrap_or(0);
|
||||
let total_cache = cache_read + cache_create;
|
||||
if total_cache > 0 {
|
||||
m.cache_efficiency_pct = Some(cache_read as f64 / total_cache as f64 * 100.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref cost) = input.cost {
|
||||
if let (Some(cost_usd), Some(duration_ms)) =
|
||||
(cost.total_cost_usd, cost.total_duration_ms)
|
||||
{
|
||||
if duration_ms > 0 {
|
||||
let minutes = duration_ms as f64 / 60_000.0;
|
||||
m.cost_velocity = Some(cost_usd / minutes);
|
||||
m.token_velocity = Some(m.total_tokens as f64 / minutes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m
|
||||
}
|
||||
}
|
||||
48
src/section/beads.rs
Normal file
48
src/section/beads.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use crate::color;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
use crate::shell;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.beads.base.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check if .beads/ exists in project dir
|
||||
if !ctx.project_dir.join(".beads").is_dir() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ttl = Duration::from_secs(ctx.config.sections.beads.ttl);
|
||||
let timeout = Duration::from_millis(200);
|
||||
|
||||
let cached = ctx.cache.get("beads_summary", ttl);
|
||||
let summary = cached.or_else(|| {
|
||||
// Run br ready to get count of ready items
|
||||
let out = shell::exec_with_timeout(
|
||||
"br",
|
||||
&["ready", "--json"],
|
||||
Some(ctx.project_dir.to_str()?),
|
||||
timeout,
|
||||
)?;
|
||||
// Count JSON array items (simple: count opening braces at indent level 1)
|
||||
let count = out.matches("\"id\"").count();
|
||||
let summary = format!("{count}");
|
||||
ctx.cache.set("beads_summary", &summary);
|
||||
Some(summary)
|
||||
})?;
|
||||
|
||||
let count: usize = summary.trim().parse().unwrap_or(0);
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let raw = format!("{count} ready");
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
29
src/section/cache_efficiency.rs
Normal file
29
src/section/cache_efficiency.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use crate::color;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.cache_efficiency.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let pct = ctx.metrics.cache_efficiency_pct?;
|
||||
let pct_int = pct.round() as u64;
|
||||
|
||||
let raw = format!("Cache: {pct_int}%");
|
||||
|
||||
let color_code = if pct >= 80.0 {
|
||||
color::GREEN
|
||||
} else if pct >= 50.0 {
|
||||
color::YELLOW
|
||||
} else {
|
||||
color::RED
|
||||
};
|
||||
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{color_code}{raw}{}", color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
45
src/section/context_bar.rs
Normal file
45
src/section/context_bar.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
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 = (u32::from(pct_int) * u32::from(bar_width) / 100) as usize;
|
||||
let empty = bar_width as usize - filled;
|
||||
|
||||
let bar = "=".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()
|
||||
}
|
||||
}
|
||||
43
src/section/context_remaining.rs
Normal file
43
src/section/context_remaining.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use crate::color;
|
||||
use crate::format;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.context_remaining.base.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cw = ctx.input.context_window.as_ref()?;
|
||||
let pct = cw.used_percentage.unwrap_or(0.0);
|
||||
let capacity = cw.context_window_size.unwrap_or(200_000);
|
||||
let used_tokens = (pct / 100.0 * capacity as f64) as u64;
|
||||
let remaining = capacity.saturating_sub(used_tokens);
|
||||
|
||||
let remaining_str = format::human_tokens(remaining);
|
||||
let raw = ctx
|
||||
.config
|
||||
.sections
|
||||
.context_remaining
|
||||
.format
|
||||
.replace("{remaining}", &remaining_str);
|
||||
|
||||
let thresh = &ctx.config.sections.context_remaining.thresholds;
|
||||
// Invert thresholds: high usage = low remaining = more danger
|
||||
let color_code = 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()
|
||||
};
|
||||
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{color_code}{raw}{}", color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
47
src/section/context_trend.rs
Normal file
47
src/section/context_trend.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use crate::color;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
use crate::trend;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.context_trend.base.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let pct = ctx.input.context_window.as_ref()?.used_percentage?;
|
||||
let pct_int = pct.round() as i64;
|
||||
|
||||
let width = ctx.config.sections.context_trend.width as usize;
|
||||
let csv = trend::append(
|
||||
ctx.cache,
|
||||
"context",
|
||||
pct_int,
|
||||
width,
|
||||
Duration::from_secs(30),
|
||||
)?;
|
||||
let spark = trend::sparkline(&csv, width);
|
||||
|
||||
if spark.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let thresh = &ctx.config.sections.context_trend.thresholds;
|
||||
let color_code = 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()
|
||||
};
|
||||
|
||||
let raw = spark.clone();
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{color_code}{spark}{}", color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
49
src/section/context_usage.rs
Normal file
49
src/section/context_usage.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use crate::color;
|
||||
use crate::format;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.context_usage.base.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cw = ctx.input.context_window.as_ref()?;
|
||||
let pct = cw.used_percentage?;
|
||||
let pct_int = pct.round() as u64;
|
||||
|
||||
let total_input = cw.total_input_tokens.unwrap_or(0);
|
||||
let total_output = cw.total_output_tokens.unwrap_or(0);
|
||||
let used = total_input + total_output;
|
||||
let capacity = cw
|
||||
.context_window_size
|
||||
.unwrap_or(ctx.config.sections.context_usage.capacity);
|
||||
|
||||
let raw = format!(
|
||||
"{}/{} ({pct_int}%)",
|
||||
format::human_tokens(used),
|
||||
format::human_tokens(capacity),
|
||||
);
|
||||
|
||||
let thresh = &ctx.config.sections.context_usage.thresholds;
|
||||
let color_code = threshold_color(pct, thresh);
|
||||
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{color_code}{raw}{}", color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
39
src/section/cost.rs
Normal file
39
src/section/cost.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
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 })
|
||||
}
|
||||
36
src/section/cost_trend.rs
Normal file
36
src/section/cost_trend.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use crate::color;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
use crate::trend;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.cost_trend.base.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cost_val = ctx.input.cost.as_ref()?.total_cost_usd?;
|
||||
let cost_cents = (cost_val * 100.0) as i64;
|
||||
|
||||
let width = ctx.config.sections.cost_trend.width as usize;
|
||||
let csv = trend::append(
|
||||
ctx.cache,
|
||||
"cost",
|
||||
cost_cents,
|
||||
width,
|
||||
Duration::from_secs(30),
|
||||
)?;
|
||||
let spark = trend::sparkline(&csv, width);
|
||||
|
||||
if spark.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let raw = format!("${spark}");
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
19
src/section/cost_velocity.rs
Normal file
19
src/section/cost_velocity.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use crate::color;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.cost_velocity.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let velocity = ctx.metrics.cost_velocity?;
|
||||
let raw = format!("${velocity:.2}/min");
|
||||
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
66
src/section/custom.rs
Normal file
66
src/section/custom.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use crate::color;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
use crate::shell;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Render a custom command section by ID.
|
||||
pub fn render(id: &str, ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
let cmd_cfg = ctx.config.custom.iter().find(|c| c.id == id)?;
|
||||
|
||||
let ttl = Duration::from_secs(cmd_cfg.ttl);
|
||||
let timeout = Duration::from_millis(ctx.config.global.shell_timeout_ms);
|
||||
let cache_key = format!("custom_{id}");
|
||||
|
||||
let cached = ctx.cache.get(&cache_key, ttl);
|
||||
let output_str = cached.or_else(|| {
|
||||
let result = if let Some(ref exec) = cmd_cfg.exec {
|
||||
if exec.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let args: Vec<&str> = exec[1..].iter().map(|s| s.as_str()).collect();
|
||||
shell::exec_with_timeout(&exec[0], &args, None, timeout)
|
||||
} else if let Some(ref command) = cmd_cfg.command {
|
||||
shell::exec_with_timeout("sh", &["-c", command], None, timeout)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(ref val) = result {
|
||||
ctx.cache.set(&cache_key, val);
|
||||
}
|
||||
result
|
||||
})?;
|
||||
|
||||
if output_str.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let label = cmd_cfg.label.as_deref().unwrap_or("");
|
||||
let raw = if label.is_empty() {
|
||||
output_str.clone()
|
||||
} else {
|
||||
format!("{label}: {output_str}")
|
||||
};
|
||||
|
||||
let ansi = if ctx.color_enabled {
|
||||
if let Some(ref color_cfg) = cmd_cfg.color {
|
||||
if let Some(matched_color) = color_cfg.match_map.get(&output_str) {
|
||||
let c = color::resolve_color(matched_color, ctx.theme, &ctx.config.colors);
|
||||
format!("{c}{raw}{}", color::RESET)
|
||||
} else if let Some(ref default_c) = cmd_cfg.default_color {
|
||||
let c = color::resolve_color(default_c, ctx.theme, &ctx.config.colors);
|
||||
format!("{c}{raw}{}", color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
}
|
||||
} else if let Some(ref default_c) = cmd_cfg.default_color {
|
||||
let c = color::resolve_color(default_c, ctx.theme, &ctx.config.colors);
|
||||
format!("{c}{raw}{}", color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
}
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
23
src/section/duration.rs
Normal file
23
src/section/duration.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use crate::color;
|
||||
use crate::format;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.duration.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ms = ctx.input.cost.as_ref()?.total_duration_ms?;
|
||||
if ms == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let raw = format::human_duration(ms);
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
34
src/section/hostname.rs
Normal file
34
src/section/hostname.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use crate::color;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.hostname.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = hostname()?;
|
||||
let raw = name.clone();
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{name}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
|
||||
fn hostname() -> Option<String> {
|
||||
// Try gethostname via libc
|
||||
let mut buf = [0u8; 256];
|
||||
let ret = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) };
|
||||
if ret == 0 {
|
||||
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
|
||||
let name = String::from_utf8_lossy(&buf[..end]).to_string();
|
||||
if !name.is_empty() {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: HOSTNAME env var
|
||||
std::env::var("HOSTNAME").ok()
|
||||
}
|
||||
32
src/section/lines_changed.rs
Normal file
32
src/section/lines_changed.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use crate::color;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.lines_changed.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cost = ctx.input.cost.as_ref()?;
|
||||
let added = cost.total_lines_added.unwrap_or(0);
|
||||
let removed = cost.total_lines_removed.unwrap_or(0);
|
||||
|
||||
if added == 0 && removed == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let raw = format!("+{added}/-{removed}");
|
||||
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!(
|
||||
"{}+{added}{}{}/-{removed}{}",
|
||||
color::GREEN,
|
||||
color::RESET,
|
||||
color::RED,
|
||||
color::RESET,
|
||||
)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
53
src/section/load.rs
Normal file
53
src/section/load.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use crate::color;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.load.base.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ttl = Duration::from_secs(ctx.config.sections.load.ttl);
|
||||
let cached = ctx.cache.get("load_avg", ttl);
|
||||
|
||||
let load_str = cached.or_else(|| {
|
||||
// Read load average from /proc/loadavg (Linux) or sysctl (macOS)
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let content = std::fs::read_to_string("/proc/loadavg").ok()?;
|
||||
let load1 = content.split_whitespace().next()?;
|
||||
ctx.cache.set("load_avg", load1);
|
||||
Some(load1.to_string())
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let out = crate::shell::exec_with_timeout(
|
||||
"sysctl",
|
||||
&["-n", "vm.loadavg"],
|
||||
None,
|
||||
Duration::from_millis(100),
|
||||
)?;
|
||||
// sysctl output: "{ 1.23 4.56 7.89 }"
|
||||
let load1 = out
|
||||
.trim_start_matches(|c: char| !c.is_ascii_digit() && c != '.')
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.to_string();
|
||||
ctx.cache.set("load_avg", &load1);
|
||||
Some(load1)
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
None
|
||||
}
|
||||
})?;
|
||||
|
||||
let raw = format!("load {load_str}");
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
118
src/section/mod.rs
Normal file
118
src/section/mod.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use crate::cache::Cache;
|
||||
use crate::config::Config;
|
||||
use crate::input::InputData;
|
||||
use crate::metrics::ComputedMetrics;
|
||||
use crate::theme::Theme;
|
||||
use crate::width::WidthTier;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
pub mod beads;
|
||||
pub mod cache_efficiency;
|
||||
pub mod context_bar;
|
||||
pub mod context_remaining;
|
||||
pub mod context_trend;
|
||||
pub mod context_usage;
|
||||
pub mod cost;
|
||||
pub mod cost_trend;
|
||||
pub mod cost_velocity;
|
||||
pub mod custom;
|
||||
pub mod duration;
|
||||
pub mod hostname;
|
||||
pub mod lines_changed;
|
||||
pub mod load;
|
||||
pub mod model;
|
||||
pub mod output_style;
|
||||
pub mod project;
|
||||
pub mod provider;
|
||||
pub mod time;
|
||||
pub mod token_velocity;
|
||||
pub mod tokens_raw;
|
||||
pub mod tools;
|
||||
pub mod turns;
|
||||
pub mod vcs;
|
||||
pub mod version;
|
||||
|
||||
/// What every section renderer returns.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SectionOutput {
|
||||
pub raw: String,
|
||||
pub ansi: String,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// Build the registry of all built-in sections.
|
||||
pub fn registry() -> Vec<(&'static str, RenderFn)> {
|
||||
vec![
|
||||
("model", model::render),
|
||||
("provider", provider::render),
|
||||
("project", project::render),
|
||||
("vcs", vcs::render),
|
||||
("beads", beads::render),
|
||||
("context_bar", context_bar::render),
|
||||
("context_usage", context_usage::render),
|
||||
("context_remaining", context_remaining::render),
|
||||
("tokens_raw", tokens_raw::render),
|
||||
("cache_efficiency", cache_efficiency::render),
|
||||
("cost", cost::render),
|
||||
("cost_velocity", cost_velocity::render),
|
||||
("token_velocity", token_velocity::render),
|
||||
("cost_trend", cost_trend::render),
|
||||
("context_trend", context_trend::render),
|
||||
("lines_changed", lines_changed::render),
|
||||
("duration", duration::render),
|
||||
("tools", tools::render),
|
||||
("turns", turns::render),
|
||||
("load", load::render),
|
||||
("version", version::render),
|
||||
("time", time::render),
|
||||
("output_style", output_style::render),
|
||||
("hostname", hostname::render),
|
||||
]
|
||||
}
|
||||
|
||||
/// Dispatch: look up section by ID and render it.
|
||||
pub fn render_section(id: &str, ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if is_spacer(id) {
|
||||
return Some(SectionOutput {
|
||||
raw: " ".into(),
|
||||
ansi: " ".into(),
|
||||
});
|
||||
}
|
||||
|
||||
for (name, render_fn) in registry() {
|
||||
if name == id {
|
||||
return render_fn(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
custom::render(id, ctx)
|
||||
}
|
||||
|
||||
pub fn is_spacer(id: &str) -> bool {
|
||||
id == "spacer" || id.starts_with("_spacer")
|
||||
}
|
||||
68
src/section/model.rs
Normal file
68
src/section/model.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
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,
|
||||
));
|
||||
};
|
||||
|
||||
let version = extract_version(&id_lower, &base_name.to_ascii_lowercase());
|
||||
|
||||
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> {
|
||||
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 }
|
||||
}
|
||||
19
src/section/output_style.rs
Normal file
19
src/section/output_style.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use crate::color;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.output_style.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let style_name = ctx.input.output_style.as_ref()?.name.as_deref()?;
|
||||
let raw = style_name.to_string();
|
||||
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
36
src/section/project.rs
Normal file
36
src/section/project.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use crate::color;
|
||||
use crate::format;
|
||||
use crate::glyph;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.project.base.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let dir = ctx.input.workspace.as_ref()?.project_dir.as_deref()?;
|
||||
|
||||
let name = std::path::Path::new(dir).file_name()?.to_str()?;
|
||||
|
||||
let truncated = if ctx.config.sections.project.truncate.enabled
|
||||
&& ctx.config.sections.project.truncate.max > 0
|
||||
{
|
||||
format::truncate(
|
||||
name,
|
||||
ctx.config.sections.project.truncate.max,
|
||||
&ctx.config.sections.project.truncate.style,
|
||||
)
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
|
||||
let folder_glyph = glyph::glyph("folder", &ctx.config.glyphs);
|
||||
let raw = format!("{folder_glyph}{truncated}");
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{folder_glyph}{truncated}{}", color::CYAN, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
35
src/section/provider.rs
Normal file
35
src/section/provider.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use crate::color;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.provider.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 provider = if id_lower.contains("claude")
|
||||
|| id_lower.contains("opus")
|
||||
|| id_lower.contains("sonnet")
|
||||
|| id_lower.contains("haiku")
|
||||
{
|
||||
"Anthropic"
|
||||
} else if id_lower.contains("gpt") || id_lower.contains("o1") || id_lower.contains("o3") {
|
||||
"OpenAI"
|
||||
} else if id_lower.contains("gemini") {
|
||||
"Google"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let raw = provider.to_string();
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{provider}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
37
src/section/time.rs
Normal file
37
src/section/time.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use crate::color;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.time.base.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Simple HH:MM format without chrono dependency
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()?;
|
||||
let secs = now.as_secs();
|
||||
|
||||
// Get local time offset using libc
|
||||
let (hour, minute) = local_time(secs)?;
|
||||
|
||||
let raw = format!("{hour:02}:{minute:02}");
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
|
||||
fn local_time(epoch_secs: u64) -> Option<(u32, u32)> {
|
||||
let time_t = epoch_secs as libc::time_t;
|
||||
let mut tm = std::mem::MaybeUninit::<libc::tm>::uninit();
|
||||
let result = unsafe { libc::localtime_r(&time_t, tm.as_mut_ptr()) };
|
||||
if result.is_null() {
|
||||
return None;
|
||||
}
|
||||
let tm = unsafe { tm.assume_init() };
|
||||
Some((tm.tm_hour as u32, tm.tm_min as u32))
|
||||
}
|
||||
20
src/section/token_velocity.rs
Normal file
20
src/section/token_velocity.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use crate::color;
|
||||
use crate::format;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.token_velocity.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let velocity = ctx.metrics.token_velocity?;
|
||||
let raw = format!("{} tok/min", format::human_tokens(velocity as u64));
|
||||
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
33
src/section/tokens_raw.rs
Normal file
33
src/section/tokens_raw.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use crate::color;
|
||||
use crate::format;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.tokens_raw.base.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cw = ctx.input.context_window.as_ref()?;
|
||||
let input_tok = cw.total_input_tokens.unwrap_or(0);
|
||||
let output_tok = cw.total_output_tokens.unwrap_or(0);
|
||||
|
||||
if input_tok == 0 && output_tok == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let raw = ctx
|
||||
.config
|
||||
.sections
|
||||
.tokens_raw
|
||||
.format
|
||||
.replace("{input}", &format::human_tokens(input_tok))
|
||||
.replace("{output}", &format::human_tokens(output_tok));
|
||||
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
33
src/section/tools.rs
Normal file
33
src/section/tools.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use crate::color;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.tools.base.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cost = ctx.input.cost.as_ref()?;
|
||||
let count = cost.total_tool_uses.unwrap_or(0);
|
||||
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let last = if ctx.config.sections.tools.show_last_name {
|
||||
cost.last_tool_name
|
||||
.as_deref()
|
||||
.map(|n| format!(" ({n})"))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let raw = format!("{count} tools{last}");
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
24
src/section/turns.rs
Normal file
24
src/section/turns.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use crate::color;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.turns.base.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let count = ctx.input.cost.as_ref()?.total_turns?;
|
||||
if count == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let label = if count == 1 { "turn" } else { "turns" };
|
||||
let raw = format!("{count} {label}");
|
||||
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
166
src/section/vcs.rs
Normal file
166
src/section/vcs.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
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> {
|
||||
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);
|
||||
|
||||
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() {
|
||||
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 => GitStatusV2 {
|
||||
branch: branch_cached.or_else(|| ctx.cache.get_stale("vcs_branch")),
|
||||
is_dirty: dirty_cached
|
||||
.or_else(|| ctx.cache.get_stale("vcs_dirty"))
|
||||
.is_some_and(|v| !v.is_empty()),
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
GitStatusV2 {
|
||||
branch: branch_cached,
|
||||
is_dirty: dirty_cached.is_some_and(|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()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
19
src/section/version.rs
Normal file
19
src/section/version.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use crate::color;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
|
||||
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
if !ctx.config.sections.version.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ver = ctx.input.version.as_deref()?;
|
||||
let raw = format!("v{ver}");
|
||||
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
89
src/shell.rs
Normal file
89
src/shell.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Stable env for all git commands.
|
||||
const GIT_ENV: &[(&str, &str)] = &[
|
||||
("GIT_OPTIONAL_LOCKS", "0"),
|
||||
("GIT_TERMINAL_PROMPT", "0"),
|
||||
("LC_ALL", "C"),
|
||||
];
|
||||
|
||||
/// Execute a command with a polling timeout. 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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed result from `git status --porcelain=v2 --branch`.
|
||||
#[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.
|
||||
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 ") {
|
||||
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')
|
||||
{
|
||||
result.is_dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
42
src/theme.rs
Normal file
42
src/theme.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
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
|
||||
}
|
||||
87
src/trend.rs
Normal file
87
src/trend.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use crate::cache::Cache;
|
||||
use std::time::Duration;
|
||||
|
||||
const SPARKLINE_CHARS: &[char] = &[
|
||||
'\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', '\u{2588}',
|
||||
];
|
||||
|
||||
/// 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) {
|
||||
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.
|
||||
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 {
|
||||
return "\u{2584}".repeat(count);
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
199
src/width.rs
Normal file
199
src/width.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
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);
|
||||
|
||||
#[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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).max(40);
|
||||
|
||||
// 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 = 0x4008_7468;
|
||||
#[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 {
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user