fix(web): let the statusline run be as tall as a real statusline - #56
Merged
Conversation
`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
force-pushed
the
fix/statusline-run-bound
branch
2 times, most recently
from
July 30, 2026 11:04
93b08b7 to
36c78c7
Compare
This was referenced Jul 30, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
One constant:
MAX_STATUS_LINES: 3 → 8. A statusline taller than 3 rows madelocateInputBoxmiss 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 inCLAUDE.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.
locateInputBoxaccepts 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 whateverstatusLine.commandprints. 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,extractInputDraftreturnsnull, andsendGuardedReplystalls.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 underMAX_FOOTER_LINES = 8when 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.mdin 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 inCLAUDE.mdper.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<=3tor<=8(zero instances in 47 real captures, precondition absent from every real render, pinned as a known limitation in the tests); the composed ceiling withMAX_FOOTER_LINESrises 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.tsfails two tests on macOS: the fixture builds its base fromtmpdir(), which lives under/var/folders, andcontainedRealpathresolves/var→/private/varby 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, includingrefuses a symlink inside the root that points outside it; 18 tests before and after, noskip/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/scrollgeometry intoweb/would let box liveness be established rather than inferred from shape, which closes the echo class properly and lets the bound go entirely; andcomposer.tsx:299-316fires its pre-clear sweep beforesendGuardedReply, gated only on the memoizeddialogPresentthatagent-chat.tsx:192-194itself 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.