fix(tui): keep footer pinned when child resets scroll region#349
Conversation
Children like Claude Code (via Ink) defensively emit ESC[r at startup
to reset DECSTBM. That undid moat's scroll region, so every newline at
the bottom row scrolled the footer into scrollback and the 50ms
debounced redraw stamped a fresh copy at the bottom — producing a trail
of footers in the user's scrollback and intermittent overlap with the
child's input area.
Make moat the sole authority over the scroll region:
- In scroll mode, intercept DECSTBM (ESC[Pt;Pb r) from the child;
swallow it and re-emit ESC[1;height-1 r in its place.
- DECSTR (ESC[!p) passes through with our DECSTBM re-asserted after,
wrapped in DECSC/DECRC so the cursor stays put.
- RIS (ESC c) passes through with scroll region + footer
re-established and cursor returned to home.
- DEC private-mode restores (ESC[?Pn r) and other look-alikes are
explicitly not matched.
- In compositor mode, DECSTBM is passed to the emulator unchanged.
Belt-and-suspenders: report height-1 to the container in interactive
mode so the child's terminal model can't reach the footer row even if
some new sequence escapes interception.
📦 Previous Review (superseded by newer review)Claude finished @dpup's task in 6m 32s —— View job Code Review
OverallThe approach is correct and well-reasoned. Owning the scroll region from moat's side (both intercepting escapes and shrinking the child's TTY view) is the right way to solve this permanently rather than patching up after the fact. Test coverage is strong for a terminal-escape-parsing change, especially the Ink regression test and the split-write case. A few findings below, ordered by severity.
|
- Document the oversized-partial-sequence fallthrough in processDataLocked (real DECSTBMs are <10 bytes; bound-exceeded case passes through). - Explain the alt-screen / DECSTBM priority ordering in processDataLocked. - Note in handleControlSeqLocked why DECSTR relies on the debounced footer redraw while RIS redraws inline (RIS clears the screen, DECSTR doesn't). - Mark the trailing return in handleControlSeqLocked unreachable. - Cross-reference manager.go's r.Interactive predicate with exec.go's statusWriter check. - TestWriter_InkStartupSequence_PreservesScrollRegion now verifies DECSC -> DECSTBM -> DECRC order, not just presence. - Add TestWriter_AltScreenExit_ThenDECSTBMInSameWrite covering the mode-flip path with a trailing intercept in one Write.
📦 Previous Review (superseded by newer review)Claude finished @dpup's task in 4m 28s —— View job Claude finished @dpup's task —— Code Review
Previous review findings — all addressed ✓All six points from the prior review are resolved in this iteration: the oversized-partial fallthrough is documented (lines 275–279 of New findingsMedium — DECSTR test doesn't verify cursor-preservation machinery // writer_test.go:1039
func TestWriter_DECSTR_PassesThroughWithDECSTBMRestored(t *testing.T) {
...
softIdx := strings.Index(out, "\x1b[!p")
stbmIdx := strings.Index(out, "\x1b[1;23r")
// DECSC (\x1b7) and DECRC (\x1b8) are never checkedThe DECSTR handler deliberately wraps the re-emitted DECSTBM with decsc := strings.Index(out, "\x1b7")
stbm := strings.Index(out, "\x1b[1;23r")
decrc := strings.Index(out, "\x1b8")
if !(decsc >= 0 && stbm >= 0 && decrc >= 0 && softIdx < decsc && decsc < stbm && stbm < decrc) {
t.Errorf("expected order DECSTR -> DECSC -> DECSTBM -> DECRC, ...")
}Low — bare ESC split (
Rest of the code
The
SummaryNo blocking issues. The two findings above are test-only gaps; the interception logic itself is correct. The implementation is ready to merge once the DECSTR cursor-preservation test is tightened. |
- TestWriter_DECSTR_PassesThroughWithDECSTBMRestored now asserts the full DECSTR -> DECSC -> DECSTBM -> DECRC ordering so a regression that dropped the cursor-preservation pair would be caught. - New TestWriter_InterceptsDECSTBM_SplitAfterBareESC exercises the len(data)==1 needsMore branch in matchControlSeq: a lone ESC byte arriving in one write must be buffered, then assembled with the following [24r to be intercepted.
📦 Previous Review (superseded by newer review)Claude finished @dpup's task in 4m 18s —— View job Code Review
Previous review findings — all addressed ✓All findings from the prior two review rounds are resolved: the DECSTR ordering test now verifies the full New findingsLow — Both tests assert Low — compositor mode render goroutine races with The test enters compositor mode, which starts a This is a pre-existing pattern in the compositor tests (not introduced by this PR), but worth noting for a future test-hardening pass: stopping the render loop with Rest of the code
The
SummaryNo blocking issues. The two new findings are both low-severity test observations — neither affects the correctness of the interception logic. The implementation is ready to merge. |
Switch from "doesn't contain ESC[r" + "contains ESC[1;23r" to an exact Count(...) == 1 check on the replacement. The previous form would pass if interception double-emitted or skipped the replacement; the count makes the contract explicit. Matches the pattern already used in TestWriter_InterceptsDECSTBM_MultipleInOneWrite.
|
Claude finished @dpup's task in 4m 15s —— View job Code Review
Previous review findings — all addressed ✓The latest commit ( Fresh pass — no new blocking issues
The escape intercept logic is correct across all edge cases I tested mentally:
13 new tests; all prior review findings addressed. Assertion quality is now uniform: where a single replacement is expected, One pre-existing benign observation (noted in round 3, not introduced here):
The SummaryFour rounds of review, every finding addressed cleanly. The implementation is correct and the test suite is thorough. Ready to merge. |
Summary
When Claude Code (and other Ink-based TUIs) start up, they defensively emit
ESC[rto reset the host terminal's DECSTBM scroll region. This undid moat's region, so every newline at the bottom row scrolled the footer into scrollback. The 50ms debounced footer redraw then stamped a fresh copy at the bottom — leaving a trail of footers in the user's scrollback and intermittently overlapping the child's input area. The exact sequence was captured in TUI debug traces from a glitchy session.This PR fixes the rendering by making moat the sole authority over the scroll region and reserving the footer row at the TTY level.
Scroll-mode interception (
internal/tui/writer.go)processDataLockednow scans for and replaces terminal-state escapes that would clobber moat's region:ESC[r,ESC[Pt;Pb r) — swallowed; moat re-emitsESC[1;height-1 r.ESC[!p) — passes through, then DECSC + our DECSTBM + DECRC so the visible cursor stays put.ESC c) — passes through, then region re-set, footer redrawn, cursor returned to home.ESC[?Pn r) and other CSIs that sharer/!psyntactically but not semantically are explicitly not matched.Partial sequences split across
Write()calls are buffered through the existingescBufmechanism (bounded at 256 bytes — generous headroom over realistic DECSTBM sizes).Footer-row reservation (
internal/run/manager.go,cmd/moat/cli/exec.go)When a status bar is active, report
height-1to the container's TTY:StartAttachedsubtracts 1 fromattachOpts.InitialHeightfor interactive runs.containerTTYHeight(statusWriter, h)helper used by all threeResizeTTYcall sites (startup goroutine, SIGWINCH, post-Ctrl+/ rreset nudge).This is belt-and-suspenders with the interception: the child literally cannot address the footer row, so even sequences we don't recognize can't collide with the footer slot.
Test plan
internal/tui/writer_test.gocovering: bareESC[r, parameterized DECSTBM, embedded in surrounding text, split across writes, DECSTR with cursor preservation, RIS, multiple DECSTBMs in one write, long partials buffered correctly, the exact Ink startup sequence as a named regression test, DEC private-mode restore left alone, and compositor-mode passthrough.go test ./internal/tui/ -race -count=2passesmake test-unitpassesgolangci-lint runclean for files touched (pre-existing staticcheck warnings ininternal/run/docker_test.goare unrelated)moat run claude --dangerously-skip-permissionsand confirm the footer stays at the bottom row through extended streaming output (resize, scroll-up to inspect scrollback, etc.)