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": " | ",
|
"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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
210
src/cache.rs
210
src/cache.rs
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 != '.')
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
161
src/shell.rs
161
src/shell.rs
@@ -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,
|
||||||
|
|||||||
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.
|
/// 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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user