Skip to content

fix(web): let the statusline run be as tall as a real statusline - #56

Merged
AltanS merged 3 commits into
AltanS:mainfrom
stekman08:fix/statusline-run-bound
Jul 30, 2026
Merged

fix(web): let the statusline run be as tall as a real statusline#56
AltanS merged 3 commits into
AltanS:mainfrom
stekman08:fix/statusline-run-bound

Conversation

@stekman08

@stekman08 stekman08 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

One constant: MAX_STATUS_LINES: 3 → 8. A statusline taller than 3 rows made locateInputBox miss the input box, so sending from the composer stalled with the text already in the box.

What ships

  • chrome.ts — the bound raised to 8, its comment rewritten to drop two claims that turned out false, and a schema docstring that contradicted the code corrected. 6 lines.
  • chrome.test.ts — five blocks, red-first. Block 2 pins what actually protects the walk, which nothing checked before.
  • .adr/0004 + a one-line rule in CLAUDE.md — the argument, because "just delete the bound" is what the next reader will reach for.
  • bridge/journal/pi.test.ts — one line, unrelated, fixes two macOS-only failures. Flagged for splitting below.

No version bump — left the release commit to you, matching how #52 landed.

The bug

Sending from the composer intermittently fails with "Message didn't reach the input box — a dialog may be waiting. Nothing was submitted." The text is already in the pane's input box; only Enter is withheld.

locateInputBox accepts at most 3 non-blank, non-border rows between the input box's bottom border and the tail. That run is the statusline plus its hint rows — and a statusline's height is whatever statusLine.command prints. Nothing caps it: multi-line statuslines are an ordinary setup, generators in common use expose several line slots, and any row can soft-wrap into two on a narrow pane, which is the normal case on a phone. At 4 rows the border is never reached, extractInputDraft returns null, and sendGuardedReply stalls.

It reads as intermittent because statusline widgets are typically conditional — git state, PR state, a context meter — so a row appears and vanishes with no config change, carrying a pane over the threshold and back.

Same class as d9521e3, which admitted more rows under MAX_FOOTER_LINES = 8 when the background-agents footer broke this same walk: "it tolerated only the statusline window" (fixtures/panes/README.md:52-56). This is the statusline half, landing on the same number.

Why not just delete the bound

Because the reasons to keep it are two-thirds wrong, which is the trap. The old comment credited it with stopping unbounded stripping and with protecting dialogs below the box. Neither is true — step (c)'s border requirement does the first, and the blank line Claude paints above a dialog's footer hint does the second, in 20 of 20 dialog fixtures. Raising the bound even hedges less, not more, since dialogs are shorter than a tall statusline.

It's kept anyway, because an unbounded walk has no failsafe at all. Sized to 8 to match MAX_FOOTER_LINES.

ADR 0004 has the full argument (.adr/0004-the-statusline-run-is-bounded.md in this diff) — all four falsified justifications with the measurement that killed each, the two bounds' composition, and the consequences I'm accepting. Worth reading before the diff; it's the reason this is 8 and not 4, and the reason the constant isn't simply deleted. A short rule goes in CLAUDE.md per .adr/README.md's own division of labour. Happy to drop either if you'd rather own that text.

What it costs

Stated properly in the ADR's Consequences; in short: the scrollback-echo false-ENTER window widens from r<=3 to r<=8 (zero instances in 47 real captures, precondition absent from every real render, pinned as a known limitation in the tests); the composed ceiling with MAX_FOOTER_LINES rises from 12 rows to 17; and statusline rows past the first stop being rendered on panes this un-blocks, which is how every ≤3-row pane already behaves. Happy to follow up on that last one separately if you'd rather surface the whole run.

Tests

Red-first: blocks 1, 3 and 4 fail before the change (7 failures). Blocks 2 and 5 are invariant tests that pass before and after.

The one worth your eye is block 2 — all 20 dialog fixtures asserted safe at 3, at 8 and unbounded, plus an assertion that each one's tail run ends in a blank within 2 rows. That pins what actually protects the walk, and nothing checked it before. Block 5 pins all 29 real captures on {statusLineFound, draft, stripped}, taken from a pre-change run so it asserts an invariant rather than mirroring the new code; 0 of 29 move.

web: 92 files / 1311 tests green (was 1224). Root: 474 pass / 0 fail. Typecheck clean both sides.

One unrelated commit, flagged for splitting

