From f46c3da69c9fb129dfe713de7c73295e2ec2ed94 Mon Sep 17 00:00:00 2001 From: Taylor Eernisse Date: Sat, 7 Feb 2026 16:18:51 -0500 Subject: [PATCH] fix: width detection prioritizes dynamic sources over config Config `width` was position #3 in the detection chain, overriding all dynamic detection (ioctl, process tree, stty, etc). This meant the statusline couldn't adapt to terminal/pane resizes. Now config `width` serves two roles: - Max cap on dynamically detected widths (prevents absurd widths) - Fallback when all dynamic detection methods fail Also adds: - ioctl on stderr (works when stdout is piped) - stdin JSON `terminal_width` field for Claude Code to pass width - Distinct diagnostic sources: config_cap, config_fallback, stdin_json Co-Authored-By: Claude Opus 4.6 --- src/bin/claude-statusline.rs | 10 +++- src/input.rs | 1 + src/width.rs | 95 ++++++++++++++++++++++++++++-------- statusline.json | 28 +++++++++++ 4 files changed, 113 insertions(+), 21 deletions(-) create mode 100644 statusline.json diff --git a/src/bin/claude-statusline.rs b/src/bin/claude-statusline.rs index 3be7f0e..5e74d60 100644 --- a/src/bin/claude-statusline.rs +++ b/src/bin/claude-statusline.rs @@ -122,8 +122,14 @@ fn main() { // Detect environment let detected_theme = theme::detect_theme(&config); - let (term_width, width_source) = - width::detect_width_with_source(cli_width, config.global.width, config.global.width_margin); + let stdin_width = input_data.workspace.as_ref().and_then(|w| w.terminal_width); + let (term_width, width_source) = width::detect_width_with_source( + cli_width, + config.global.width, + config.global.width_margin, + stdin_width, + ); + let tier = width::width_tier( term_width, config.global.breakpoints.narrow, diff --git a/src/input.rs b/src/input.rs index bd8a32c..4a0cc1c 100644 --- a/src/input.rs +++ b/src/input.rs @@ -51,6 +51,7 @@ pub struct CurrentUsage { #[serde(default)] pub struct Workspace { pub project_dir: Option, + pub terminal_width: Option, } #[derive(Debug, Default, Deserialize)] diff --git a/src/width.rs b/src/width.rs index 3773fb1..e2c24b0 100644 --- a/src/width.rs +++ b/src/width.rs @@ -22,9 +22,24 @@ pub fn width_tier(width: u16, narrow_bp: u16, medium_bp: u16) -> WidthTier { } /// Detect terminal width. Memoized for 1 second across renders. -/// Priority: cli_width > env > config > ioctl > process tree > stty > COLUMNS > tput > 120 -pub fn detect_width(cli_width: Option, config_width: Option, config_margin: u16) -> u16 { - detect_width_with_source(cli_width, config_width, config_margin).0 +/// +/// Priority chain: +/// 1. `--width` CLI flag (absolute override) +/// 2. `CLAUDE_STATUSLINE_WIDTH` env var (absolute override) +/// 3. stdin JSON `terminal_width` (from Claude Code) +/// 4. ioctl stdout, 5. ioctl stderr, 6. process tree, 7. stty, 8. COLUMNS, 9. tput +/// 10. Config `width` (fallback only) +/// 11. Hardcoded 120 +/// +/// Config `width` also acts as a max cap on dynamic results (items 3-9). +/// CLI flag and env var are NOT capped — they're explicit user overrides. +pub fn detect_width( + cli_width: Option, + config_width: Option, + config_margin: u16, + stdin_width: Option, +) -> u16 { + detect_width_with_source(cli_width, config_width, config_margin, stdin_width).0 } /// Like detect_width but also returns the detection source name (for --dump-state). @@ -32,6 +47,7 @@ pub fn detect_width_with_source( cli_width: Option, config_width: Option, config_margin: u16, + stdin_width: Option, ) -> (u16, &'static str) { // Check memo first if let Ok(guard) = CACHED_WIDTH.lock() { @@ -42,7 +58,7 @@ pub fn detect_width_with_source( } } - let (raw, source) = detect_raw_with_source(cli_width, config_width); + let (raw, source) = detect_raw_with_source(cli_width, config_width, stdin_width); let effective = raw.saturating_sub(config_margin).max(40); // Store in memo @@ -56,15 +72,16 @@ pub fn detect_width_with_source( fn detect_raw_with_source( cli_width: Option, config_width: Option, + stdin_width: Option, ) -> (u16, &'static str) { - // 1. --width CLI flag + // 1. --width CLI flag (absolute, not capped) if let Some(w) = cli_width { if w > 0 { return (w, "cli_flag"); } } - // 2. CLAUDE_STATUSLINE_WIDTH env var + // 2. CLAUDE_STATUSLINE_WIDTH env var (absolute, not capped) if let Ok(val) = std::env::var("CLAUDE_STATUSLINE_WIDTH") { if let Ok(w) = val.parse::() { if w > 0 { @@ -73,52 +90,92 @@ fn detect_raw_with_source( } } - // 3. Config override + // 3-9: Dynamic detection, subject to config max cap + if let Some((w, source)) = detect_dynamic(stdin_width) { + return apply_config_cap(w, source, config_width); + } + + // 10. Config width as fallback if let Some(w) = config_width { if w > 0 { - return (w, "config"); + return (w, "config_fallback"); + } + } + + // 11. Hardcoded fallback + (120, "fallback") +} + +/// Try all dynamic detection methods in priority order. +/// Returns the first successful result. +fn detect_dynamic(stdin_width: Option) -> Option<(u16, &'static str)> { + // 3. stdin JSON terminal_width (Claude Code passes this) + if let Some(w) = stdin_width { + if w > 0 { + return Some((w, "stdin_json")); } } // 4. ioctl(TIOCGWINSZ) on stdout if let Some(w) = ioctl_width(libc::STDOUT_FILENO) { if w > 0 { - return (w, "ioctl"); + return Some((w, "ioctl_stdout")); } } - // 5. Process tree walk: find ancestor with real TTY + // 5. ioctl(TIOCGWINSZ) on stderr (often still a TTY when stdout is piped) + if let Some(w) = ioctl_width(libc::STDERR_FILENO) { + if w > 0 { + return Some((w, "ioctl_stderr")); + } + } + + // 6. Process tree walk: find ancestor with real TTY if let Some(w) = process_tree_width() { if w > 0 { - return (w, "process_tree"); + return Some((w, "process_tree")); } } - // 6. stty size < /dev/tty + // 7. stty size < /dev/tty if let Some(w) = stty_dev_tty() { if w > 0 { - return (w, "stty"); + return Some((w, "stty")); } } - // 7. COLUMNS env var + // 8. COLUMNS env var if let Ok(val) = std::env::var("COLUMNS") { if let Ok(w) = val.parse::() { if w > 0 { - return (w, "columns_env"); + return Some((w, "columns_env")); } } } - // 8. tput cols + // 9. tput cols if let Some(w) = tput_cols() { if w > 0 { - return (w, "tput"); + return Some((w, "tput")); } } - // 9. Fallback - (120, "fallback") + None +} + +/// Apply config width as a max cap on dynamically detected width. +/// If the detected width exceeds config, clamp it down and report the cap. +fn apply_config_cap( + detected: u16, + source: &'static str, + config_width: Option, +) -> (u16, &'static str) { + if let Some(max_w) = config_width { + if max_w > 0 && detected > max_w { + return (max_w, "config_cap"); + } + } + (detected, source) } fn ioctl_width(fd: i32) -> Option { diff --git a/statusline.json b/statusline.json new file mode 100644 index 0000000..7a509d4 --- /dev/null +++ b/statusline.json @@ -0,0 +1,28 @@ +{ + "$schema": "./schema.json", + "global": { + "justify": "spread", + "theme": "dark", + "responsive": true, + "width": 500, + "width_margin": 20 + }, + "glyphs": { + "enabled": true + }, + "layout": "verbose", + "sections": { + "context_usage": { + "enabled": true + }, + "context_trend": { + "enabled": true + }, + "cost_trend": { + "enabled": true + }, + "load": { + "enabled": false + } + } +}