2026-06-29 Tmux Width-Responsive Statusline

What I set out to do

Make the tmux statusline collapse gracefully in “restricted space territory” (client narrower than 100 columns): drop the network up/download widget, battery, and clock; show only ram + cpu% + 1m load; and stop showing the active window’s process name in the title. Crucially, separators had to disappear with their widgets so the bar wouldn’t render empty gaps.

What I actually did

Edited nix/home-manager/modules/tmux.nix. Each droppable widget is wrapped in a width conditional that also swallows its leading separator. The CPU widget switches content rather than hiding: tmux-mem-cpu-load -v (mem, graph, cpu%, 1m/5m/15m) when wide, -g 0 -a 1 (ram + cpu% + 1m only) when restricted.

Mid-task, on request, refactored the separator handling so separators are no longer hand-paired with each widget. Introduced seg / segWide segment constructors and a joinWidgets sep widgets helper that interleaves separators automatically, binding each separator to its widget’s visibility. Adding or removing a widget is now a one-line list edit.

What was striking

Two tmux format gotchas, both now in code comments:

  • <: / >=: are string comparisons. The numeric width test needs #{e|>=:#{client_width},100}#{<:5,100} is false because “5” > “100” lexically.
  • A literal comma inside a #{?...} branch must be escaped as #,, which matters because every #[fg=...,bg=...] token has commas. joinWidgets runs branch content through escapeCommas; the condition’s own ,100 stays unescaped since it’s a protected #{...} token.
  • #(...) is invisible to #{?...} conditions. Follow-up: wanted a separator between the AI-agents widget and ram, but only when agents are present. Can’t do it at the format layer — #{?#(echo nonempty),HAS,EMPTY} returns EMPTY because tmux runs #() as an async, cached background job that reads empty during conditional evaluation. So the conditional had to move into claude-status.sh, which already branches on total>0; appending the sep_bar past that guard renders the separator only when an agent shows (passed sep_bar in as a new separator substitution, updated test-claude-status.sh).
  • tmux reads \1 in a status string as an invalid octal escape. Trimming the space tmux-mem-cpu-load leaves between the cpu graph and the percent: a capturing sed 's/ \(…%\)/\1/' made source-file fail at the status-right line, and because the reloadTmux activation runs source-file … || true, the error was swallowed and the old config stayed live — so the change looked like it did nothing. Any sed backreference/escaped-paren breaks inside tmux’s own string parser. Fix: tr -s ' ' | sed 's/ //' (no backslashes; the graph→percent gap is the first space since mem and graph aren’t separated). Lesson: a swallowed source-file error is invisible; when a tmux change “does nothing,” re-source manually and watch for a parse error.
  • Clickable widgets via #[range=user|NAME]…#[norange] + a MouseDown1StatusRight binding dispatching on #{mouse_status_range}. Wired claude→claude-pane-picker.sh, cpu→display-popup -E -w 80% -h 80% btop, session→fzf-session-picker.sh (matches prefix g). Added a clickable name content helper alongside joinWidgets. Gotcha: range=user|X fires the Status mouse key, not StatusRight (man tmux: StatusLeft/StatusRight are only for the predefined range=left/range=right). So the binding must be on MouseDown1Status, with a final select-window -t= else-branch to preserve the window list’s default click-to-select.

Built with hm switch (slow, ~5min first eval, ran in background), sourced live via the reloadTmux activation. Verified branch selection at width 50 (restricted slots render empty, no dangling separator; cpu swaps to -g 0 -a 1; window title shows index only) and 120 (full bar). Staged but not committed.