bridge/journal/pi.test.ts fails two tests on macOS: the fixture builds its base from tmpdir(), which lives under /var/folders, and containedRealpath resolves /var/private/var by design, so the returned path never string-equals the fixture path. Linux CI never sees it. One line — realpath the base first. All six tests in that block still pass, including refuses a symlink inside the root that points outside it; 18 tests before and after, no skip/only. Without it the pre-push hook can't run the backend suite on macOS. Say the word and I'll pull it into its own PR.


Two follow-ups I'm not doing here: plumbing Herdr's agent_status / scroll geometry into web/ would let box liveness be established rather than inferred from shape, which closes the echo class properly and lets the bound go entirely; and composer.tsx:299-316 fires its pre-clear sweep before sendGuardedReply, gated only on the memoized dialogPresent that agent-chat.tsx:192-194 itself calls "not load-bearing".

The constant's comment was rewritten in place, same line count, dropping the two false claims and pointing at the ADR. Say if you'd rather have the argument inline in the source instead.

`locateInputBox` accepted at most MAX_STATUS_LINES = 3 non-blank rows between the
input box's bottom border and the tail. A statusline is the output of an arbitrary
user command, and Claude prints its output verbatim below the box, so a 4-row
statusline meant the border was never reached, `extractInputDraft` returned null,
`draftCarriesSend` was false, and `sendGuardedReply` stalled with "Message didn't
reach the input box". The text was already in the box; only Enter was withheld.

Nothing in the contract caps that height. A multi-line statusline is an ordinary
setup: generators in common use expose several line slots, a hand-rolled script can
print as many rows as it likes, and any single row can soft-wrap into two on a
narrow pane — the normal case on a phone. 3 was below configurations that are
entirely unremarkable.

Height also moves at runtime, which is why the failure reads as intermittent rather
than reproducible: statusline widgets are typically conditional (git state, PR or
review state, a context meter, a session timer), so a statusline gains or loses a
row with nobody touching a setting, carrying a pane over the threshold and back.
Seen exactly that way in practice — panes that refused to send one day were fine
the next, with others taking their place.

Raised to 8, mirroring MAX_FOOTER_LINES. Same class of bug as d9521e3, which
admitted more rows below the border when the background-agents footer broke this
same walk; fixtures/panes/README.md records that it "tolerated only the statusline
window".

The old comment credited the bound with protection it does not provide, and four
separate justifications for keeping it low were each falsified by measurement:

  - "stops unbounded stripping" — step (c) requires a border. 200k borderless
    rows with the bound removed strip 0 lines.
  - "distinguishes the live box from one in scrollback" — a Claude pane runs on
    the alternate screen and keeps no scrollback ring (7 sites in this repo say
    so; no running Claude pane reports a non-zero max_offset_from_bottom).
  - "keeps a dialog below the box from being swallowed" — all 20 dialog fixtures
    are refused by steps (c)/(d)/(e). Claude paints a blank above a dialog's
    footer hint, ending the run within 2 rows in 20/20. That blank is the guard.
  - "at least hedges against a blank-free run below the box" — backwards.
    Dialogs are 2-11 rows, so a taller ceiling admits more: a blank-free dialog
    is refused for 8/20 fixtures at 3 but only 2/20 at 8.

The bound is kept, not deleted, only because an unbounded walk has no failsafe of
its own: a 60-row blank-free run below a complete box is matched and 63 lines are
stripped. ADR 0004 carries the full reasoning.

Costs, stated rather than hedged:

  - The scrollback-echo false-ENTER window widens from r<=3 to r<=8.
    draftCarriesSend cannot help — the echo IS the sent text. Zero instances in
    47 real captures, and the precondition appears in no real render.
  - The two bounds compose: step (a) peels up to MAX_FOOTER_LINES rows plus their
    blank, then step (b) takes up to MAX_STATUS_LINES more, so the deepest
    strippable run below the box goes from 12 rows to 17.
  - extractStatusLine surfaces only the first row below the border, so an
    un-blocked pane trades "whole box visible as raw chrome" for "one line in the
    app strip": cwd, branch and the permission-mode hint stop being rendered.
    Not new behaviour — every <=3-row pane already worked this way.

Tests. Five blocks, every expectation an executed measurement:

  1. heights 1-8 locate the box; 9 and 10 fall back to the raw mirror, pinning
     the ceiling as deliberate.
  2. all 20 dialog fixtures surface no box and lose no rows above their tail, at
     3, at 8 and unbounded — plus an assertion that each one's tail run ends in a
     blank within 2 rows, so if Claude stops painting it the suite says so. This
     pins what actually protects the walk; nothing checked it before.
  3. the one shape the ceiling still refuses, and the 7-row case it does not,
     named as the known limitation it is.
  4. the scrollback echo, pinned deliberately at r=3 (pre-existing) and r=8
     (added here), with the two real echoes in the corpus asserted safe.
  5. all 29 real captures pinned on {statusLineFound, draft, stripped}, taken
     from a pre-change run so it asserts an invariant rather than mirroring the
     new code. Green before and after; 0 of 29 move.

