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.
@joonas
Copy link
Copy Markdown
Contributor

joonas commented May 14, 2026

I've noticed that Claude Code's rendering tends to get all sorts of messed up inside of moat, and I'm wondering if this might address that problem.

This is probably entirely unrelated, but I thought to give the CLAUDE_CODE_NO_FLICKER mode a shot, and that makes the terminal entirely unusable.

In any case, I'd love to see if this PR might fix my issue - is there anything help I could offer to help bring this over the line @abezzub-dr @dpup?

@dpup
Copy link
Copy Markdown
Collaborator

dpup commented May 14, 2026

Did you try after #349? In a previous commit I added a TUI trace to the Ctrl-/ menu and then used the trace to debug potential instability.

The problem for me hasn't been consistent. It almost seems like a per-terminal problem.

@joonas
Copy link
Copy Markdown
Contributor

joonas commented May 14, 2026

@dpup I have not, going to build locally and give that a whirl!

@joonas
Copy link
Copy Markdown
Contributor

joonas commented May 14, 2026

@dpup my early testing would seem to indicate that the changes from #349 have addressed the issue for me, though this is based on somewhat limited hands-on time with this so far - happy to keep testing and report back if the issue re-surfaces.

As an aside, I should've mentioned that I'm personally using moat with Ghostty as the terminal.

@dpup
Copy link
Copy Markdown
Collaborator

dpup commented May 14, 2026

Nice. I'm using cmux which is based on ghostty

@dpup
Copy link
Copy Markdown
Collaborator

dpup commented May 14, 2026

If it happens again try to grab a TUI trace. It might have sensitive data, depending on your project so you could also ask Claude to investigate.

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.

3 participants