Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions cmd/moat/cli/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -481,10 +481,19 @@ func RunInteractiveAttached(ctx context.Context, manager *run.Manager, r *run.Ru
attachCtx, attachCancel := context.WithCancel(ctx)
defer attachCancel()

// Reserve one terminal row for the status bar so the child process is
// told the PTY is one row shorter than the host terminal. Without this,
// the child draws its own bottom-pinned UI on the same row as moat's
// footer and the two interleave on every redraw.
var reservedRows uint
if statusWriter != nil {
reservedRows = 1
}

// Start with attachment - this ensures TTY is connected before process starts
attachDone := make(chan error, 1)
go func() {
attachDone <- manager.StartAttached(attachCtx, r.ID, stdin, stdout, os.Stderr)
attachDone <- manager.StartAttached(attachCtx, r.ID, stdin, stdout, os.Stderr, reservedRows)
}()

// Give container a moment to start, then resize TTY to match terminal.
Expand All @@ -497,8 +506,12 @@ func RunInteractiveAttached(ctx context.Context, manager *run.Manager, r *run.Ru
if term.IsTerminal(os.Stdout) {
width, height := term.GetSize(os.Stdout)
if width > 0 && height > 0 {
// #nosec G115 -- width/height are validated positive above
if err := manager.ResizeTTY(ctx, r.ID, uint(height), uint(width)); err != nil {
childHeight := uint(height) // #nosec G115 -- validated positive above
if childHeight > reservedRows {
childHeight -= reservedRows
}
// #nosec G115 -- width is validated positive above
if err := manager.ResizeTTY(ctx, r.ID, childHeight, uint(width)); err != nil {
log.Debug("failed to resize TTY", "error", err)
}
}
Expand All @@ -519,9 +532,14 @@ func RunInteractiveAttached(ctx context.Context, manager *run.Manager, r *run.Ru
}
ringRecorder.AddResize(width, height)
_ = statusWriter.Resize(width, height)
// Also resize container TTY
// #nosec G115 -- width/height are validated positive above
_ = manager.ResizeTTY(ctx, r.ID, uint(height), uint(width))
// Resize the container TTY to the host height minus
// the rows reserved for moat's status bar.
childHeight := uint(height) // #nosec G115 -- validated positive above
if childHeight > reservedRows {
childHeight -= reservedRows
}
// #nosec G115 -- width is validated positive above
_ = manager.ResizeTTY(ctx, r.ID, childHeight, uint(width))
}
}
continue // Don't break out of loop
Expand Down
14 changes: 14 additions & 0 deletions internal/deps/scripts/moat-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,20 @@ run_pre_run_hook() {
# If we're root and moatuser exists, drop privileges with gosu.
# If moatuser doesn't exist, fail - running as root defeats the security model.
run_pre_run_hook

# Clear the terminal so the user's command starts on a fresh screen.
# Without this, output from pre_run hooks (e.g. "pnpm install") and from
# moat-init's own setup steps remains on screen. TUIs that paint with
# relative cursor advances (\x1b[NC) instead of overwriting cells — Claude
# Code's startup banner among them — leave those characters bleeding
# through their layout. Clearing once at the boundary between init and the
# user's command avoids that. moat's CLI redraws its footer on the next
# debounce tick, so the brief disappearance is invisible in practice.
# `set -e` aborts above on hook failure, so a clear here only runs on success.
if [ -t 1 ]; then
printf '\033[2J\033[H'
fi

if [ "$(id -u)" != "0" ]; then
# Already non-root (e.g., --user was passed to docker run)
exec "$@"
Expand Down
2 changes: 1 addition & 1 deletion internal/e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1807,7 +1807,7 @@ func TestInteractiveContainer(t *testing.T) {

// Run StartAttached - this should send input to cat and get it echoed back
// Note: cat exits when stdin reaches EOF, so this will complete
err = mgr.StartAttached(ctx, r.ID, stdinReader, &stdoutBuf, &stdoutBuf)
err = mgr.StartAttached(ctx, r.ID, stdinReader, &stdoutBuf, &stdoutBuf, 0)
if err != nil {
t.Fatalf("StartAttached: %v", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/e2e/logs_capture_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ func TestLogsCapturedInInteractiveMode(t *testing.T) {
// verify that StartAttached captures logs when the container exits
doneCh := make(chan error, 1)
go func() {
doneCh <- mgr.StartAttached(ctx, r.ID, os.Stdin, os.Stdout, os.Stderr)
doneCh <- mgr.StartAttached(ctx, r.ID, os.Stdin, os.Stdout, os.Stderr, 0)
}()

// Wait for completion
Expand Down
6 changes: 3 additions & 3 deletions internal/e2e/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func TestAppleTUIWriterPassthrough(t *testing.T) {
defer writer.Cleanup()

// Route container output through the tui.Writer
err = mgr.StartAttached(ctx, r.ID, strings.NewReader(""), writer, &bytes.Buffer{})
err = mgr.StartAttached(ctx, r.ID, strings.NewReader(""), writer, &bytes.Buffer{}, 0)
if err != nil {
t.Fatalf("StartAttached: %v", err)
}
Expand Down Expand Up @@ -127,7 +127,7 @@ func TestAppleTUIWriterAltScreenDuringInit(t *testing.T) {
_ = writer.Setup()
defer writer.Cleanup()

err = mgr.StartAttached(ctx, r.ID, strings.NewReader(""), writer, &bytes.Buffer{})
err = mgr.StartAttached(ctx, r.ID, strings.NewReader(""), writer, &bytes.Buffer{}, 0)
if err != nil {
t.Fatalf("StartAttached: %v", err)
}
Expand Down Expand Up @@ -189,7 +189,7 @@ func TestAppleTUIWriterMultipleWrites(t *testing.T) {
_ = writer.Setup()
defer writer.Cleanup()

err = mgr.StartAttached(ctx, r.ID, strings.NewReader(""), writer, &bytes.Buffer{})
err = mgr.StartAttached(ctx, r.ID, strings.NewReader(""), writer, &bytes.Buffer{}, 0)
if err != nil {
t.Fatalf("StartAttached: %v", err)
}
Expand Down
20 changes: 18 additions & 2 deletions internal/run/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -3073,7 +3073,14 @@ func (m *Manager) Start(ctx context.Context, runID string, opts StartOptions) er
// This is required for TUI applications (like Codex CLI) that need the terminal
// connected before the process starts to properly detect terminal capabilities.
// Unlike Start + Attach, this ensures the TTY is ready when the container command begins.
func (m *Manager) StartAttached(ctx context.Context, runID string, stdin io.Reader, stdout, stderr io.Writer) error {
// StartAttached attaches stdio to the run's container and blocks until the
// process exits.
//
// reservedRows tells the manager how many rows of the host terminal are
// reserved by the CLI (e.g., for a status bar) and must NOT be advertised
// to the child process. The auto-detected initial PTY height is reduced
// by this amount; pass 0 when no rows are reserved.
func (m *Manager) StartAttached(ctx context.Context, runID string, stdin io.Reader, stdout, stderr io.Writer, reservedRows uint) error {
m.mu.Lock()
r, ok := m.runs[runID]
if !ok {
Expand Down Expand Up @@ -3120,12 +3127,21 @@ func (m *Manager) StartAttached(ctx context.Context, runID string, stdin io.Read

// Pass initial terminal size so the container can be resized immediately
// after starting, before the process queries terminal dimensions.
//
// reservedRows is subtracted so the child sees only the rows it can
// actually paint into. Otherwise the child draws its own bottom-pinned
// UI (e.g., Claude Code's input prompt and status lines) at the same
// row as moat's status bar, producing character-interleaved artifacts.
if useTTY && term.IsTerminal(os.Stdout) {
width, height := term.GetSize(os.Stdout)
if width > 0 && height > 0 {
// #nosec G115 -- width/height are validated positive above
attachOpts.InitialWidth = uint(width)
attachOpts.InitialHeight = uint(height)
usableHeight := uint(height)
if usableHeight > reservedRows {
usableHeight -= reservedRows
}
attachOpts.InitialHeight = usableHeight
}
}

Expand Down
89 changes: 84 additions & 5 deletions internal/tui/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,25 @@ var altScreenExit = [][]byte{
[]byte("\x1b[?1047l"),
}

// decstbmReset is the DECSTBM "reset scroll region to full screen" escape
// sequence. Some node-based TUIs (Claude Code among them) emit this once
// during startup as part of TTY normalization. When that happens, moat's
// scroll region — which reserves the bottom row for the footer — is wiped
// out, and any subsequent footer redraw at row H becomes regular text that
// scrolls up with content. We detect this sequence in the data stream, let
// it pass through, then immediately reassert moat's scroll region. The
// trace evidence is one reset at startup and one at exit per session, so
// this is a one-shot restore — not an ongoing fight with the child.
var decstbmReset = []byte("\x1b[r")

// eraseScreen is the "erase entire display" sequence. moat-init.sh emits
// this between pre_run hooks and the user's command so the agent starts
// on a clean screen. After it lands, moat's footer at row H is gone too,
// and the 50ms debounce isn't reliable during a busy agent startup. We
// detect the sequence, pass it through, then immediately redraw the
// footer so it doesn't disappear for the duration of the splash sequence.
var eraseScreen = []byte("\x1b[2J")

// renderInterval is the compositor render tick rate (~60fps).
const renderInterval = 16 * time.Millisecond

Expand Down Expand Up @@ -241,8 +260,39 @@ func (w *Writer) processDataLocked(data []byte) error {
continue
}

// Detect DECSTBM "reset scroll region" — pass it through, then
// immediately reassert moat's scroll region so the footer keeps a
// home. Only meaningful in scroll mode; in compositor mode the
// emulator owns its own scroll state.
if bytes.HasPrefix(data, decstbmReset) {
if err := w.outputLocked(data[:len(decstbmReset)]); err != nil {
return err
}
data = data[len(decstbmReset):]
if !w.altScreen && w.height > 1 {
if err := w.reassertScrollRegionLocked(); err != nil {
return err
}
}
continue
}

// Detect "erase entire screen". Pass it through, then redraw the
// footer immediately — the 50ms debounce isn't reliable during the
// agent's startup splash. Compositor mode owns its own surface.
if bytes.HasPrefix(data, eraseScreen) {
if err := w.outputLocked(data[:len(eraseScreen)]); err != nil {
return err
}
data = data[len(eraseScreen):]
if !w.altScreen && w.height > 1 {
w.redrawFooterLocked()
}
continue
}

// Check if this could be a partial match at the end of the buffer
if w.isPrefixOfAltScreen(data) && len(data) < maxAltScreenSeqLen() {
if w.isPrefixOfKnownSequence(data) && len(data) < maxKnownSeqLen() {
// Buffer it for the next Write call
w.escBuf = append(w.escBuf[:0], data...)
return nil
Expand Down Expand Up @@ -291,8 +341,10 @@ func (w *Writer) matchAltScreen(data []byte) (matched bool, enter bool, length i
return false, false, 0
}

// isPrefixOfAltScreen returns true if data is a prefix of any alt screen sequence.
func (w *Writer) isPrefixOfAltScreen(data []byte) bool {
// isPrefixOfKnownSequence returns true if data is a prefix of any sequence
// the writer recognizes (alt screen enter/exit or DECSTBM reset). Used to
// defer processing of sequences that may be split across Write calls.
func (w *Writer) isPrefixOfKnownSequence(data []byte) bool {
for _, seq := range altScreenEnter {
if len(data) < len(seq) && bytes.HasPrefix(seq, data) {
return true
Expand All @@ -303,11 +355,17 @@ func (w *Writer) isPrefixOfAltScreen(data []byte) bool {
return true
}
}
if len(data) < len(decstbmReset) && bytes.HasPrefix(decstbmReset, data) {
return true
}
if len(data) < len(eraseScreen) && bytes.HasPrefix(eraseScreen, data) {
return true
}
return false
}

// maxAltScreenSeqLen returns the length of the longest alt screen sequence.
func maxAltScreenSeqLen() int {
// maxKnownSeqLen returns the length of the longest sequence the writer recognizes.
func maxKnownSeqLen() int {
max := 0
for _, seq := range altScreenEnter {
if len(seq) > max {
Expand All @@ -319,9 +377,30 @@ func maxAltScreenSeqLen() int {
max = len(seq)
}
}
if len(decstbmReset) > max {
max = len(decstbmReset)
}
if len(eraseScreen) > max {
max = len(eraseScreen)
}
return max
}

// reassertScrollRegionLocked re-establishes moat's DECSTBM scroll region
// without disturbing the cursor. Used after the child process resets the
// scroll region (e.g., via `\x1b[r` during TTY normalization). The save
// and restore are needed because setting DECSTBM moves the cursor to the
// home position by default.
// Caller must hold the mutex.
func (w *Writer) reassertScrollRegionLocked() error {
var buf bytes.Buffer
buf.WriteString("\x1b7") // DECSC: save cursor + attrs
fmt.Fprintf(&buf, "\x1b[1;%dr", w.height-1)
buf.WriteString("\x1b8") // DECRC: restore cursor + attrs
_, err := w.out.Write(buf.Bytes())
return err
}

// enterCompositorLocked switches from scroll mode to compositor mode.
func (w *Writer) enterCompositorLocked() error {
if w.altScreen {
Expand Down
Loading
Loading