Skip to content

fix(tui): repair footer artifacts during agent startup and runtime#346

Open
abezzub-dr wants to merge 4 commits into
majorcontext:mainfrom
abezzub-dr:fix/tui-pty-reserve-footer-row
Open

fix(tui): repair footer artifacts during agent startup and runtime#346
abezzub-dr wants to merge 4 commits into
majorcontext:mainfrom
abezzub-dr:fix/tui-pty-reserve-footer-row

Conversation

@abezzub-dr
Copy link
Copy Markdown
Contributor

@abezzub-dr abezzub-dr commented Apr 28, 2026

Summary

Fix three independent bugs that caused moat's footer to bleed into the agent's output. The visible symptoms were character-interleaved text in Claude's content area, the moat footer appearing as scattered standalone lines mid-conversation, and pnpm-install output bleeding through Claude's startup banner.

The bugs were diagnosed from a TTY trace (moat claude --tty-trace) rather than guessed at — happy to share the trace if useful for review.

Three fixes, each addressing a distinct cause

1. Reserve the status bar row in the PTY size advertised to the child (ab5fa6f)

Manager.StartAttached and exec.go's ResizeTTY calls passed the full host terminal height to the child. moat's DECSTBM scroll region was correctly set to lines 1..H-1, but the child still saw all H rows and drew its bottom-pinned UI at row H — colliding with moat's footer. Recent Claude Code versions paint a multi-line bottom UI (input prompt, model/workspace status, permissions hint), which made every repaint clobber the footer; both processes then redrew on top of each other, producing the character-interleaved artifacts.

StartAttached gains a reservedRows uint parameter. The manager subtracts it from the auto-detected InitialHeight. The CLI passes 1 when a status bar is in use and subtracts 1 from the height in both ResizeTTY calls (initial post-start and SIGWINCH).

2. Reassert the scroll region after the child resets DECSTBM (ad972c5)

Some node-based TUIs (Claude Code among them) emit \x1b[r once during TTY normalization to reset the scroll region to full screen. After that, moat's region is gone — any subsequent footer redraw at row H becomes regular text that scrolls up with content. The visible symptom is moat's footer text appearing as scattered standalone lines inside the agent's content area.

The writer detects \x1b[r in the byte stream, lets it pass through (some terminals normalize state on it), then immediately reasserts moat's scroll region wrapped in DECSC/DECRC so the cursor isn't disturbed. Skipped in compositor mode since the emulator owns its own state.

3. Clear the screen between pre_run hooks and the user's command (c51e191)

Pre-run hooks (e.g. pnpm install) and moat-init's own setup steps print to the same TTY as the user's command. TUIs that paint with relative cursor advances (\x1b[NC) instead of overwriting cells leave any prior characters bleeding through their layout — Claude Code's startup banner is a striking example, with pnpm install lines visible inside the logo glyphs.

moat-init.sh now emits \x1b[2J\x1b[H after the pre_run hook, gated on a TTY (and on hook success — set -e aborts above on hook failure, so errors stay visible). The writer detects \x1b[2J in the data stream and redraws the footer immediately, since the 50ms debounce isn't reliable during a busy startup.

Test plan

  • go test -race ./internal/tui/ passes (28 existing + 8 new tests, including DECSTBM reassert, erase-screen redraw, split-across-writes prefix detection, and compositor-mode skips)
  • go test -race ./internal/run/ ./cmd/moat/cli/ passes
  • golangci-lint run --new-from-rev=main reports 0 new issues
  • Manually verified with moat claude pricing-agent against a TTY trace: no character-interleaving in content, no scattered footer lines, banner renders on a clean screen
  • Run e2e: go test -tags=e2e ./internal/e2e/ (5 e2e callers updated to pass 0 reservedRows)
  • Reviewer to verify on a fresh container build (the moat-init.sh change requires a rebuild — --rebuild flag — since the script is embedded into the image at build time)

Moat tells the child process the terminal is one row shorter than the
host so the child doesn't paint its own bottom-pinned UI on the same
row as moat's footer.

Previously, manager.StartAttached and exec.go's ResizeTTY calls passed
the full host terminal height. The status bar's DECSTBM scroll region
was correctly set to lines 1..height-1, but the child still saw all
height rows and drew its bottom UI at row height — colliding with the
footer. Recent Claude Code versions paint a multi-line bottom UI
(input prompt, model/workspace status, permissions hint), and every
repaint clobbered moat's footer. Both processes then redrew on top of
each other, producing character-interleaved artifacts in the content
area.

Add a reservedRows parameter to Manager.StartAttached. The manager
subtracts it from the auto-detected InitialHeight. The CLI passes 1
when a status bar is present and subtracts 1 from the height in both
ResizeTTY calls (initial post-start and SIGWINCH).
Some node-based TUIs (Claude Code among them) emit `\x1b[r` once during
TTY normalization to reset the DECSTBM scroll region to full screen.
That wipes out moat's scroll region — the bottom row is no longer
reserved for the footer, and any subsequent footer redraw at row H
becomes regular text that scrolls up with content. The visible
symptom is moat's footer text appearing as scattered standalone lines
inside Claude's content area.

Detect `\x1b[r` in the byte stream, let it pass through to the terminal
(the child may have meaningful side effects on it), then immediately
reassert moat's scroll region wrapped in DECSC/DECRC so the cursor is
not disturbed. Skip the reassert in compositor mode since the emulator
owns its own screen state.

Diagnosed from a TTY trace showing exactly one `\x1b[r` near startup
and one at exit per session — this is a one-shot restore, not an
ongoing fight with the child.
Pre-run hooks (e.g. `pnpm install`) and moat-init's own setup steps print
to the same TTY as the user's command. TUIs that paint with relative
cursor advances (\x1b[NC) instead of overwriting cells leave any prior
characters bleeding through their layout — Claude Code's startup banner
is a striking example, with pnpm install lines visible inside the logo
glyphs.

moat-init.sh now emits ESC[2J ESC[H after the pre_run hook (gated on a
TTY and a successful hook — `set -e` aborts above on hook failure, so
errors stay visible). The writer detects ESC[2J in the data stream and
redraws the footer immediately, since the 50ms debounce isn't reliable
during a busy agent startup.
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.

1 participant