Skip to content
Merged
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
23 changes: 19 additions & 4 deletions cmd/moat/cli/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ func executeRunWrapper(ctx context.Context, opts intcli.ExecOptions) (*intcli.Ex
}, nil
}

// containerTTYHeight returns the height to report to the container, reserving
// the bottom row for the status bar when one is active. Keeping the child's
// view of the terminal one row shorter prevents it from drawing on (or
// scrolling content over) the footer line. Paired with DECSTBM ownership in
// tui.Writer to fully isolate the child from moat's chrome.
func containerTTYHeight(statusWriter *tui.Writer, actual int) int {
if statusWriter != nil && actual > 1 {
return actual - 1
}
return actual
}

// setupStatusBar creates a status bar for interactive container sessions.
// Returns the writer (which wraps stdout with status bar compositing), a cleanup
// function that must be deferred, and the output writer to use for container output.
Expand Down Expand Up @@ -497,8 +509,9 @@ 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 {
h := containerTTYHeight(statusWriter, height)
// #nosec G115 -- width/height are validated positive above
if err := manager.ResizeTTY(ctx, r.ID, uint(height), uint(width)); err != nil {
if err := manager.ResizeTTY(ctx, r.ID, uint(h), uint(width)); err != nil {
log.Debug("failed to resize TTY", "error", err)
}
}
Expand All @@ -519,9 +532,10 @@ func RunInteractiveAttached(ctx context.Context, manager *run.Manager, r *run.Ru
}
ringRecorder.AddResize(width, height)
_ = statusWriter.Resize(width, height)
// Also resize container TTY
// Also resize container TTY, reserving the footer row.
h := containerTTYHeight(statusWriter, height)
// #nosec G115 -- width/height are validated positive above
_ = manager.ResizeTTY(ctx, r.ID, uint(height), uint(width))
_ = manager.ResizeTTY(ctx, r.ID, uint(h), uint(width))
}
}
continue // Don't break out of loop
Expand Down Expand Up @@ -659,8 +673,9 @@ func resetTUI(ctx context.Context, manager *run.Manager, r *run.Run, statusWrite

if term.IsTerminal(os.Stdout) {
if width, height := term.GetSize(os.Stdout); width > 0 && height > 0 {
h := containerTTYHeight(statusWriter, height)
// #nosec G115 -- width/height validated positive
if err := manager.ResizeTTY(ctx, r.ID, uint(height), uint(width)); err != nil {
if err := manager.ResizeTTY(ctx, r.ID, uint(h), uint(width)); err != nil {
log.Debug("post-reset resize nudge failed", "error", err)
}
}
Expand Down
19 changes: 18 additions & 1 deletion internal/run/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -3120,11 +3120,28 @@ 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.
//
// In interactive mode the CLI reserves the bottom row for a status bar
// (see internal/tui.Writer). Subtract 1 from the reported height so the
// child renders in rows 1..height-1 and can't collide with the footer
// slot. Subsequent ResizeTTY calls from the CLI use the same adjustment.
//
// Predicate note: this site checks r.Interactive while the CLI's
// containerTTYHeight helper checks statusWriter != nil. They're
// equivalent today because both are gated by term.IsTerminal(os.Stdout)
// and exec.go only constructs a statusWriter when r.Interactive is true.
// If a future caller invokes StartAttached for an Interactive run in a
// non-TTY context, this branch is unreached (the outer term.IsTerminal
// guard fails first), so the predicates stay consistent.
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
if r.Interactive && height > 1 {
height--
}
// #nosec G115 -- width is validated positive above
attachOpts.InitialWidth = uint(width)
// #nosec G115 -- height is validated positive above (and only decremented when > 1)
attachOpts.InitialHeight = uint(height)
}
}
Expand Down
200 changes: 199 additions & 1 deletion internal/tui/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,13 +241,44 @@ func (w *Writer) processDataLocked(data []byte) error {
continue
}

// Check if this could be a partial match at the end of the buffer
// Check if this could be a partial match at the end of the buffer.
// This runs before the DECSTBM/DECSTR matcher below; both paths
// share w.escBuf, but the priority ordering is safe: a 2-byte
// ESC[ prefix matches alt-screen first and gets buffered, then on
// the next Write the combined buffer is re-classified by both
// matchers in turn — if it turns out to be DECSTBM, the second
// pass routes it correctly.
if w.isPrefixOfAltScreen(data) && len(data) < maxAltScreenSeqLen() {
// Buffer it for the next Write call
w.escBuf = append(w.escBuf[:0], data...)
return nil
}

// In scroll mode, intercept terminal-state escapes that would
// clobber moat's scroll region (DECSTBM, DECSTR, RIS). The
// emulator owns its own scroll region in compositor mode, so we
// don't intercept there.
if !w.altScreen {
res := matchControlSeq(data)
if res.needsMore && len(data) <= maxControlSeqBufLen {
w.escBuf = append(w.escBuf[:0], data...)
return nil
}
if res.kind != ctrlNone {
if err := w.handleControlSeqLocked(res, data[:res.length]); err != nil {
return err
}
data = data[res.length:]
continue
}
// If needsMore but data exceeded maxControlSeqBufLen, we fall
// through and emit the ESC byte. The remaining bytes pass to
// the terminal in order, which reassembles the original
// CSI — interception silently fails, but real DECSTBMs are
// well under 10 bytes, so this only fires for pathological
// input. Memory bound > correctness coverage here.
}

// Not an alt screen sequence - output the ESC and continue
if err := w.outputLocked(data[:1]); err != nil {
return err
Expand All @@ -257,6 +288,173 @@ func (w *Writer) processDataLocked(data []byte) error {
return nil
}

// maxControlSeqBufLen bounds how much of a partial DECSTBM/DECSTR sequence
// we'll buffer before giving up and passing the bytes through. Realistic
// DECSTBMs are 3–8 bytes; the generous cap leaves room for the pathological
// case of many-paramed sequences split across Write boundaries while
// preventing a malformed never-terminating sequence from pinning memory.
const maxControlSeqBufLen = 256

// Assumes 7-bit ANSI input (ESC [, not the 8-bit C1 byte 0x9B). All real
// children writing through this Writer emit UTF-8, where 0x9B can only
// appear as a continuation byte. If we ever support a non-UTF-8 child
// encoding, matchControlSeq will need to grow a C1 branch.

// controlSeqKind tags terminal-state sequences that affect the scroll region.
// In scroll mode, moat owns the scroll region; the child can't be allowed to
// change it directly.
type controlSeqKind int

const (
ctrlNone controlSeqKind = iota
ctrlDECSTBM // CSI Pt;Pb r — set scroll region. Swallow and re-emit moat's region.
ctrlDECSTR // CSI ! p — soft terminal reset; clears DECSTBM as a side effect. Pass through, then re-emit.
ctrlRIS // ESC c — hard reset; clears screen and DECSTBM. Pass through, then re-establish layout.
)

// controlSeqResult is the outcome of matchControlSeq.
type controlSeqResult struct {
kind controlSeqKind
length int // bytes consumed; 0 when no match or partial
needsMore bool // true if data is a viable prefix of DECSTBM/DECSTR and more data may complete it
}

// matchControlSeq checks whether data starts with a DECSTBM, DECSTR, or RIS
// sequence. Returns needsMore=true if the buffer holds a prefix that could
// still resolve into one of these; caller should buffer and retry.
//
// CSI sequences that share the same final byte but have different syntax
// (e.g. CSI ? 2026 r, a DEC private mode restore) are explicitly NOT matched
// — they pass through to the terminal unmodified.
func matchControlSeq(data []byte) controlSeqResult {
if len(data) == 0 || data[0] != 0x1b {
return controlSeqResult{}
}
if len(data) == 1 {
// Bare ESC at end of buffer — anything could follow.
return controlSeqResult{needsMore: true}
}
// RIS: ESC c
if data[1] == 'c' {
return controlSeqResult{kind: ctrlRIS, length: 2}
}
// Everything else we care about starts with CSI (ESC [).
if data[1] != '[' {
return controlSeqResult{}
}

i := 2
paramStart := i
onlyDigitsAndSemi := true
for i < len(data) && data[i] >= 0x30 && data[i] <= 0x3F {
b := data[i]
if !((b >= '0' && b <= '9') || b == ';') {
onlyDigitsAndSemi = false
}
i++
}
paramLen := i - paramStart

intStart := i
var firstIntermediate byte
for i < len(data) && data[i] >= 0x20 && data[i] <= 0x2F {
if i == intStart {
firstIntermediate = data[i]
}
i++
}
intLen := i - intStart

if i >= len(data) {
// Incomplete CSI. Could it still be DECSTBM or DECSTR?
if intLen == 0 && onlyDigitsAndSemi {
return controlSeqResult{needsMore: true} // could be DECSTBM
}
if paramLen == 0 && intLen == 1 && firstIntermediate == '!' {
return controlSeqResult{needsMore: true} // could be DECSTR
}
return controlSeqResult{}
}

final := data[i]
if final < 0x40 || final > 0x7E {
// Not a valid CSI final byte — let the original parser handle it.
return controlSeqResult{}
}
length := i + 1

// DECSTBM: digit/semi params (or none), no intermediates, final 'r'.
if final == 'r' && intLen == 0 && onlyDigitsAndSemi {
return controlSeqResult{kind: ctrlDECSTBM, length: length}
}
// DECSTR: no params, single '!' intermediate, final 'p'.
if final == 'p' && paramLen == 0 && intLen == 1 && firstIntermediate == '!' {
return controlSeqResult{kind: ctrlDECSTR, length: length}
}
return controlSeqResult{}
}

// handleControlSeqLocked applies the policy for a matched DECSTBM/DECSTR/RIS:
// - DECSTBM (any args from the child) is swallowed; moat's own scroll
// region command is emitted in its place.
// - DECSTR passes through (other resets may be intended), and moat's
// DECSTBM is re-asserted right after so the footer slot stays reserved.
// The pair is wrapped in DECSC/DECRC so the cursor — which DECSTR
// preserves but DECSTBM moves — is restored.
// - RIS passes through (it clears the screen and homes the cursor), then
// moat re-establishes its scroll region and footer and returns the
// cursor to home so the child can resume drawing.
//
// Caller passes raw bytes of the matched sequence so RIS/DECSTR can be
// forwarded verbatim.
func (w *Writer) handleControlSeqLocked(res controlSeqResult, raw []byte) error {
switch res.kind {
case ctrlDECSTBM:
return w.outputLocked(w.scrollRegionBytes())
case ctrlDECSTR:
// Deviation: DECSTR resets the DECSC slot to home (1,1). Our
// DECSC here overwrites that with the live cursor instead. No
// known TUI relies on DECSTR's saved-cursor reset; we accept the
// trade to keep the visible cursor stable across the re-emit.
//
// We intentionally do NOT redraw the footer here. DECSTR
// preserves on-screen content (only modes/state are reset), so
// the existing footer pixels remain. The debounced redraw fires
// shortly after this Write returns and repairs the row in case
// the child writes content immediately afterward. Inline redraw
// would risk clobbering the child's mid-frame rendering.
var buf bytes.Buffer
buf.Write(raw)
buf.WriteString("\x1b7") // save cursor (DECSTR preserves it)
buf.Write(w.scrollRegionBytes())
buf.WriteString("\x1b8") // restore cursor (DECSTBM moves it)
return w.outputLocked(buf.Bytes())
case ctrlRIS:
// Unlike DECSTR, RIS clears the screen — the footer pixels are
// gone. Redraw it inline rather than waiting for the debounce so
// the row isn't visibly blank in the gap.
var buf bytes.Buffer
buf.Write(raw)
buf.Write(w.scrollRegionBytes())
fmt.Fprintf(&buf, "\x1b[%d;1H\x1b[2K", w.height)
buf.WriteString(w.bar.Render())
// Return cursor to home so child resumes drawing where RIS left it.
buf.WriteString("\x1b[H")
return w.outputLocked(buf.Bytes())
}
// Unreachable: callers gate this on res.kind != ctrlNone.
return nil
}

// scrollRegionBytes returns the DECSTBM command that pins moat's footer at
// the bottom row. Empty when the terminal is too short to have a region.
func (w *Writer) scrollRegionBytes() []byte {
if w.height <= 1 {
return nil
}
return []byte(fmt.Sprintf("\x1b[1;%dr", w.height-1))
}

// outputLocked sends data to either the real terminal (scroll mode) or the
// VT emulator (compositor mode).
func (w *Writer) outputLocked(data []byte) error {
Expand Down
Loading
Loading