Statusline silently corrupts unrelated lines when any line exceeds terminal width

Resolved 💬 2 comments Opened May 11, 2026 by Liquidmasl Closed May 12, 2026

Summary

When a custom statusline command emits multi-line output, if any single line exceeds the terminal width by even one character, Claude Code:

  1. Truncates that line with , but at a position that is not near terminal width — instead at a position that depends on the cumulative size of all lines in the output, not just the offending line.
  2. Silently drops every line that would have come after the offending one (the \n separators are eaten).
  3. In severe cases (line is many times terminal width), corrupts lines that came before the offending one as well.

This contradicts the statusline documentation, which states:

Multiple lines: each echo or print statement displays as a separate row. Claude Code does not truncate long lines.

The bug is reproducible in both iTerm2 and wezterm — terminal-independent.

Minimal repro

/tmp/statusline-repro.sh:

#!/bin/bash
cols=$(stty size < /dev/tty | awk '{print $2}')
echo "T1: short test line one"
printf 'T2: '; printf 'b%.0s' $(seq 1 $((cols + 5))); printf '\n'
echo "T3: short test line three"

~/.claude/settings.json:

"statusLine": { "type": "command", "command": "bash /tmp/statusline-repro.sh" }

Expected: three rows visible — T1, T2 (truncated at terminal width per docs), T3.

Actual: T1 visible (padded to width); T2 truncated mid-content well before terminal width; T3 entirely missing.

Evidence that the cut depends on cumulative output, not just the offending line

Varying the lengths of the non-overflowing lines (T1, T3) changes where T2 gets cut. With T2 fixed at term_cols + 5 chars in every case:

| T1 / T3 size | T2 cut position (approx) | T3 visible? |
|---|---|---|
| 10 chars each | ~100 b's into T2 | no |
| 28 chars each (default short text) | ~44 b's into T2 | no |
| 100 chars each | T1 itself cut at ~99 chars; T2 entirely missing | no |
| 120 chars each | T1 itself cut at ~119 chars; T2 entirely missing | no |

When T1 and T3 are large enough, the truncation cut moves to T1 — a line that, by itself, is well under terminal width. T2 (the actual offender) then never renders at all, despite being the line that violated the cap.

This means: from the perspective of a statusline-script author, no single module can know whether its output will render correctly, because that depends on what every other module produced. The contract is impossible to reason about locally.

Catastrophic variant

Increasing the offending line's size further amplifies the damage:

#!/bin/bash
cols=$(stty size < /dev/tty | awk '{print $2}')
echo "T1: short"
echo "T2: short"
printf 'T3: '; printf 'a%.0s' $(seq 1 $((cols * 3))); printf '\n'
echo "T4: short"
echo "T5: short"
echo "T6: short"

Expected: T1, T2, T3 (truncated), T4, T5, T6 — or at minimum T1, T2 unaffected and T3..T6 dropped.

Actual: entire statusline area is effectively empty / only the truncated T3 ellipsis renders. Lines before the offending line vanish along with everything after.

Workaround

Detect terminal width via stty size < /dev/tty (the stdin JSON has no terminal field) and pre-truncate every row to term_cols - 4 visible chars before emitting. ANSI-aware truncation is required so escape sequences aren't counted toward the cap. The -4 margin is empirical and may need to be widened if Claude's cap moves.

Reference implementation:

# Width detection — works inside Claude Code's statusline subprocess.
# Brace group + 2>/dev/null silences the redirect error in non-TTY contexts.
term_cols=$({ stty size < /dev/tty | awk '{print $2}'; } 2>/dev/null)
case "$term_cols" in ''|*[!0-9]*) term_cols=0 ;; esac
[ "$term_cols" -lt 20 ] && term_cols=0
row_max=0
[ "$term_cols" -gt 0 ] && row_max=$((term_cols - 4))

# ANSI/OSC-aware truncate: counts visible chars only, preserves escape
# sequences (SGR colors + OSC hyperlinks), appends "…" + reset on clip.
truncate_ansi() {
  local s="$1" max="$2"
  [ "$max" -gt 0 ] || { printf '%s' "$s"; return; }
  printf '%s' "$s" | perl -CSDA -e '
    my $max = shift; local $/; my $in = <STDIN>;
    my $out = ""; my $vis = 0; my $clipped = 0;
    while (length $in) {
      if ($in =~ s/^(\e\[[0-9;]*[A-Za-z])//)      { $out .= $1; next; }
      if ($in =~ s/^(\e\][^\a\e]*(?:\a|\e\\))//)  { $out .= $1; next; }
      if ($vis >= $max - 1) { $clipped = 1; last; }
      $in =~ s/^(.)//s; $out .= $1; $vis++;
    }
    $out .= "\x{2026}\e[0m" if $clipped;
    print $out;
  ' "$max"
}

# Then for every row of output:
[ "$row_max" -gt 0 ] && row=$(truncate_ansi "$row" "$row_max")
printf '%s\n' "$row"

Environment

  • Claude Code: 2.1.138
  • Terminals tested (same behavior in both): iTerm2 Build 3.6.10, wezterm 20240203-110809-5046fc22
  • OS: macOS 25.4 (Darwin)
  • Shell: zsh

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