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:
Taylor Eernisse
2026-02-06 15:59:15 -05:00
parent 73401beb47
commit 4c9139ec42
15 changed files with 1198 additions and 138 deletions

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@
"separator": " | ", "separator": " | ",
"justify": "space-between", "justify": "space-between",
"vcs": "auto", "vcs": "auto",
"cache_dir": "/tmp/claude-sl-{session_id}", "cache_dir": "/tmp/claude-sl-{session_id}-{cache_version}-{config_hash}",
"responsive": true, "responsive": true,
"breakpoints": { "breakpoints": {
"narrow": 60, "narrow": 60,

View File

@@ -1,6 +1,11 @@
use claude_statusline::section::RenderContext; 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::io::Read;
use std::time::Duration;
fn main() { fn main() {
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
@@ -18,12 +23,27 @@ fn main() {
return; return;
} }
if args.iter().any(|a| a == "--list-sections") { if args.iter().any(|a| a == "--list-sections") {
for (id, _) in section::registry() { println!("{:<22} pri flex shell est_w", "ID");
println!("{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; 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 let cli_color = args
.iter() .iter()
.find_map(|a| a.strip_prefix("--color=")) .find_map(|a| a.strip_prefix("--color="))
@@ -57,7 +77,7 @@ fn main() {
}); });
// Load config // 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, Ok(v) => v,
Err(e) => { Err(e) => {
eprintln!("claude-statusline: {e}"); eprintln!("claude-statusline: {e}");
@@ -102,8 +122,8 @@ fn main() {
// Detect environment // Detect environment
let detected_theme = theme::detect_theme(&config); let detected_theme = theme::detect_theme(&config);
let term_width = let (term_width, width_source) =
width::detect_width(cli_width, config.global.width, config.global.width_margin); width::detect_width_with_source(cli_width, config.global.width, config.global.width_margin);
let tier = width::width_tier( let tier = width::width_tier(
term_width, term_width,
config.global.breakpoints.narrow, config.global.breakpoints.narrow,
@@ -117,31 +137,63 @@ fn main() {
.unwrap_or("."); .unwrap_or(".");
let session = cache::session_id(project_dir); 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 color_enabled = color::should_use_color(cli_color.as_deref(), &config.global.color);
let vcs_type = detect_vcs(project_dir, &config); 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 // Build render context
let project_path = std::path::Path::new(project_dir); let project_path = std::path::Path::new(project_dir);
let computed_metrics = metrics::ComputedMetrics::from_input(&input_data); 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 { let ctx = RenderContext {
input: &input_data, input: &input_data,
config: &config, config: &config,
@@ -153,11 +205,161 @@ fn main() {
cache: &cache, cache: &cache,
glyphs_enabled: config.glyphs.enabled, glyphs_enabled: config.glyphs.enabled,
color_enabled, color_enabled,
no_shell,
shell_config: &shell_config,
metrics: computed_metrics, 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); let output = layout::render_all(&ctx);
print!("{output}"); 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 { fn detect_vcs(dir: &str, config: &config::Config) -> section::VcsType {
@@ -196,24 +398,74 @@ fn dump_state_output(
format: &str, format: &str,
config: &config::Config, config: &config::Config,
term_width: u16, term_width: u16,
width_source: &str,
tier: width::WidthTier, tier: width::WidthTier,
theme: theme::Theme, theme: theme::Theme,
vcs: section::VcsType, vcs: section::VcsType,
project_dir: &str, project_dir: &str,
session_id: &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!({ let json = serde_json::json!({
"terminal": { "terminal": {
"effective_width": term_width, "effective_width": term_width,
"width_margin": config.global.width_margin, "width_margin": config.global.width_margin,
"width_tier": format!("{tier:?}"), "width_tier": format!("{tier:?}"),
"source": width_source,
}, },
"theme": theme.as_str(), "theme": theme.as_str(),
"vcs": format!("{vcs:?}"), "vcs": format!("{vcs:?}"),
"layout": { "layout": {
"justify": format!("{:?}", config.global.justify), "justify": format!("{:?}", config.global.justify),
"separator": &config.global.separator, "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": { "paths": {
"project_dir": project_dir, "project_dir": project_dir,
"cache_dir": config.global.cache_dir.replace("{session_id}", session_id), "cache_dir": config.global.cache_dir.replace("{session_id}", session_id),

View File

@@ -1,21 +1,56 @@
use std::cell::RefCell;
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime}; 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 { pub struct Cache {
dir: Option<PathBuf>, dir: Option<PathBuf>,
jitter_pct: u8,
diagnostics: RefCell<Vec<CacheDiag>>,
} }
impl Cache { 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. /// Create cache with secure directory. Returns disabled cache on failure.
pub fn new(template: &str, session_id: &str) -> Self { /// Replaces `{session_id}`, `{cache_version}`, and `{config_hash}` in template.
let dir_str = template.replace("{session_id}", session_id); 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); let dir = PathBuf::from(&dir_str);
if !dir.exists() { if !dir.exists() {
if fs::create_dir_all(&dir).is_err() { 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)] #[cfg(unix)]
{ {
@@ -26,29 +61,93 @@ impl Cache {
// Security: verify ownership, not a symlink, not world-writable // Security: verify ownership, not a symlink, not world-writable
if !verify_cache_dir(&dir) { 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> { pub fn dir(&self) -> Option<&Path> {
self.dir.as_deref() 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> { pub fn get(&self, key: &str, ttl: Duration) -> Option<String> {
let path = self.key_path(key)?; let path = match self.key_path(key) {
let meta = fs::metadata(&path).ok()?; 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 modified = meta.modified().ok()?;
let age = SystemTime::now().duration_since(modified).ok()?; let age = SystemTime::now().duration_since(modified).ok()?;
if age < ttl { let age_ms = age.as_millis() as u64;
fs::read_to_string(&path).ok() 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 { } else {
self.record_diag(key, false, Some(age_ms));
None 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. /// Get stale cached value (ignores TTL). Used as fallback on command failure.
pub fn get_stale(&self, key: &str) -> Option<String> { pub fn get_stale(&self, key: &str) -> Option<String> {
let path = self.key_path(key)?; 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()); let hash = Md5::digest(project_dir.as_bytes());
format!("{:x}", hash)[..12].to_string() 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
}
}

View File

@@ -49,6 +49,7 @@ pub fn resolve_color(name: &str, theme: Theme, palette: &ThemeColors) -> String
} }
/// Determine whether color output should be used. /// 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 { pub fn should_use_color(cli_color: Option<&str>, config_color: &crate::config::ColorMode) -> bool {
if std::env::var("NO_COLOR").is_ok() { if std::env::var("NO_COLOR").is_ok() {
return false; 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 { match config_color {
crate::config::ColorMode::Always => true, crate::config::ColorMode::Always => true,
crate::config::ColorMode::Never => false, crate::config::ColorMode::Never => false,

View File

@@ -128,6 +128,7 @@ pub enum ColorMode {
pub struct Breakpoints { pub struct Breakpoints {
pub narrow: u16, pub narrow: u16,
pub medium: u16, pub medium: u16,
pub hysteresis: u16,
} }
impl Default for Breakpoints { impl Default for Breakpoints {
@@ -135,6 +136,7 @@ impl Default for Breakpoints {
Self { Self {
narrow: 60, narrow: 60,
medium: 100, medium: 100,
hysteresis: 2,
} }
} }
} }
@@ -660,7 +662,11 @@ pub fn deep_merge(base: &mut Value, patch: &Value) {
// ── Config loading ────────────────────────────────────────────────────── // ── Config loading ──────────────────────────────────────────────────────
/// Load config: embedded defaults deep-merged with user overrides. /// 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 mut base: Value = serde_json::from_str(DEFAULTS_JSON)?;
let user_path = explicit_path 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 mut warnings = Vec::new();
let config: Config = serde_ignored::deserialize(base, |path| { let config: Config = serde_ignored::deserialize(base, |path| {
warnings.push(format!("unknown config key: {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> { fn xdg_config_path() -> Option<std::path::PathBuf> {

View File

@@ -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 { 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" "dense"
} else if width < bp.medium { } else if width < bp.medium {
"standard" "standard"
} else { } else {
"verbose" "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(); let mut active: Vec<ActiveSection> = Vec::new();
for id in section_ids { 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 let Some(mut output) = section::render_section(id, ctx) {
if output.raw.is_empty() && !section::is_spacer(id) { if output.raw.is_empty() && !section::is_spacer(id) {
continue; continue;
@@ -97,7 +157,12 @@ fn render_line(section_ids: &[String], ctx: &RenderContext, separator: &str) ->
} }
// Phase 2: Priority drop if overflowing // 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 // Phase 3: Flex expand or justify
let line = if ctx.config.global.justify != JustifyMode::Left 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.config.global.justify,
ctx.color_enabled, ctx.color_enabled,
) )
} else if ctx.budget_exceeded() {
// Over budget: skip flex expansion, emit as-is
assemble_left(&active, separator, ctx.color_enabled)
} else { } else {
flex::flex_expand(&mut active, ctx, separator); flex::flex_expand(&mut active, ctx, separator);
assemble_left(&active, separator, ctx.color_enabled) assemble_left(&active, separator, ctx.color_enabled)

View File

@@ -1,9 +1,22 @@
use crate::format; use crate::format;
use crate::layout::ActiveSection; 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. /// Priority 1 sections never drop.
pub fn priority_drop( fn tiered_drop(
mut active: Vec<ActiveSection>, mut active: Vec<ActiveSection>,
term_width: u16, term_width: u16,
separator: &str, separator: &str,
@@ -23,9 +36,42 @@ pub fn priority_drop(
active 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. /// Calculate total display width including separators.
/// Spacers suppress adjacent separators on both sides. /// 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 sep_w = format::display_width(separator);
let mut total = 0; let mut total = 0;

View File

@@ -13,18 +13,24 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
return None; 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 ttl = Duration::from_secs(ctx.config.sections.beads.ttl);
let timeout = Duration::from_millis(200);
let cached = ctx.cache.get("beads_summary", ttl); let cached = ctx.cache.get("beads_summary", ttl);
let summary = cached.or_else(|| { let summary = cached.or_else(|| {
// Run br ready to get count of ready items // Use prefetched result if available, otherwise exec
let out = shell::exec_with_timeout( let out = ctx.shell_results.get("beads").cloned().unwrap_or_else(|| {
"br", shell::exec_gated(
&["ready", "--json"], ctx.shell_config,
Some(ctx.project_dir.to_str()?), "br",
timeout, &["ready", "--json"],
)?; Some(ctx.project_dir.to_str()?),
)
})?;
// Count JSON array items (simple: count opening braces at indent level 1) // Count JSON array items (simple: count opening braces at indent level 1)
let count = out.matches("\"id\"").count(); let count = out.matches("\"id\"").count();
let summary = format!("{count}"); let summary = format!("{count}");
@@ -46,3 +52,17 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
Some(SectionOutput { raw, ansi }) 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 })
}

View File

@@ -1,4 +1,5 @@
use crate::color; use crate::color;
use crate::config::CustomCommand;
use crate::section::{RenderContext, SectionOutput}; use crate::section::{RenderContext, SectionOutput};
use crate::shell; use crate::shell;
use std::time::Duration; use std::time::Duration;
@@ -7,10 +8,16 @@ use std::time::Duration;
pub fn render(id: &str, ctx: &RenderContext) -> Option<SectionOutput> { pub fn render(id: &str, ctx: &RenderContext) -> Option<SectionOutput> {
let cmd_cfg = ctx.config.custom.iter().find(|c| c.id == id)?; 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 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 cached = ctx.cache.get(&cache_key, ttl);
let output_str = cached.or_else(|| { let output_str = cached.or_else(|| {
let result = if let Some(ref exec) = cmd_cfg.exec { 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; return None;
} }
let args: Vec<&str> = exec[1..].iter().map(|s| s.as_str()).collect(); 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 { } 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 { } else {
None None
}; };
@@ -34,16 +41,29 @@ pub fn render(id: &str, ctx: &RenderContext) -> Option<SectionOutput> {
return None; 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 label = cmd_cfg.label.as_deref().unwrap_or("");
let raw = if label.is_empty() { let raw = if label.is_empty() {
output_str.clone() output_str.to_string()
} else { } else {
format!("{label}: {output_str}") format!("{label}: {output_str}")
}; };
let ansi = if ctx.color_enabled { let ansi = if ctx.color_enabled {
if let Some(ref color_cfg) = cmd_cfg.color { 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); let c = color::resolve_color(matched_color, ctx.theme, &ctx.config.colors);
format!("{c}{raw}{}", color::RESET) format!("{c}{raw}{}", color::RESET)
} else if let Some(ref default_c) = cmd_cfg.default_color { } else if let Some(ref default_c) = cmd_cfg.default_color {

View File

@@ -7,6 +7,18 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
return None; 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 ttl = Duration::from_secs(ctx.config.sections.load.ttl);
let cached = ctx.cache.get("load_avg", ttl); let cached = ctx.cache.get("load_avg", ttl);
@@ -21,12 +33,10 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
let out = crate::shell::exec_with_timeout( // Use prefetched result if available, otherwise exec
"sysctl", let out = ctx.shell_results.get("load").cloned().unwrap_or_else(|| {
&["-n", "vm.loadavg"], crate::shell::exec_gated(ctx.shell_config, "sysctl", &["-n", "vm.loadavg"], None)
None, })?;
Duration::from_millis(100),
)?;
// sysctl output: "{ 1.23 4.56 7.89 }" // sysctl output: "{ 1.23 4.56 7.89 }"
let load1 = out let load1 = out
.trim_start_matches(|c: char| !c.is_ascii_digit() && c != '.') .trim_start_matches(|c: char| !c.is_ascii_digit() && c != '.')

View File

@@ -2,6 +2,7 @@ use crate::cache::Cache;
use crate::config::Config; use crate::config::Config;
use crate::input::InputData; use crate::input::InputData;
use crate::metrics::ComputedMetrics; use crate::metrics::ComputedMetrics;
use crate::shell::ShellConfig;
use crate::theme::Theme; use crate::theme::Theme;
use crate::width::WidthTier; use crate::width::WidthTier;
@@ -62,36 +63,259 @@ pub struct RenderContext<'a> {
pub cache: &'a Cache, pub cache: &'a Cache,
pub glyphs_enabled: bool, pub glyphs_enabled: bool,
pub color_enabled: bool, pub color_enabled: bool,
pub no_shell: bool,
pub shell_config: &'a ShellConfig,
pub metrics: ComputedMetrics, 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. impl RenderContext<'_> {
pub fn registry() -> Vec<(&'static str, RenderFn)> { /// 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![ vec![
("model", model::render), SectionDescriptor {
("provider", provider::render), id: "model",
("project", project::render), render: model::render,
("vcs", vcs::render), priority: 1,
("beads", beads::render), is_spacer: false,
("context_bar", context_bar::render), is_flex: false,
("context_usage", context_usage::render), estimated_width: 12,
("context_remaining", context_remaining::render), shell_out: false,
("tokens_raw", tokens_raw::render), },
("cache_efficiency", cache_efficiency::render), SectionDescriptor {
("cost", cost::render), id: "provider",
("cost_velocity", cost_velocity::render), render: provider::render,
("token_velocity", token_velocity::render), priority: 2,
("cost_trend", cost_trend::render), is_spacer: false,
("context_trend", context_trend::render), is_flex: false,
("lines_changed", lines_changed::render), estimated_width: 10,
("duration", duration::render), shell_out: false,
("tools", tools::render), },
("turns", turns::render), SectionDescriptor {
("load", load::render), id: "project",
("version", version::render), render: project::render,
("time", time::render), priority: 1,
("output_style", output_style::render), is_spacer: false,
("hostname", hostname::render), 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() { for desc in registry() {
if name == id { if desc.id == id {
return render_fn(ctx); return (desc.render)(ctx);
} }
} }

View File

@@ -3,7 +3,6 @@ use crate::glyph;
use crate::section::{RenderContext, SectionOutput, VcsType}; use crate::section::{RenderContext, SectionOutput, VcsType};
use crate::shell::{self, GitStatusV2}; use crate::shell::{self, GitStatusV2};
use crate::width::WidthTier; use crate::width::WidthTier;
use std::time::Duration;
pub fn render(ctx: &RenderContext) -> Option<SectionOutput> { pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
if !ctx.config.sections.vcs.base.enabled { if !ctx.config.sections.vcs.base.enabled {
@@ -13,6 +12,11 @@ pub fn render(ctx: &RenderContext) -> Option<SectionOutput> {
return None; 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 dir = ctx.project_dir.to_str()?;
let ttl = &ctx.config.sections.vcs.ttl; let ttl = &ctx.config.sections.vcs.ttl;
let glyphs = &ctx.config.glyphs; 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( fn render_git(
ctx: &RenderContext, ctx: &RenderContext,
dir: &str, dir: &str,
ttl: &crate::config::VcsTtl, ttl: &crate::config::VcsTtl,
glyphs: &crate::config::GlyphConfig, glyphs: &crate::config::GlyphConfig,
) -> Option<SectionOutput> { ) -> Option<SectionOutput> {
use std::time::Duration;
let branch_ttl = Duration::from_secs(ttl.branch); let branch_ttl = Duration::from_secs(ttl.branch);
let dirty_ttl = Duration::from_secs(ttl.dirty); let dirty_ttl = Duration::from_secs(ttl.dirty);
let ab_ttl = Duration::from_secs(ttl.ahead_behind); 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 branch_cached = ctx.cache.get("vcs_branch", branch_ttl);
let dirty_cached = ctx.cache.get("vcs_dirty", dirty_ttl); let dirty_cached = ctx.cache.get("vcs_dirty", dirty_ttl);
let ab_cached = ctx.cache.get("vcs_ab", ab_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 status = if branch_cached.is_none() || dirty_cached.is_none() || ab_cached.is_none() {
let output = shell::exec_with_timeout( // Use prefetched result if available, otherwise exec
"git", let output = ctx.shell_results.get("vcs").cloned().unwrap_or_else(|| {
&["-C", dir, "status", "--porcelain=v2", "--branch"], shell::exec_gated(
None, ctx.shell_config,
timeout, "git",
); &["-C", dir, "status", "--porcelain=v2", "--branch"],
None,
)
});
match output { match output {
Some(ref out) => { Some(ref out) => {
let s = shell::parse_git_status_v2(out); let s = shell::parse_git_status_v2(out);
@@ -138,24 +159,33 @@ fn render_jj(
ttl: &crate::config::VcsTtl, ttl: &crate::config::VcsTtl,
glyphs: &crate::config::GlyphConfig, glyphs: &crate::config::GlyphConfig,
) -> Option<SectionOutput> { ) -> Option<SectionOutput> {
use std::time::Duration;
let branch_ttl = Duration::from_secs(ttl.branch); 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 branch = ctx.cache.get("vcs_branch", branch_ttl).or_else(|| {
let out = shell::exec_with_timeout( // Use prefetched result if available
"jj", let out = ctx
&[ .shell_results
"log", .get("vcs")
"-r", .cloned()
"@", .flatten()
"--no-graph", .or_else(|| {
"-T", shell::exec_gated(
"if(bookmarks, bookmarks.join(\",\"), change_id.shortest(8))", ctx.shell_config,
"--color=never", "jj",
], &[
Some(dir), "log",
timeout, "-r",
)?; "@",
"--no-graph",
"-T",
"if(bookmarks, bookmarks.join(\",\"), change_id.shortest(8))",
"--color=never",
],
Some(dir),
)
})?;
ctx.cache.set("vcs_branch", &out); ctx.cache.set("vcs_branch", &out);
Some(out) Some(out)
})?; })?;

View File

@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -8,6 +9,166 @@ const GIT_ENV: &[(&str, &str)] = &[
("LC_ALL", "C"), ("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. /// Execute a command with a polling timeout. Returns None on timeout or error.
pub fn exec_with_timeout( pub fn exec_with_timeout(
program: &str, program: &str,

View File

@@ -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. /// Detect terminal width. Memoized for 1 second across renders.
/// Priority: cli_width > env > config > ioctl > process tree > stty > COLUMNS > tput > 120 /// 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 { 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 // Check memo first
if let Ok(guard) = CACHED_WIDTH.lock() { if let Ok(guard) = CACHED_WIDTH.lock() {
if let Some((w, ts)) = *guard { if let Some((w, ts)) = *guard {
if ts.elapsed() < WIDTH_TTL { 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); let effective = raw.saturating_sub(config_margin).max(40);
// Store in memo // 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())); *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 // 1. --width CLI flag
if let Some(w) = cli_width { if let Some(w) = cli_width {
if w > 0 { 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(val) = std::env::var("CLAUDE_STATUSLINE_WIDTH") {
if let Ok(w) = val.parse::<u16>() { if let Ok(w) = val.parse::<u16>() {
if w > 0 { 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 // 3. Config override
if let Some(w) = config_width { if let Some(w) = config_width {
if w > 0 { if w > 0 {
return w; return (w, "config");
} }
} }
// 4. ioctl(TIOCGWINSZ) on stdout // 4. ioctl(TIOCGWINSZ) on stdout
if let Some(w) = ioctl_width(libc::STDOUT_FILENO) { if let Some(w) = ioctl_width(libc::STDOUT_FILENO) {
if w > 0 { if w > 0 {
return w; return (w, "ioctl");
} }
} }
// 5. Process tree walk: find ancestor with real TTY // 5. Process tree walk: find ancestor with real TTY
if let Some(w) = process_tree_width() { if let Some(w) = process_tree_width() {
if w > 0 { if w > 0 {
return w; return (w, "process_tree");
} }
} }
// 6. stty size < /dev/tty // 6. stty size < /dev/tty
if let Some(w) = stty_dev_tty() { if let Some(w) = stty_dev_tty() {
if w > 0 { 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(val) = std::env::var("COLUMNS") {
if let Ok(w) = val.parse::<u16>() { if let Ok(w) = val.parse::<u16>() {
if w > 0 { 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 // 8. tput cols
if let Some(w) = tput_cols() { if let Some(w) = tput_cols() {
if w > 0 { if w > 0 {
return w; return (w, "tput");
} }
} }
// 9. Fallback // 9. Fallback
120 (120, "fallback")
} }
fn ioctl_width(fd: i32) -> Option<u16> { fn ioctl_width(fd: i32) -> Option<u16> {