Note on comment density: the repo's commit hook blocks new explanatory comments,
so the reasoning above lives here and in ADR 0004 rather than in chrome.ts.
Two tests in this block fail on macOS and pass on Linux:

  PiTranscriptSource — path refs are confined to the root
    > resolves a path ref that really is inside the root
    > resolves the id fallback by scanning the per-cwd directories

  Expected: "/var/folders/…/sessions/…/2026-07-29T10-00-00-000Z_….jsonl"
  Received: "/private/var/folders/…/sessions/…/2026-07-29T10-00-00-000Z_….jsonl"

The fixture derives its base from tmpdir(), which on macOS is under /var/folders,
and /var is a symlink to /private/var. containedRealpath resolves symlinks by
design — that is the entire containment rule — so the path it returns can never
string-equal the unresolved fixture path. On Linux /var is a real directory and
both tests pass, which is why CI has never seen this.

Fix: realpath the fixture's base before deriving root and log from it, so the
expected value is already in the form containedRealpath will return. The
production code and the assertions are untouched.

All six tests in the block still pass, including the two that make it worth
having — "refuses a path ref pointing outside the root" and "refuses a symlink
inside the root that points outside it". File total is unchanged at 18 tests
(was 16 pass + 2 fail, now 18 pass), and there are no skip/only/todo markers, so
nothing was silenced to get here.

Incidental to the statusline-run change it ships alongside: without it the
pre-push hook cannot run the backend suite on a macOS contributor's machine.
Happy to split it into its own PR if you'd rather.
Four justifications for keeping MAX_STATUS_LINES low were each falsified by
measurement while fixing d41b14b, and the constant's old comment asserted two of
them. Records which claims died and how, so the next reader doesn't re-derive
them — 'just delete the bound' was proposed twice in one session precisely
because the stated reasons were two-thirds wrong.

Adds the short normative rule to CLAUDE.md per .adr/README.md's own division of
labour (rule in CLAUDE.md, argument in the ADR), alongside the existing ADR 0001
and 0002 rules.
@stekman08
stekman08 force-pushed the fix/statusline-run-bound branch 2 times, most recently from 93b08b7 to 36c78c7 Compare July 30, 2026 11:04
@AltanS
AltanS merged commit 96df14f into AltanS:main Jul 30, 2026
1 check passed
AltanS added a commit that referenced this pull request Jul 30, 2026
A statusline separates its fields by colour before anyone reads them — the
context meter, the model, the branch. extractStatusLines flattened the
styled lines to text one call before the strip that renders them, so the
strip was uniformly muted grey.

Rows now come back as StyledLine, and the strip renders segment spans in
the MIRROR's colour space rather than as app chrome: terminal colour is
dark-space colour, so a bright statusline colour re-themed onto a light
background is the illegibility ADR 0002 exists to prevent. The space and
its invert rule move to mirror-space.ts, since two surfaces now share them
and a second spelling would drift.

Refs #56

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AltanS added a commit that referenced this pull request Jul 30, 2026
…lour

Follow-up to #56: the bound was raised, but `extractStatusLine` still returned only the run's FIRST
row, so model, cwd, git branch and permission mode were stripped off the mirror and rendered nowhere.
Verified against a real 3-row host — only `CTX:… LIMITS …` was ever visible.

`extractStatusLine(lines): string | null` → `extractStatusLines(lines): StyledLine[]`. Styled, not
flattened: a statusline tells its fields apart by colour before anyone reads them, and the old
signature threw that away one call before the surface that renders it. The strip draws the segments
in the mirror's colour space and inverts with it in light theme (ADR 0002) — terminal colour is
dark-space colour. MIRROR_SPACE/MIRROR_INVERT/styleFor now live in mirror-space.ts, shared by the two
surfaces that carry terminal segments rather than spelled twice.

`InputBox.statusEnd` bounds the run from the walk that already knows where it stops, so the
background-agents footer still cannot leak in; `stripChrome` is untouched.

Stacked one truncated row per line: joining would put ~150 chars into a strip that fits ~55 on a
phone, truncating away exactly the fields this exists to surface. Hint rows are kept — filtering them
needs the content-matching this module refuses, and that row carries the permission mode.

web: 92 files / 1317 tests. Typecheck clean both sides.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants