feat: implement remaining PRD features (10 beads)
Complete the PRD feature set with shell gating pipeline, cache
improvements, layout enhancements, and diagnostics:
- Shell: exec_gated with allowlist/denylist, circuit breaker, env merge
- Shell: parallel prefetch via std::thread::scope for cold renders
- Cache: TTL jitter (FNV-1a), config hash namespace, garbage collection
- Cache: diagnostic tracking (hit/miss, age) for dump-state
- Layout: gradual drop strategy (one-by-one vs tiered)
- Layout: render budget timer with graceful priority-based degradation
- Layout: breakpoint hysteresis to prevent preset toggling
- Width: detection source tracking for diagnostics
- CLI: --no-cache, --no-shell, --clear-cache, env var overrides
- Diagnostics: enhanced --dump-state with section timing and cache stats
Closes: bd-3oy, bd-62g, bd-khk, bd-3q1, bd-ywx, bd-3l2,
bd-2vm, bd-1if, bd-2qr, bd-30o, bd-3ax, bd-3uw, bd-4b1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@
|
||||
"separator": " | ",
|
||||
"justify": "space-between",
|
||||
"vcs": "auto",
|
||||
"cache_dir": "/tmp/claude-sl-{session_id}",
|
||||
"cache_dir": "/tmp/claude-sl-{session_id}-{cache_version}-{config_hash}",
|
||||
"responsive": true,
|
||||
"breakpoints": {
|
||||
"narrow": 60,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
use claude_statusline::section::RenderContext;
|
||||
use claude_statusline::{cache, color, config, input, layout, metrics, section, theme, width};
|
||||
use claude_statusline::shell::{self, ShellConfig};
|
||||
use claude_statusline::{
|
||||
cache, color, config, format as sl_format, input, layout, metrics, section, theme, width,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
@@ -18,12 +23,27 @@ fn main() {
|
||||
return;
|
||||
}
|
||||
if args.iter().any(|a| a == "--list-sections") {
|
||||
for (id, _) in section::registry() {
|
||||
println!("{id}");
|
||||
println!("{:<22} pri flex shell est_w", "ID");
|
||||
println!("{}", "-".repeat(58));
|
||||
for desc in section::registry() {
|
||||
println!(
|
||||
"{:<22} {:<4} {:<5} {:<6} {}",
|
||||
desc.id,
|
||||
desc.priority,
|
||||
if desc.is_flex { "yes" } else { "-" },
|
||||
if desc.shell_out { "yes" } else { "-" },
|
||||
desc.estimated_width,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let no_cache = args.iter().any(|a| a == "--no-cache")
|
||||
|| std::env::var("CLAUDE_STATUSLINE_NO_CACHE").is_ok();
|
||||
let no_shell = args.iter().any(|a| a == "--no-shell")
|
||||
|| std::env::var("CLAUDE_STATUSLINE_NO_SHELL").is_ok();
|
||||
let clear_cache = args.iter().any(|a| a == "--clear-cache");
|
||||
|
||||
let cli_color = args
|
||||
.iter()
|
||||
.find_map(|a| a.strip_prefix("--color="))
|
||||
@@ -57,7 +77,7 @@ fn main() {
|
||||
});
|
||||
|
||||
// Load config
|
||||
let (config, warnings) = match config::load_config(config_path) {
|
||||
let (config, warnings, config_hash) = match config::load_config(config_path) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("claude-statusline: {e}");
|
||||
@@ -102,8 +122,8 @@ fn main() {
|
||||
|
||||
// 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 (term_width, width_source) =
|
||||
width::detect_width_with_source(cli_width, config.global.width, config.global.width_margin);
|
||||
let tier = width::width_tier(
|
||||
term_width,
|
||||
config.global.breakpoints.narrow,
|
||||
@@ -117,31 +137,63 @@ fn main() {
|
||||
.unwrap_or(".");
|
||||
|
||||
let session = cache::session_id(project_dir);
|
||||
let cache = cache::Cache::new(&config.global.cache_dir, &session);
|
||||
|
||||
// --clear-cache: remove cache directory and exit
|
||||
if clear_cache {
|
||||
let dir_str = config
|
||||
.global
|
||||
.cache_dir
|
||||
.replace("{session_id}", &session)
|
||||
.replace("{cache_version}", &config.global.cache_version.to_string())
|
||||
.replace("{config_hash}", &config_hash);
|
||||
let dir = std::path::Path::new(&dir_str);
|
||||
if dir.exists() {
|
||||
if let Err(e) = std::fs::remove_dir_all(dir) {
|
||||
eprintln!("claude-statusline: clear-cache: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
let cache = if no_cache {
|
||||
cache::Cache::disabled()
|
||||
} else {
|
||||
cache::Cache::new(
|
||||
&config.global.cache_dir,
|
||||
&session,
|
||||
config.global.cache_version,
|
||||
&config_hash,
|
||||
config.global.cache_ttl_jitter_pct,
|
||||
)
|
||||
};
|
||||
|
||||
let shell_config = ShellConfig {
|
||||
enabled: config.global.shell_enabled && !no_shell,
|
||||
allowlist: config.global.shell_allowlist.clone(),
|
||||
denylist: config.global.shell_denylist.clone(),
|
||||
timeout: Duration::from_millis(config.global.shell_timeout_ms),
|
||||
max_output_bytes: config.global.shell_max_output_bytes,
|
||||
env: config.global.shell_env.clone(),
|
||||
failure_threshold: config.global.shell_failure_threshold,
|
||||
cooldown_ms: config.global.shell_cooldown_ms,
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// Prefetch shell-outs in parallel (only for cache-miss commands)
|
||||
let shell_results = if !no_shell && shell_config.enabled {
|
||||
prefetch_shell_outs(&shell_config, &cache, vcs_type, project_dir, &config)
|
||||
} else {
|
||||
std::collections::HashMap::new()
|
||||
};
|
||||
|
||||
let ctx = RenderContext {
|
||||
input: &input_data,
|
||||
config: &config,
|
||||
@@ -153,11 +205,161 @@ fn main() {
|
||||
cache: &cache,
|
||||
glyphs_enabled: config.glyphs.enabled,
|
||||
color_enabled,
|
||||
no_shell,
|
||||
shell_config: &shell_config,
|
||||
metrics: computed_metrics,
|
||||
budget_start: if config.global.render_budget_ms > 0 {
|
||||
Some(std::time::Instant::now())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
budget_ms: config.global.render_budget_ms,
|
||||
shell_results,
|
||||
};
|
||||
|
||||
// Handle --dump-state (after building ctx so we can collect diagnostics)
|
||||
if let Some(format) = dump_state {
|
||||
dump_state_output(
|
||||
format,
|
||||
&config,
|
||||
term_width,
|
||||
width_source,
|
||||
tier,
|
||||
detected_theme,
|
||||
vcs_type,
|
||||
project_dir,
|
||||
&session,
|
||||
&ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let output = layout::render_all(&ctx);
|
||||
print!("{output}");
|
||||
|
||||
// Cache GC: run after output, never blocks status line
|
||||
if !no_cache {
|
||||
cache::gc(
|
||||
config.global.cache_gc_days,
|
||||
config.global.cache_gc_interval_hours,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefetch shell-out results in parallel using std::thread::scope.
|
||||
/// Only spawns threads for commands whose cache has expired.
|
||||
/// Returns results keyed by section name.
|
||||
fn prefetch_shell_outs(
|
||||
shell_config: &ShellConfig,
|
||||
cache: &cache::Cache,
|
||||
vcs_type: section::VcsType,
|
||||
project_dir: &str,
|
||||
config: &config::Config,
|
||||
) -> HashMap<String, Option<String>> {
|
||||
let mut results = HashMap::new();
|
||||
|
||||
// Check which shell-outs need refreshing
|
||||
let vcs_ttl = Duration::from_secs(config.sections.vcs.ttl.branch);
|
||||
let needs_vcs = vcs_type != section::VcsType::None
|
||||
&& config.sections.vcs.base.enabled
|
||||
&& cache.get("vcs_branch", vcs_ttl).is_none();
|
||||
|
||||
let load_ttl = Duration::from_secs(config.sections.load.ttl);
|
||||
let needs_load = config.sections.load.base.enabled && cache.get("load_avg", load_ttl).is_none();
|
||||
|
||||
let beads_ttl = Duration::from_secs(config.sections.beads.ttl);
|
||||
let needs_beads = config.sections.beads.base.enabled
|
||||
&& std::path::Path::new(project_dir).join(".beads").is_dir()
|
||||
&& cache.get("beads_summary", beads_ttl).is_none();
|
||||
|
||||
// If nothing needs refreshing, skip thread::scope entirely
|
||||
if !needs_vcs && !needs_load && !needs_beads {
|
||||
return results;
|
||||
}
|
||||
|
||||
std::thread::scope(|s| {
|
||||
let vcs_handle = if needs_vcs {
|
||||
let args: Vec<String> = match vcs_type {
|
||||
section::VcsType::Git => vec![
|
||||
"git".into(),
|
||||
"-C".into(),
|
||||
project_dir.into(),
|
||||
"status".into(),
|
||||
"--porcelain=v2".into(),
|
||||
"--branch".into(),
|
||||
],
|
||||
section::VcsType::Jj => vec![
|
||||
"jj".into(),
|
||||
"log".into(),
|
||||
"-r".into(),
|
||||
"@".into(),
|
||||
"--no-graph".into(),
|
||||
"-T".into(),
|
||||
"if(bookmarks, bookmarks.join(\",\"), change_id.shortest(8))".into(),
|
||||
"--color=never".into(),
|
||||
],
|
||||
section::VcsType::None => vec![],
|
||||
};
|
||||
if args.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let dir = if vcs_type == section::VcsType::Jj {
|
||||
Some(project_dir.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(s.spawn(move || {
|
||||
let prog = &args[0];
|
||||
let str_args: Vec<&str> = args[1..].iter().map(|s| s.as_str()).collect();
|
||||
shell::exec_gated(shell_config, prog, &str_args, dir.as_deref())
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let load_handle = if needs_load {
|
||||
Some(s.spawn(|| {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
shell::exec_gated(shell_config, "sysctl", &["-n", "vm.loadavg"], None)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::fs::read_to_string("/proc/loadavg")
|
||||
.ok()
|
||||
.and_then(|c| c.split_whitespace().next().map(|s| s.to_string()))
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let beads_handle = if needs_beads {
|
||||
let dir = project_dir.to_string();
|
||||
Some(s.spawn(move || {
|
||||
shell::exec_gated(shell_config, "br", &["ready", "--json"], Some(&dir))
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(h) = vcs_handle {
|
||||
results.insert("vcs".into(), h.join().ok().flatten());
|
||||
}
|
||||
if let Some(h) = load_handle {
|
||||
results.insert("load".into(), h.join().ok().flatten());
|
||||
}
|
||||
if let Some(h) = beads_handle {
|
||||
results.insert("beads".into(), h.join().ok().flatten());
|
||||
}
|
||||
});
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
fn detect_vcs(dir: &str, config: &config::Config) -> section::VcsType {
|
||||
@@ -196,24 +398,74 @@ fn dump_state_output(
|
||||
format: &str,
|
||||
config: &config::Config,
|
||||
term_width: u16,
|
||||
width_source: &str,
|
||||
tier: width::WidthTier,
|
||||
theme: theme::Theme,
|
||||
vcs: section::VcsType,
|
||||
project_dir: &str,
|
||||
session_id: &str,
|
||||
ctx: &RenderContext,
|
||||
) {
|
||||
// Render all sections with per-section timing
|
||||
let layout_lines = layout::resolve_layout(ctx.config, ctx.term_width);
|
||||
let registry = section::registry();
|
||||
let mut section_timings: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
for line_ids in &layout_lines {
|
||||
for id in line_ids {
|
||||
if section::is_spacer(id) {
|
||||
continue;
|
||||
}
|
||||
let start = std::time::Instant::now();
|
||||
let output = section::render_section(id, ctx);
|
||||
let elapsed_us = start.elapsed().as_micros() as u64;
|
||||
|
||||
let priority = registry
|
||||
.iter()
|
||||
.find(|d| d.id == id)
|
||||
.map_or(2, |d| d.priority);
|
||||
|
||||
section_timings.push(serde_json::json!({
|
||||
"id": id,
|
||||
"render_us": elapsed_us,
|
||||
"rendered": output.is_some(),
|
||||
"priority": priority,
|
||||
"raw_width": output.as_ref().map(|o| sl_format::display_width(&o.raw)).unwrap_or(0),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Collect cache diagnostics
|
||||
let cache_diags: Vec<serde_json::Value> = ctx
|
||||
.cache
|
||||
.diagnostics()
|
||||
.into_iter()
|
||||
.map(|d| {
|
||||
serde_json::json!({
|
||||
"key": d.key,
|
||||
"hit": d.hit,
|
||||
"age_ms": d.age_ms,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let json = serde_json::json!({
|
||||
"terminal": {
|
||||
"effective_width": term_width,
|
||||
"width_margin": config.global.width_margin,
|
||||
"width_tier": format!("{tier:?}"),
|
||||
"source": width_source,
|
||||
},
|
||||
"theme": theme.as_str(),
|
||||
"vcs": format!("{vcs:?}"),
|
||||
"layout": {
|
||||
"justify": format!("{:?}", config.global.justify),
|
||||
"separator": &config.global.separator,
|
||||
"drop_strategy": &config.global.drop_strategy,
|
||||
"render_budget_ms": config.global.render_budget_ms,
|
||||
},
|
||||
"sections": section_timings,
|
||||
"cache": cache_diags,
|
||||
"paths": {
|
||||
"project_dir": project_dir,
|
||||
"cache_dir": config.global.cache_dir.replace("{session_id}", session_id),
|
||||
|
||||
210
src/cache.rs
210
src/cache.rs
@@ -1,21 +1,56 @@
|
||||
use std::cell::RefCell;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
/// Diagnostic entry for a single cache lookup.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CacheDiag {
|
||||
pub key: String,
|
||||
pub hit: bool,
|
||||
pub age_ms: Option<u64>,
|
||||
}
|
||||
|
||||
pub struct Cache {
|
||||
dir: Option<PathBuf>,
|
||||
jitter_pct: u8,
|
||||
diagnostics: RefCell<Vec<CacheDiag>>,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
/// Create a disabled cache where all operations are no-ops.
|
||||
/// Used for --no-cache mode.
|
||||
pub fn disabled() -> Self {
|
||||
Self {
|
||||
dir: None,
|
||||
jitter_pct: 0,
|
||||
diagnostics: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
/// Replaces `{session_id}`, `{cache_version}`, and `{config_hash}` in template.
|
||||
pub fn new(
|
||||
template: &str,
|
||||
session_id: &str,
|
||||
cache_version: u32,
|
||||
config_hash: &str,
|
||||
jitter_pct: u8,
|
||||
) -> Self {
|
||||
let dir_str = template
|
||||
.replace("{session_id}", session_id)
|
||||
.replace("{cache_version}", &cache_version.to_string())
|
||||
.replace("{config_hash}", config_hash);
|
||||
let dir = PathBuf::from(&dir_str);
|
||||
|
||||
if !dir.exists() {
|
||||
if fs::create_dir_all(&dir).is_err() {
|
||||
return Self { dir: None };
|
||||
return Self {
|
||||
dir: None,
|
||||
jitter_pct,
|
||||
diagnostics: RefCell::new(Vec::new()),
|
||||
};
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -26,29 +61,93 @@ impl Cache {
|
||||
|
||||
// Security: verify ownership, not a symlink, not world-writable
|
||||
if !verify_cache_dir(&dir) {
|
||||
return Self { dir: None };
|
||||
return Self {
|
||||
dir: None,
|
||||
jitter_pct,
|
||||
diagnostics: RefCell::new(Vec::new()),
|
||||
};
|
||||
}
|
||||
|
||||
Self { dir: Some(dir) }
|
||||
Self {
|
||||
dir: Some(dir),
|
||||
jitter_pct,
|
||||
diagnostics: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> Option<&Path> {
|
||||
self.dir.as_deref()
|
||||
}
|
||||
|
||||
/// Get cached value if fresher than TTL.
|
||||
/// Get cached value if fresher than TTL (with per-key jitter applied).
|
||||
pub fn get(&self, key: &str, ttl: Duration) -> Option<String> {
|
||||
let path = self.key_path(key)?;
|
||||
let meta = fs::metadata(&path).ok()?;
|
||||
let path = match self.key_path(key) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
self.record_diag(key, false, None);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let meta = match fs::metadata(&path).ok() {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
self.record_diag(key, false, None);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let modified = meta.modified().ok()?;
|
||||
let age = SystemTime::now().duration_since(modified).ok()?;
|
||||
if age < ttl {
|
||||
fs::read_to_string(&path).ok()
|
||||
let age_ms = age.as_millis() as u64;
|
||||
let effective_ttl = self.jittered_ttl(key, ttl);
|
||||
if age < effective_ttl {
|
||||
let value = fs::read_to_string(&path).ok();
|
||||
self.record_diag(key, value.is_some(), Some(age_ms));
|
||||
value
|
||||
} else {
|
||||
self.record_diag(key, false, Some(age_ms));
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn record_diag(&self, key: &str, hit: bool, age_ms: Option<u64>) {
|
||||
if let Ok(mut diags) = self.diagnostics.try_borrow_mut() {
|
||||
diags.push(CacheDiag {
|
||||
key: key.to_string(),
|
||||
hit,
|
||||
age_ms,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Return collected cache diagnostics (for --dump-state).
|
||||
pub fn diagnostics(&self) -> Vec<CacheDiag> {
|
||||
self.diagnostics.borrow().clone()
|
||||
}
|
||||
|
||||
/// Apply deterministic per-key jitter to TTL.
|
||||
/// Uses FNV-1a hash of key to produce stable jitter (same key = same jitter every time).
|
||||
fn jittered_ttl(&self, key: &str, base_ttl: Duration) -> Duration {
|
||||
if self.jitter_pct == 0 {
|
||||
return base_ttl;
|
||||
}
|
||||
|
||||
// FNV-1a hash of key for deterministic per-key jitter
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for byte in key.bytes() {
|
||||
hash ^= u64::from(byte);
|
||||
hash = hash.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
|
||||
// Map hash to range [-jitter_pct, +jitter_pct]
|
||||
let jitter_range = f64::from(self.jitter_pct) / 100.0;
|
||||
let normalized = (hash % 2001) as f64 / 1000.0 - 1.0; // [-1.0, 1.0]
|
||||
let multiplier = 1.0 + (normalized * jitter_range);
|
||||
|
||||
let jittered_ms = (base_ttl.as_millis() as f64 * multiplier) as u64;
|
||||
// Clamp: minimum 100ms to avoid zero TTL
|
||||
Duration::from_millis(jittered_ms.max(100))
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
@@ -152,3 +251,94 @@ pub fn session_id(project_dir: &str) -> String {
|
||||
let hash = Md5::digest(project_dir.as_bytes());
|
||||
format!("{:x}", hash)[..12].to_string()
|
||||
}
|
||||
|
||||
/// Garbage-collect old cache directories.
|
||||
/// Runs at most once per `gc_interval_hours`. Deletes dirs older than `gc_days`
|
||||
/// that match /tmp/claude-sl-* and are owned by the current user.
|
||||
/// Never blocks: uses non-blocking flock on a sentinel file.
|
||||
pub fn gc(gc_days: u16, gc_interval_hours: u16) {
|
||||
let lock_path = Path::new("/tmp/claude-sl-gc.lock");
|
||||
|
||||
// Check interval: if lock file exists and is younger than gc_interval, skip
|
||||
if let Ok(meta) = fs::metadata(lock_path) {
|
||||
if let Ok(modified) = meta.modified() {
|
||||
if let Ok(age) = SystemTime::now().duration_since(modified) {
|
||||
if age < Duration::from_secs(u64::from(gc_interval_hours) * 3600) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try non-blocking lock
|
||||
let lock_file = match fs::File::create(lock_path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return,
|
||||
};
|
||||
if !try_flock(&lock_file) {
|
||||
return; // another process is GC-ing
|
||||
}
|
||||
|
||||
// Touch the lock file (create already set mtime to now)
|
||||
let max_age = Duration::from_secs(u64::from(gc_days) * 86400);
|
||||
|
||||
let entries = match fs::read_dir("/tmp") {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
unlock(&lock_file);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let uid = current_uid();
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if !name_str.starts_with("claude-sl-") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = entry.path();
|
||||
|
||||
// Safety: skip symlinks
|
||||
let meta = match fs::symlink_metadata(&path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !meta.is_dir() || meta.file_type().is_symlink() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only delete dirs owned by current user
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if meta.uid() != uid {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check age
|
||||
if let Ok(modified) = meta.modified() {
|
||||
if let Ok(age) = SystemTime::now().duration_since(modified) {
|
||||
if age > max_age {
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unlock(&lock_file);
|
||||
}
|
||||
|
||||
fn current_uid() -> u32 {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe { libc::getuid() }
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ pub fn resolve_color(name: &str, theme: Theme, palette: &ThemeColors) -> String
|
||||
}
|
||||
|
||||
/// Determine whether color output should be used.
|
||||
/// Precedence: NO_COLOR > --color= CLI flag > CLAUDE_STATUSLINE_COLOR env > config
|
||||
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;
|
||||
@@ -62,6 +63,14 @@ pub fn should_use_color(cli_color: Option<&str>, config_color: &crate::config::C
|
||||
};
|
||||
}
|
||||
|
||||
if let Ok(env_color) = std::env::var("CLAUDE_STATUSLINE_COLOR") {
|
||||
return match env_color.as_str() {
|
||||
"always" => true,
|
||||
"never" => false,
|
||||
_ => atty_stdout(),
|
||||
};
|
||||
}
|
||||
|
||||
match config_color {
|
||||
crate::config::ColorMode::Always => true,
|
||||
crate::config::ColorMode::Never => false,
|
||||
|
||||
@@ -128,6 +128,7 @@ pub enum ColorMode {
|
||||
pub struct Breakpoints {
|
||||
pub narrow: u16,
|
||||
pub medium: u16,
|
||||
pub hysteresis: u16,
|
||||
}
|
||||
|
||||
impl Default for Breakpoints {
|
||||
@@ -135,6 +136,7 @@ impl Default for Breakpoints {
|
||||
Self {
|
||||
narrow: 60,
|
||||
medium: 100,
|
||||
hysteresis: 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -660,7 +662,11 @@ pub fn deep_merge(base: &mut Value, patch: &Value) {
|
||||
// ── 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> {
|
||||
/// Returns (Config, warnings, config_hash) where config_hash is 8-char hex MD5
|
||||
/// of the merged JSON (for cache namespace invalidation on config change).
|
||||
pub fn load_config(
|
||||
explicit_path: Option<&str>,
|
||||
) -> Result<(Config, Vec<String>, String), crate::Error> {
|
||||
let mut base: Value = serde_json::from_str(DEFAULTS_JSON)?;
|
||||
|
||||
let user_path = explicit_path
|
||||
@@ -687,12 +693,24 @@ pub fn load_config(explicit_path: Option<&str>) -> Result<(Config, Vec<String>),
|
||||
));
|
||||
}
|
||||
|
||||
// Compute config hash from merged JSON before deserialize consumes it
|
||||
let config_hash = compute_config_hash(&base);
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
let config: Config = serde_ignored::deserialize(base, |path| {
|
||||
warnings.push(format!("unknown config key: {path}"));
|
||||
})?;
|
||||
|
||||
Ok((config, warnings))
|
||||
Ok((config, warnings, config_hash))
|
||||
}
|
||||
|
||||
/// MD5 of the merged JSON value, truncated to 8 hex chars.
|
||||
/// Deterministic: serde_json produces stable output for the same Value.
|
||||
fn compute_config_hash(merged: &Value) -> String {
|
||||
use md5::{Digest, Md5};
|
||||
let json_bytes = serde_json::to_string(merged).unwrap_or_default();
|
||||
let hash = Md5::digest(json_bytes.as_bytes());
|
||||
format!("{:x}", hash)[..8].to_string()
|
||||
}
|
||||
|
||||
fn xdg_config_path() -> Option<std::path::PathBuf> {
|
||||
|
||||
@@ -36,12 +36,64 @@ pub fn resolve_layout(config: &Config, term_width: u16) -> Vec<Vec<String>> {
|
||||
}
|
||||
|
||||
fn responsive_preset(width: u16, bp: &crate::config::Breakpoints) -> &'static str {
|
||||
if width < bp.narrow {
|
||||
use std::sync::Mutex;
|
||||
|
||||
static LAST_PRESET: Mutex<Option<&'static str>> = Mutex::new(None);
|
||||
|
||||
let simple = if width < bp.narrow {
|
||||
"dense"
|
||||
} else if width < bp.medium {
|
||||
"standard"
|
||||
} else {
|
||||
"verbose"
|
||||
};
|
||||
|
||||
let hysteresis = bp.hysteresis;
|
||||
if hysteresis == 0 {
|
||||
return simple;
|
||||
}
|
||||
|
||||
let guard = LAST_PRESET.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let prev = *guard;
|
||||
drop(guard);
|
||||
|
||||
let result = match prev {
|
||||
Some(prev) => {
|
||||
// Check if width is within hysteresis zone of a breakpoint
|
||||
let in_narrow_zone =
|
||||
width >= bp.narrow.saturating_sub(hysteresis) && width < bp.narrow + hysteresis;
|
||||
let in_medium_zone =
|
||||
width >= bp.medium.saturating_sub(hysteresis) && width < bp.medium + hysteresis;
|
||||
|
||||
if (in_narrow_zone || in_medium_zone) && is_valid_preset(prev, width, bp, hysteresis) {
|
||||
prev
|
||||
} else {
|
||||
simple
|
||||
}
|
||||
}
|
||||
None => simple,
|
||||
};
|
||||
|
||||
let mut guard = LAST_PRESET.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*guard = Some(result);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Check if a previous preset is still valid given the current width.
|
||||
fn is_valid_preset(
|
||||
preset: &str,
|
||||
width: u16,
|
||||
bp: &crate::config::Breakpoints,
|
||||
hysteresis: u16,
|
||||
) -> bool {
|
||||
match preset {
|
||||
"dense" => width < bp.narrow + hysteresis,
|
||||
"standard" => {
|
||||
width >= bp.narrow.saturating_sub(hysteresis) && width < bp.medium + hysteresis
|
||||
}
|
||||
"verbose" => width >= bp.medium.saturating_sub(hysteresis),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +117,14 @@ fn render_line(section_ids: &[String], ctx: &RenderContext, separator: &str) ->
|
||||
let mut active: Vec<ActiveSection> = Vec::new();
|
||||
|
||||
for id in section_ids {
|
||||
// Budget check: skip non-priority-1 sections when over budget
|
||||
if ctx.budget_exceeded() {
|
||||
let (prio, _) = section_meta(id, ctx.config);
|
||||
if prio > 1 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(mut output) = section::render_section(id, ctx) {
|
||||
if output.raw.is_empty() && !section::is_spacer(id) {
|
||||
continue;
|
||||
@@ -97,7 +157,12 @@ fn render_line(section_ids: &[String], ctx: &RenderContext, separator: &str) ->
|
||||
}
|
||||
|
||||
// Phase 2: Priority drop if overflowing
|
||||
let mut active = priority::priority_drop(active, ctx.term_width, separator);
|
||||
let mut active = priority::drop_sections(
|
||||
active,
|
||||
ctx.term_width,
|
||||
separator,
|
||||
&ctx.config.global.drop_strategy,
|
||||
);
|
||||
|
||||
// Phase 3: Flex expand or justify
|
||||
let line = if ctx.config.global.justify != JustifyMode::Left
|
||||
@@ -111,6 +176,9 @@ fn render_line(section_ids: &[String], ctx: &RenderContext, separator: &str) ->
|
||||
ctx.config.global.justify,
|
||||
ctx.color_enabled,
|
||||
)
|
||||
} else if ctx.budget_exceeded() {
|
||||
// Over budget: skip flex expansion, emit as-is
|
||||
assemble_left(&active, separator, ctx.color_enabled)
|
||||
} else {
|
||||
flex::flex_expand(&mut active, ctx, separator);
|
||||
assemble_left(&active, separator, ctx.color_enabled)
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
use crate::format;
|
||||
use crate::layout::ActiveSection;
|
||||
|
||||
/// Drop priority 3 sections (all at once), then priority 2, until line fits.
|
||||
/// Dispatch: select drop strategy by name.
|
||||
pub fn drop_sections(
|
||||
active: Vec<ActiveSection>,
|
||||
term_width: u16,
|
||||
separator: &str,
|
||||
strategy: &str,
|
||||
) -> Vec<ActiveSection> {
|
||||
match strategy {
|
||||
"gradual" => gradual_drop(active, term_width, separator),
|
||||
_ => tiered_drop(active, term_width, separator),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiered: drop all priority 3 sections at once, then all priority 2.
|
||||
/// Priority 1 sections never drop.
|
||||
pub fn priority_drop(
|
||||
fn tiered_drop(
|
||||
mut active: Vec<ActiveSection>,
|
||||
term_width: u16,
|
||||
separator: &str,
|
||||
@@ -23,9 +36,42 @@ pub fn priority_drop(
|
||||
active
|
||||
}
|
||||
|
||||
/// Gradual: drop sections one-by-one.
|
||||
/// Order: highest priority number first, then widest, then rightmost.
|
||||
/// Priority 1 sections never drop.
|
||||
fn gradual_drop(
|
||||
mut active: Vec<ActiveSection>,
|
||||
term_width: u16,
|
||||
separator: &str,
|
||||
) -> Vec<ActiveSection> {
|
||||
while line_width(&active, separator) > term_width as usize {
|
||||
// Find the best candidate to drop
|
||||
let candidate = active
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, s)| s.priority > 1) // never drop priority 1
|
||||
.max_by_key(|(idx, s)| {
|
||||
(
|
||||
s.priority,
|
||||
format::display_width(&s.output.raw),
|
||||
*idx, // rightmost wins ties
|
||||
)
|
||||
})
|
||||
.map(|(idx, _)| idx);
|
||||
|
||||
match candidate {
|
||||
Some(idx) => {
|
||||
active.remove(idx);
|
||||
}
|
||||
None => break, // only priority 1 left
|
||||
}
|
||||
}
|
||||
active
|
||||
}
|
||||
|
||||
/// Calculate total display width including separators.
|
||||
/// Spacers suppress adjacent separators on both sides.
|
||||
fn line_width(active: &[ActiveSection], separator: &str) -> usize {
|
||||
pub fn line_width(active: &[ActiveSection], separator: &str) -> usize {
|
||||
let sep_w = format::display_width(separator);
|
||||
let mut total = 0;
|
||||
|
||||
|
||||
@@ -13,18 +13,24 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
return None;
|
||||
}
|
||||
|
||||
// --no-shell: serve stale cache only
|
||||
if ctx.no_shell {
|
||||
return render_from_cache(ctx, ctx.cache.get_stale("beads_summary")?);
|
||||
}
|
||||
|
||||
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,
|
||||
)?;
|
||||
// Use prefetched result if available, otherwise exec
|
||||
let out = ctx.shell_results.get("beads").cloned().unwrap_or_else(|| {
|
||||
shell::exec_gated(
|
||||
ctx.shell_config,
|
||||
"br",
|
||||
&["ready", "--json"],
|
||||
Some(ctx.project_dir.to_str()?),
|
||||
)
|
||||
})?;
|
||||
// Count JSON array items (simple: count opening braces at indent level 1)
|
||||
let count = out.matches("\"id\"").count();
|
||||
let summary = format!("{count}");
|
||||
@@ -46,3 +52,17 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
|
||||
Some(SectionOutput { raw, ansi })
|
||||
}
|
||||
|
||||
fn render_from_cache(ctx: &RenderContext, summary: String) -> Option<SectionOutput> {
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::color;
|
||||
use crate::config::CustomCommand;
|
||||
use crate::section::{RenderContext, SectionOutput};
|
||||
use crate::shell;
|
||||
use std::time::Duration;
|
||||
@@ -7,10 +8,16 @@ use std::time::Duration;
|
||||
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}");
|
||||
|
||||
// --no-shell: serve stale cache only
|
||||
if ctx.no_shell {
|
||||
let output_str = ctx.cache.get_stale(&cache_key)?;
|
||||
return render_output(ctx, cmd_cfg, &output_str);
|
||||
}
|
||||
|
||||
let ttl = Duration::from_secs(cmd_cfg.ttl);
|
||||
|
||||
let cached = ctx.cache.get(&cache_key, ttl);
|
||||
let output_str = cached.or_else(|| {
|
||||
let result = if let Some(ref exec) = cmd_cfg.exec {
|
||||
@@ -18,9 +25,9 @@ pub fn render(id: &str, ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
return None;
|
||||
}
|
||||
let args: Vec<&str> = exec[1..].iter().map(|s| s.as_str()).collect();
|
||||
shell::exec_with_timeout(&exec[0], &args, None, timeout)
|
||||
shell::exec_gated(ctx.shell_config, &exec[0], &args, None)
|
||||
} else if let Some(ref command) = cmd_cfg.command {
|
||||
shell::exec_with_timeout("sh", &["-c", command], None, timeout)
|
||||
shell::exec_gated(ctx.shell_config, "sh", &["-c", command], None)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -34,16 +41,29 @@ pub fn render(id: &str, ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
return None;
|
||||
}
|
||||
|
||||
render_output(ctx, cmd_cfg, &output_str)
|
||||
}
|
||||
|
||||
/// Shared rendering: label + color logic used by both live and stale-cache paths.
|
||||
fn render_output(
|
||||
ctx: &RenderContext,
|
||||
cmd_cfg: &CustomCommand,
|
||||
output_str: &str,
|
||||
) -> Option<SectionOutput> {
|
||||
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()
|
||||
output_str.to_string()
|
||||
} 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) {
|
||||
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 {
|
||||
|
||||
@@ -7,6 +7,18 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
return None;
|
||||
}
|
||||
|
||||
// --no-shell: serve stale cache only
|
||||
if ctx.no_shell {
|
||||
let load_str = ctx.cache.get_stale("load_avg")?;
|
||||
let raw = format!("load {load_str}");
|
||||
let ansi = if ctx.color_enabled {
|
||||
format!("{}{raw}{}", color::DIM, color::RESET)
|
||||
} else {
|
||||
raw.clone()
|
||||
};
|
||||
return Some(SectionOutput { raw, ansi });
|
||||
}
|
||||
|
||||
let ttl = Duration::from_secs(ctx.config.sections.load.ttl);
|
||||
let cached = ctx.cache.get("load_avg", ttl);
|
||||
|
||||
@@ -21,12 +33,10 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let out = crate::shell::exec_with_timeout(
|
||||
"sysctl",
|
||||
&["-n", "vm.loadavg"],
|
||||
None,
|
||||
Duration::from_millis(100),
|
||||
)?;
|
||||
// Use prefetched result if available, otherwise exec
|
||||
let out = ctx.shell_results.get("load").cloned().unwrap_or_else(|| {
|
||||
crate::shell::exec_gated(ctx.shell_config, "sysctl", &["-n", "vm.loadavg"], None)
|
||||
})?;
|
||||
// sysctl output: "{ 1.23 4.56 7.89 }"
|
||||
let load1 = out
|
||||
.trim_start_matches(|c: char| !c.is_ascii_digit() && c != '.')
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::cache::Cache;
|
||||
use crate::config::Config;
|
||||
use crate::input::InputData;
|
||||
use crate::metrics::ComputedMetrics;
|
||||
use crate::shell::ShellConfig;
|
||||
use crate::theme::Theme;
|
||||
use crate::width::WidthTier;
|
||||
|
||||
@@ -62,36 +63,259 @@ pub struct RenderContext<'a> {
|
||||
pub cache: &'a Cache,
|
||||
pub glyphs_enabled: bool,
|
||||
pub color_enabled: bool,
|
||||
pub no_shell: bool,
|
||||
pub shell_config: &'a ShellConfig,
|
||||
pub metrics: ComputedMetrics,
|
||||
pub budget_start: Option<std::time::Instant>,
|
||||
pub budget_ms: u64,
|
||||
pub shell_results: std::collections::HashMap<String, Option<String>>,
|
||||
}
|
||||
|
||||
/// Build the registry of all built-in sections.
|
||||
pub fn registry() -> Vec<(&'static str, RenderFn)> {
|
||||
impl RenderContext<'_> {
|
||||
/// Check if the render budget has been exceeded.
|
||||
/// Returns false if budget_ms == 0 (unlimited) or budget_start is None.
|
||||
pub fn budget_exceeded(&self) -> bool {
|
||||
if self.budget_ms == 0 {
|
||||
return false;
|
||||
}
|
||||
if let Some(start) = self.budget_start {
|
||||
start.elapsed().as_millis() as u64 >= self.budget_ms
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata for layout planning, CLI introspection, and render budgeting.
|
||||
pub struct SectionDescriptor {
|
||||
pub id: &'static str,
|
||||
pub render: RenderFn,
|
||||
pub priority: u8,
|
||||
pub is_spacer: bool,
|
||||
pub is_flex: bool,
|
||||
pub estimated_width: u16,
|
||||
pub shell_out: bool,
|
||||
}
|
||||
|
||||
/// Build the registry of all built-in sections with metadata.
|
||||
pub fn registry() -> Vec<SectionDescriptor> {
|
||||
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),
|
||||
SectionDescriptor {
|
||||
id: "model",
|
||||
render: model::render,
|
||||
priority: 1,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 12,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "provider",
|
||||
render: provider::render,
|
||||
priority: 2,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 10,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "project",
|
||||
render: project::render,
|
||||
priority: 1,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 20,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "vcs",
|
||||
render: vcs::render,
|
||||
priority: 1,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 15,
|
||||
shell_out: true,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "beads",
|
||||
render: beads::render,
|
||||
priority: 3,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 25,
|
||||
shell_out: true,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "context_bar",
|
||||
render: context_bar::render,
|
||||
priority: 1,
|
||||
is_spacer: false,
|
||||
is_flex: true,
|
||||
estimated_width: 18,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "context_usage",
|
||||
render: context_usage::render,
|
||||
priority: 2,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 12,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "context_remaining",
|
||||
render: context_remaining::render,
|
||||
priority: 2,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 10,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "tokens_raw",
|
||||
render: tokens_raw::render,
|
||||
priority: 3,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 18,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "cache_efficiency",
|
||||
render: cache_efficiency::render,
|
||||
priority: 3,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 10,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "cost",
|
||||
render: cost::render,
|
||||
priority: 1,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 8,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "cost_velocity",
|
||||
render: cost_velocity::render,
|
||||
priority: 3,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 10,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "token_velocity",
|
||||
render: token_velocity::render,
|
||||
priority: 3,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 14,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "cost_trend",
|
||||
render: cost_trend::render,
|
||||
priority: 3,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 8,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "context_trend",
|
||||
render: context_trend::render,
|
||||
priority: 3,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 8,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "lines_changed",
|
||||
render: lines_changed::render,
|
||||
priority: 2,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 10,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "duration",
|
||||
render: duration::render,
|
||||
priority: 2,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 5,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "tools",
|
||||
render: tools::render,
|
||||
priority: 2,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 15,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "turns",
|
||||
render: turns::render,
|
||||
priority: 3,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 8,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "load",
|
||||
render: load::render,
|
||||
priority: 3,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 10,
|
||||
shell_out: true,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "version",
|
||||
render: version::render,
|
||||
priority: 3,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 8,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "time",
|
||||
render: time::render,
|
||||
priority: 3,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 5,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "output_style",
|
||||
render: output_style::render,
|
||||
priority: 2,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 10,
|
||||
shell_out: false,
|
||||
},
|
||||
SectionDescriptor {
|
||||
id: "hostname",
|
||||
render: hostname::render,
|
||||
priority: 3,
|
||||
is_spacer: false,
|
||||
is_flex: false,
|
||||
estimated_width: 10,
|
||||
shell_out: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -104,9 +328,9 @@ pub fn render_section(id: &str, ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
});
|
||||
}
|
||||
|
||||
for (name, render_fn) in registry() {
|
||||
if name == id {
|
||||
return render_fn(ctx);
|
||||
for desc in registry() {
|
||||
if desc.id == id {
|
||||
return (desc.render)(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ 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 {
|
||||
@@ -13,6 +12,11 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
return None;
|
||||
}
|
||||
|
||||
// --no-shell: serve stale cache only, skip all git/jj commands
|
||||
if ctx.no_shell {
|
||||
return render_stale_cache(ctx);
|
||||
}
|
||||
|
||||
let dir = ctx.project_dir.to_str()?;
|
||||
let ttl = &ctx.config.sections.vcs.ttl;
|
||||
let glyphs = &ctx.config.glyphs;
|
||||
@@ -24,28 +28,45 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve stale cached VCS data without running any commands.
|
||||
fn render_stale_cache(ctx: &RenderContext) -> Option<SectionOutput> {
|
||||
let branch = ctx.cache.get_stale("vcs_branch")?;
|
||||
let branch_glyph = glyph::glyph("branch", &ctx.config.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 })
|
||||
}
|
||||
|
||||
fn render_git(
|
||||
ctx: &RenderContext,
|
||||
dir: &str,
|
||||
ttl: &crate::config::VcsTtl,
|
||||
glyphs: &crate::config::GlyphConfig,
|
||||
) -> Option<SectionOutput> {
|
||||
use std::time::Duration;
|
||||
|
||||
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,
|
||||
);
|
||||
// Use prefetched result if available, otherwise exec
|
||||
let output = ctx.shell_results.get("vcs").cloned().unwrap_or_else(|| {
|
||||
shell::exec_gated(
|
||||
ctx.shell_config,
|
||||
"git",
|
||||
&["-C", dir, "status", "--porcelain=v2", "--branch"],
|
||||
None,
|
||||
)
|
||||
});
|
||||
match output {
|
||||
Some(ref out) => {
|
||||
let s = shell::parse_git_status_v2(out);
|
||||
@@ -138,24 +159,33 @@ fn render_jj(
|
||||
ttl: &crate::config::VcsTtl,
|
||||
glyphs: &crate::config::GlyphConfig,
|
||||
) -> Option<SectionOutput> {
|
||||
use std::time::Duration;
|
||||
|
||||
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",
|
||||
],
|
||||
Some(dir),
|
||||
timeout,
|
||||
)?;
|
||||
// Use prefetched result if available
|
||||
let out = ctx
|
||||
.shell_results
|
||||
.get("vcs")
|
||||
.cloned()
|
||||
.flatten()
|
||||
.or_else(|| {
|
||||
shell::exec_gated(
|
||||
ctx.shell_config,
|
||||
"jj",
|
||||
&[
|
||||
"log",
|
||||
"-r",
|
||||
"@",
|
||||
"--no-graph",
|
||||
"-T",
|
||||
"if(bookmarks, bookmarks.join(\",\"), change_id.shortest(8))",
|
||||
"--color=never",
|
||||
],
|
||||
Some(dir),
|
||||
)
|
||||
})?;
|
||||
ctx.cache.set("vcs_branch", &out);
|
||||
Some(out)
|
||||
})?;
|
||||
|
||||
161
src/shell.rs
161
src/shell.rs
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -8,6 +9,166 @@ const GIT_ENV: &[(&str, &str)] = &[
|
||||
("LC_ALL", "C"),
|
||||
];
|
||||
|
||||
/// Shell execution configuration for gated access.
|
||||
pub struct ShellConfig {
|
||||
pub enabled: bool,
|
||||
pub allowlist: Vec<String>,
|
||||
pub denylist: Vec<String>,
|
||||
pub timeout: Duration,
|
||||
pub max_output_bytes: usize,
|
||||
pub env: HashMap<String, String>,
|
||||
pub failure_threshold: u8,
|
||||
pub cooldown_ms: u64,
|
||||
}
|
||||
|
||||
// ── Circuit breaker ─────────────────────────────────────────────────────
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct CircuitState {
|
||||
failures: u8,
|
||||
cooldown_until: Option<Instant>,
|
||||
}
|
||||
|
||||
static BREAKER: Mutex<Option<HashMap<String, CircuitState>>> = Mutex::new(None);
|
||||
|
||||
fn circuit_check(program: &str, threshold: u8) -> bool {
|
||||
if threshold == 0 {
|
||||
return true; // disabled
|
||||
}
|
||||
let mut guard = BREAKER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let map = guard.get_or_insert_with(HashMap::new);
|
||||
|
||||
if let Some(state) = map.get(program) {
|
||||
if let Some(until) = state.cooldown_until {
|
||||
if Instant::now() < until {
|
||||
return false; // in cooldown
|
||||
}
|
||||
// Cooldown expired: allow retry
|
||||
map.remove(program);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn circuit_record_success(program: &str) {
|
||||
let mut guard = BREAKER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(map) = guard.as_mut() {
|
||||
map.remove(program);
|
||||
}
|
||||
}
|
||||
|
||||
fn circuit_record_failure(program: &str, threshold: u8, cooldown_ms: u64) {
|
||||
if threshold == 0 {
|
||||
return;
|
||||
}
|
||||
let mut guard = BREAKER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let map = guard.get_or_insert_with(HashMap::new);
|
||||
let state = map.entry(program.to_string()).or_insert(CircuitState {
|
||||
failures: 0,
|
||||
cooldown_until: None,
|
||||
});
|
||||
state.failures = state.failures.saturating_add(1);
|
||||
if state.failures >= threshold {
|
||||
state.cooldown_until = Some(Instant::now() + Duration::from_millis(cooldown_ms));
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a command through the gated shell pipeline.
|
||||
/// Checks: enabled -> denylist -> allowlist -> execute with env merge -> truncate.
|
||||
/// Returns None if gated out (caller falls back to stale cache).
|
||||
pub fn exec_gated(
|
||||
config: &ShellConfig,
|
||||
program: &str,
|
||||
args: &[&str],
|
||||
dir: Option<&str>,
|
||||
) -> Option<String> {
|
||||
// 1. Global kill switch
|
||||
if !config.enabled {
|
||||
return None;
|
||||
}
|
||||
// 2. Denylist (wins over allowlist)
|
||||
if config.denylist.iter().any(|d| d == program) {
|
||||
return None;
|
||||
}
|
||||
// 3. Allowlist (empty = all allowed)
|
||||
if !config.allowlist.is_empty() && !config.allowlist.iter().any(|a| a == program) {
|
||||
return None;
|
||||
}
|
||||
// 4. Circuit breaker check
|
||||
if !circuit_check(program, config.failure_threshold) {
|
||||
return None;
|
||||
}
|
||||
// 5. Execute with merged env
|
||||
let result = exec_with_timeout_env(program, args, dir, config.timeout, &config.env);
|
||||
match result {
|
||||
Some(ref output) => {
|
||||
circuit_record_success(program);
|
||||
// 6. Truncate output
|
||||
if config.max_output_bytes > 0 && output.len() > config.max_output_bytes {
|
||||
let truncated = &output.as_bytes()[..config.max_output_bytes];
|
||||
Some(String::from_utf8_lossy(truncated).into_owned())
|
||||
} else {
|
||||
Some(output.clone())
|
||||
}
|
||||
}
|
||||
None => {
|
||||
circuit_record_failure(program, config.failure_threshold, config.cooldown_ms);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute with timeout and optional extra env vars.
|
||||
fn exec_with_timeout_env(
|
||||
program: &str,
|
||||
args: &[&str],
|
||||
dir: Option<&str>,
|
||||
timeout: Duration,
|
||||
extra_env: &HashMap<String, String>,
|
||||
) -> 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);
|
||||
}
|
||||
}
|
||||
|
||||
// shell_env overrides GIT_ENV for same key (intentional: user override)
|
||||
for (k, v) in extra_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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a command with a polling timeout. Returns None on timeout or error.
|
||||
pub fn exec_with_timeout(
|
||||
program: &str,
|
||||
|
||||
38
src/width.rs
38
src/width.rs
@@ -24,16 +24,25 @@ pub fn width_tier(width: u16, narrow_bp: u16, medium_bp: u16) -> WidthTier {
|
||||
/// 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 {
|
||||
detect_width_with_source(cli_width, config_width, config_margin).0
|
||||
}
|
||||
|
||||
/// Like detect_width but also returns the detection source name (for --dump-state).
|
||||
pub fn detect_width_with_source(
|
||||
cli_width: Option<u16>,
|
||||
config_width: Option<u16>,
|
||||
config_margin: u16,
|
||||
) -> (u16, &'static str) {
|
||||
// Check memo first
|
||||
if let Ok(guard) = CACHED_WIDTH.lock() {
|
||||
if let Some((w, ts)) = *guard {
|
||||
if ts.elapsed() < WIDTH_TTL {
|
||||
return w;
|
||||
return (w, "cached");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let raw = detect_raw(cli_width, config_width);
|
||||
let (raw, source) = detect_raw_with_source(cli_width, config_width);
|
||||
let effective = raw.saturating_sub(config_margin).max(40);
|
||||
|
||||
// Store in memo
|
||||
@@ -41,14 +50,17 @@ pub fn detect_width(cli_width: Option<u16>, config_width: Option<u16>, config_ma
|
||||
*guard = Some((effective, Instant::now()));
|
||||
}
|
||||
|
||||
effective
|
||||
(effective, source)
|
||||
}
|
||||
|
||||
fn detect_raw(cli_width: Option<u16>, config_width: Option<u16>) -> u16 {
|
||||
fn detect_raw_with_source(
|
||||
cli_width: Option<u16>,
|
||||
config_width: Option<u16>,
|
||||
) -> (u16, &'static str) {
|
||||
// 1. --width CLI flag
|
||||
if let Some(w) = cli_width {
|
||||
if w > 0 {
|
||||
return w;
|
||||
return (w, "cli_flag");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +68,7 @@ fn detect_raw(cli_width: Option<u16>, config_width: Option<u16>) -> u16 {
|
||||
if let Ok(val) = std::env::var("CLAUDE_STATUSLINE_WIDTH") {
|
||||
if let Ok(w) = val.parse::<u16>() {
|
||||
if w > 0 {
|
||||
return w;
|
||||
return (w, "env_var");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,28 +76,28 @@ fn detect_raw(cli_width: Option<u16>, config_width: Option<u16>) -> u16 {
|
||||
// 3. Config override
|
||||
if let Some(w) = config_width {
|
||||
if w > 0 {
|
||||
return w;
|
||||
return (w, "config");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. ioctl(TIOCGWINSZ) on stdout
|
||||
if let Some(w) = ioctl_width(libc::STDOUT_FILENO) {
|
||||
if w > 0 {
|
||||
return w;
|
||||
return (w, "ioctl");
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Process tree walk: find ancestor with real TTY
|
||||
if let Some(w) = process_tree_width() {
|
||||
if w > 0 {
|
||||
return w;
|
||||
return (w, "process_tree");
|
||||
}
|
||||
}
|
||||
|
||||
// 6. stty size < /dev/tty
|
||||
if let Some(w) = stty_dev_tty() {
|
||||
if w > 0 {
|
||||
return w;
|
||||
return (w, "stty");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +105,7 @@ fn detect_raw(cli_width: Option<u16>, config_width: Option<u16>) -> u16 {
|
||||
if let Ok(val) = std::env::var("COLUMNS") {
|
||||
if let Ok(w) = val.parse::<u16>() {
|
||||
if w > 0 {
|
||||
return w;
|
||||
return (w, "columns_env");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,12 +113,12 @@ fn detect_raw(cli_width: Option<u16>, config_width: Option<u16>) -> u16 {
|
||||
// 8. tput cols
|
||||
if let Some(w) = tput_cols() {
|
||||
if w > 0 {
|
||||
return w;
|
||||
return (w, "tput");
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Fallback
|
||||
120
|
||||
(120, "fallback")
|
||||
}
|
||||
|
||||
fn ioctl_width(fd: i32) -> Option<u16> {
|
||||
|
||||
Reference in New Issue
Block a user