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 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-07 16:18:51 -05:00
parent 4e38b8259b
commit f46c3da69c
4 changed files with 113 additions and 21 deletions

View File

@@ -122,8 +122,14 @@ fn main() {
// Detect environment // Detect environment
let detected_theme = theme::detect_theme(&config); let detected_theme = theme::detect_theme(&config);
let (term_width, width_source) = let stdin_width = input_data.workspace.as_ref().and_then(|w| w.terminal_width);
width::detect_width_with_source(cli_width, config.global.width, config.global.width_margin); let (term_width, width_source) = width::detect_width_with_source(
cli_width,
config.global.width,
config.global.width_margin,
stdin_width,
);
let tier = width::width_tier( let tier = width::width_tier(
term_width, term_width,
config.global.breakpoints.narrow, config.global.breakpoints.narrow,

View File

@@ -51,6 +51,7 @@ pub struct CurrentUsage {
#[serde(default)] #[serde(default)]
pub struct Workspace { pub struct Workspace {
pub project_dir: Option<String>, pub project_dir: Option<String>,
pub terminal_width: Option<u16>,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]

View File

@@ -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. /// Detect terminal width. Memoized for 1 second across renders.
/// Priority: cli_width > env > config > ioctl > process tree > stty > COLUMNS > tput > 120 ///
pub fn detect_width(cli_width: Option<u16>, config_width: Option<u16>, config_margin: u16) -> u16 { /// Priority chain:
detect_width_with_source(cli_width, config_width, config_margin).0 /// 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<u16>,
config_width: Option<u16>,
config_margin: u16,
stdin_width: Option<u16>,
) -> 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). /// 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<u16>, cli_width: Option<u16>,
config_width: Option<u16>, config_width: Option<u16>,
config_margin: u16, config_margin: u16,
stdin_width: Option<u16>,
) -> (u16, &'static str) { ) -> (u16, &'static str) {
// Check memo first // Check memo first
if let Ok(guard) = CACHED_WIDTH.lock() { 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); let effective = raw.saturating_sub(config_margin).max(40);
// Store in memo // Store in memo
@@ -56,15 +72,16 @@ pub fn detect_width_with_source(
fn detect_raw_with_source( fn detect_raw_with_source(
cli_width: Option<u16>, cli_width: Option<u16>,
config_width: Option<u16>, config_width: Option<u16>,
stdin_width: Option<u16>,
) -> (u16, &'static str) { ) -> (u16, &'static str) {
// 1. --width CLI flag // 1. --width CLI flag (absolute, not capped)
if let Some(w) = cli_width { if let Some(w) = cli_width {
if w > 0 { if w > 0 {
return (w, "cli_flag"); 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(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 {
@@ -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 let Some(w) = config_width {
if w > 0 { 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<u16>) -> 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 // 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, "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 let Some(w) = process_tree_width() {
if w > 0 { 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 let Some(w) = stty_dev_tty() {
if w > 0 { 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(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, "columns_env"); return Some((w, "columns_env"));
} }
} }
} }
// 8. tput cols // 9. tput cols
if let Some(w) = tput_cols() { if let Some(w) = tput_cols() {
if w > 0 { if w > 0 {
return (w, "tput"); return Some((w, "tput"));
} }
} }
// 9. Fallback None
(120, "fallback") }
/// 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>,
) -> (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<u16> { fn ioctl_width(fd: i32) -> Option<u16> {

28
statusline.json Normal file
View File

@@ -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
}
}
}