package app import ( "fmt" "strconv" "strings" "sync" ) // viewportRenderer rewrites child PTY output so it lands inside the // main viewport instead of controlling patterm's full host terminal. type viewportRenderer struct { mu sync.Mutex shifter *cursorShifter layout terminalLayout row int col int state viewportState buf []byte pending strings.Builder // scrolled is set when the chunk contained an escape that shifts // content row-wise within the host's scroll region — RI / IND / // NEL / SU / SD / IL / DL. DECSTBM constrains rows but not columns, // so these scrolls drag the right-hand sidebar content with them. // OnPTYOut consumes the flag and invalidates the sidebar chrome // cache so the next drawSidebar repaints over the clobber. scrolled bool // skipUTF8 is set when the current multi-byte UTF-8 character started // past the viewport's right edge. The starter byte was dropped, so // the remaining continuation bytes must be dropped too instead of // leaking into the sidebar columns. skipUTF8 bool } type viewportState int const ( vpNormal viewportState = iota vpEsc vpCSI vpOSC vpOSCEsc vpDCS vpDCSEsc vpSOSPMAPC vpSOSPMAPCEsc ) func newViewportRenderer(l terminalLayout) *viewportRenderer { return &viewportRenderer{ shifter: newCursorShifter(int(l.mainTop)-1, int(l.childRows()), int(l.childCols())), layout: l, row: 1, col: 1, } } func (vr *viewportRenderer) SetLayout(l terminalLayout) { vr.mu.Lock() defer vr.mu.Unlock() vr.layout = l vr.shifter.SetGeometry(int(l.mainTop)-1, int(l.childRows()), int(l.childCols())) } func (vr *viewportRenderer) Render(in []byte) []byte { vr.mu.Lock() defer vr.mu.Unlock() vr.pending.Reset() for _, b := range in { vr.feed(b) } return []byte(vr.pending.String()) } func (vr *viewportRenderer) ClearViewport() []byte { vr.mu.Lock() defer vr.mu.Unlock() return []byte(vr.clearViewport()) } // TookScrollAction reports whether the most recent Render emitted (or // forwarded) a scroll-triggering escape — RI / IND / NEL / SU / SD / // IL / DL — since the previous call. The flag is reset on read. // Callers use it to invalidate sidebar-cache state, because the host's // scroll region spans the full row width and any scroll there drags // the sidebar content downward. func (vr *viewportRenderer) TookScrollAction() bool { vr.mu.Lock() defer vr.mu.Unlock() out := vr.scrolled vr.scrolled = false return out } func (vr *viewportRenderer) feed(b byte) { switch vr.state { case vpNormal: if b == 0x1b { vr.state = vpEsc vr.buf = vr.buf[:0] vr.buf = append(vr.buf, b) return } vr.feedPrintable(b) case vpEsc: vr.buf = append(vr.buf, b) switch b { case '[': vr.state = vpCSI case ']': vr.state = vpOSC case 'P': vr.state = vpDCS case 'X', '^', '_': vr.state = vpSOSPMAPC case 'M', 'D', 'E': // RI (ESC M), IND (ESC D), NEL (ESC E). All three can scroll // the host's scroll region when the cursor is at the top // (RI) or bottom (IND/NEL) edge. The region spans the full // row width, so the scroll drags the sidebar columns along // with the main pane. Forward as-is and flag for sidebar // cache invalidation. Codex emits 8× RI on startup, which // is what motivated this branch. vr.pending.Write(vr.buf) vr.scrolled = true vr.state = vpNormal vr.buf = vr.buf[:0] default: vr.pending.Write(vr.buf) vr.state = vpNormal vr.buf = vr.buf[:0] } case vpCSI: vr.buf = append(vr.buf, b) if isCSIFinal(b) { vr.emitCSI() vr.state = vpNormal vr.buf = vr.buf[:0] } case vpOSC: vr.buf = append(vr.buf, b) switch b { case 0x07: vr.pending.Write(vr.buf) vr.state = vpNormal vr.buf = vr.buf[:0] case 0x1b: vr.state = vpOSCEsc } case vpOSCEsc: vr.buf = append(vr.buf, b) vr.pending.Write(vr.buf) vr.state = vpNormal vr.buf = vr.buf[:0] case vpDCS: vr.buf = append(vr.buf, b) if b == 0x1b { vr.state = vpDCSEsc } case vpDCSEsc: vr.buf = append(vr.buf, b) vr.pending.Write(vr.buf) vr.state = vpNormal vr.buf = vr.buf[:0] case vpSOSPMAPC: vr.buf = append(vr.buf, b) if b == 0x1b { vr.state = vpSOSPMAPCEsc } case vpSOSPMAPCEsc: vr.buf = append(vr.buf, b) vr.pending.Write(vr.buf) vr.state = vpNormal vr.buf = vr.buf[:0] } } func (vr *viewportRenderer) emitCSI() { if len(vr.buf) < 3 { vr.pending.Write(vr.buf) return } final := vr.buf[len(vr.buf)-1] params := vr.buf[2 : len(vr.buf)-1] if final == 'h' || final == 'l' { if isAltScreenMode(params) { return } } switch final { case 'J': n, ok := parseOneParam(params, 0) if !ok { vr.pending.Write(vr.shifter.Shift(vr.buf)) return } switch n { case 0: vr.pending.WriteString(vr.clearViewportFromCursor()) case 1: vr.pending.WriteString(vr.clearViewportToCursor()) case 2, 3: vr.pending.WriteString(vr.clearViewport()) default: vr.pending.Write(vr.shifter.Shift(vr.buf)) } case 'K': n, ok := parseOneParam(params, 0) if !ok { vr.pending.Write(vr.shifter.Shift(vr.buf)) return } vr.pending.WriteString(vr.clearLine(n)) case 'S', 'T', 'L', 'M': // SU (S) / SD (T) / IL (L) / DL (M) all shift content within // the host's scroll region row-wise across every column. The // sidebar lives at the right of the host, inside the scroll // region's row range, so any of these drag its cells along // with the main pane. Forward verbatim and flag the chunk so // the sidebar is repainted afterwards. vr.pending.Write(vr.shifter.Shift(vr.buf)) vr.scrolled = true default: vr.pending.Write(vr.shifter.Shift(vr.buf)) } vr.trackCSI(final, params) } func isAltScreenMode(params []byte) bool { s := string(params) if !strings.HasPrefix(s, "?") { return false } for _, p := range strings.Split(strings.TrimPrefix(s, "?"), ";") { switch p { case "47", "1047", "1049": return true } } return false } func (vr *viewportRenderer) clearViewport() string { var b strings.Builder b.WriteString("\x1b7") for r := uint16(0); r < vr.layout.childRows(); r++ { fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX", int(vr.layout.mainTop+r), int(vr.layout.mainLeft), int(vr.layout.childCols())) } b.WriteString("\x1b8") return b.String() } // clearViewportFromCursor implements `CSI 0 J` clamped to the viewport. // Without clamping, the child's "clear to end of screen" would reach the // rightmost columns and erase the sidebar. func (vr *viewportRenderer) clearViewportFromCursor() string { row, col := vr.row, vr.col cols := int(vr.layout.childCols()) rows := int(vr.layout.childRows()) if row < 1 { row = 1 } if col < 1 { col = 1 } var b strings.Builder b.WriteString("\x1b7") if remaining := cols - col + 1; remaining > 0 { fmt.Fprintf(&b, "\x1b[%dX", remaining) } for r := row + 1; r <= rows; r++ { fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX", int(vr.layout.mainTop)+r-1, int(vr.layout.mainLeft), cols) } b.WriteString("\x1b8") return b.String() } // clearViewportToCursor implements `CSI 1 J` clamped to the viewport. func (vr *viewportRenderer) clearViewportToCursor() string { row, col := vr.row, vr.col cols := int(vr.layout.childCols()) if row < 1 { row = 1 } if col < 1 { col = 1 } if col > cols { col = cols } var b strings.Builder b.WriteString("\x1b7") for r := 1; r < row; r++ { fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX", int(vr.layout.mainTop)+r-1, int(vr.layout.mainLeft), cols) } fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX", int(vr.layout.mainTop)+row-1, int(vr.layout.mainLeft), col) b.WriteString("\x1b8") return b.String() } func (vr *viewportRenderer) clearLine(n int) string { right := int(vr.layout.childCols()) if vr.col < 1 { vr.col = 1 } if vr.col > right { vr.col = right } switch n { case 0: return "\x1b[" + strconv.Itoa(right-vr.col+1) + "X" case 1: return "\x1b7\r\x1b[" + strconv.Itoa(vr.col) + "X\x1b8" case 2: return "\x1b7\r\x1b[" + strconv.Itoa(right) + "X\x1b8" default: return "\x1b[" + strconv.Itoa(right-vr.col+1) + "X" } } // feedPrintable handles one non-ESC byte in the vpNormal state. It both // advances vr's cursor model and decides whether the byte should be // forwarded to the host. Bytes that would land past the viewport's // right edge (childCols) are dropped so a child whose internal column // state drifted past the viewport can't spray into the sidebar columns. // UTF-8 continuation bytes follow the fate of their starter so a // multi-byte glyph drops as a unit. func (vr *viewportRenderer) feedPrintable(b byte) { // Control codes (CR, LF, BS, TAB, BEL, etc.) move the cursor or // signal state and must always be forwarded. They never produce // glyphs, so they can't clobber the sidebar themselves. if b < 0x20 || b == 0x7f { vr.pending.WriteByte(b) switch b { case '\r': vr.col = 1 case '\n': vr.row++ case '\b': if vr.col > 1 { vr.col-- } case '\t': vr.col += 8 - ((vr.col - 1) % 8) } vr.skipUTF8 = false vr.clampCursor() return } // UTF-8 continuation byte (10xxxxxx) belongs to the current glyph. if b >= 0x80 && b < 0xC0 { if vr.skipUTF8 { return } vr.pending.WriteByte(b) return } // Glyph starter (ASCII 0x20..0x7E or UTF-8 leading byte 0xC0+). If // the cursor sits past the viewport we'd be spraying into the // sidebar columns — drop the glyph (and the continuation bytes that // follow, via skipUTF8). maxCol := int(vr.layout.childCols()) if maxCol > 0 && vr.col > maxCol { vr.skipUTF8 = b >= 0xC0 return } vr.skipUTF8 = false vr.pending.WriteByte(b) vr.col++ vr.clampCursor() } // advancePrintable is retained for tests that exercise cursor tracking // directly; the runtime path goes through feedPrintable. func (vr *viewportRenderer) advancePrintable(b byte) { switch b { case '\r': vr.col = 1 case '\n': vr.row++ case '\b': if vr.col > 1 { vr.col-- } case '\t': vr.col += 8 - ((vr.col - 1) % 8) default: if b >= 0x20 && b != 0x7f && (b < 0x80 || b >= 0xC0) { vr.col++ } } vr.clampCursor() } func (vr *viewportRenderer) trackCSI(final byte, params []byte) { switch final { case 'H', 'f': r, c, ok := parseTwoParams(params) if ok { vr.row, vr.col = r, c } case 'G', '`': c, ok := parseOneParam(params, 1) if ok { vr.col = c } case 'd': r, ok := parseOneParam(params, 1) if ok { vr.row = r } case 'A': n, ok := parseOneParam(params, 1) if ok { vr.row -= n } case 'B', 'e': n, ok := parseOneParam(params, 1) if ok { vr.row += n } case 'C', 'a': n, ok := parseOneParam(params, 1) if ok { vr.col += n } case 'D': n, ok := parseOneParam(params, 1) if ok { vr.col -= n } } vr.clampCursor() } func (vr *viewportRenderer) clampCursor() { if vr.row < 1 { vr.row = 1 } if vr.col < 1 { vr.col = 1 } if max := int(vr.layout.childRows()); vr.row > max { vr.row = max } // Intentionally do NOT clamp vr.col to childCols here. feedPrintable // drops glyphs once vr.col exceeds childCols (so a child whose // internal column state drifted past the viewport can't spray bytes // into the sidebar). If we clamped col back to childCols on every // printable, every subsequent byte would look like it was still "at // the right margin" and would write again. We cap at childCols+1 // instead so clear-line bookkeeping doesn't see arbitrarily large // numbers. if max := int(vr.layout.childCols()); vr.col > max+1 { vr.col = max + 1 } }