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 scrollTop int scrollBottom int originMode bool lrMarginMode bool 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, or LF / VT / FF at the bottom margin. // 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 // childOnAlt tracks whether the focused child has entered its // alternate screen (via ?47 / ?1047 / ?1049). Used to gate mouse- // tracking-mode forwarding to the host: filter on primary so // patterm's wheel-scrollback stays armed, forward on alt so codex // (which disables mouse) lets the user select text and vim (which // enables it) still gets mouse events. childOnAlt 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 { vr := &viewportRenderer{ shifter: newCursorShifter(int(l.mainTop)-1, int(l.childRows()), int(l.childCols())), layout: l, row: 1, col: 1, } vr.resetScrollRegion() return vr } // SetChildOnAlt seeds the renderer's view of the focused child's screen // side. Used when a new renderer is constructed for an already-running // child whose alt-screen transition we missed, so subsequent mouse-mode // toggles are filtered/forwarded according to the right side. func (vr *viewportRenderer) SetChildOnAlt(onAlt bool) { vr.mu.Lock() defer vr.mu.Unlock() vr.childOnAlt = onAlt } 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())) vr.resetScrollRegion() } func (vr *viewportRenderer) Render(in []byte) []byte { vr.mu.Lock() defer vr.mu.Unlock() vr.pending.Reset() // Fast path: while we're in vpNormal and have a run of plain ASCII // printables that fit the remaining column budget, copy en bloc // instead of round-tripping each byte through the feed state // machine. UTF-8 leaders and any control byte fall back to the // per-byte path so the cursor/skipUTF8/clamp logic stays exact. for i := 0; i < len(in); { if vr.state == vpNormal { maxCol := int(vr.layout.childCols()) if maxCol > 0 && vr.col >= 1 && vr.col <= maxCol { budget := maxCol - vr.col + 1 j := i for j < len(in) && budget > 0 { b := in[j] // Pure ASCII printables only — any control byte // (0x1b ESC included), UTF-8 leader, or trailer // kicks back to the state machine. if b < 0x20 || b == 0x7f || b >= 0x80 { break } j++ budget-- } if j-i >= 4 { vr.pending.Write(in[i:j]) vr.col += j - i vr.skipUTF8 = false vr.clampCursor() i = j continue } } } vr.feed(in[i]) i++ } 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 action since the previous call. 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 vertically. 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 isOriginMode(params) { vr.setOriginMode(final == 'h') vr.emitCursorPosition(vr.row, vr.col) return } if isLeftRightMarginMode(params) { vr.lrMarginMode = final == 'h' return } if isAltScreenMode(params) { // Track the child's screen side so we know whether to filter // or forward subsequent mouse-mode toggles. Entering alt // disables host mouse reporting by default so codex (and // any other alt-screen TUI that doesn't request mouse) // allows the user to click-drag to select text. Alt-screen // TUIs that want mouse (vim, less with -X) re-enable it // via ?1000h after switching to alt — the forwarder below // passes that through. Leaving alt re-arms host mouse for // primary-screen wheel-scrollback. wasAlt := vr.childOnAlt vr.childOnAlt = final == 'h' if !wasAlt && vr.childOnAlt { vr.pending.WriteString("\x1b[?1000l\x1b[?1006l") } if wasAlt && !vr.childOnAlt { vr.pending.WriteString("\x1b[?1000h\x1b[?1006h") } return } if isMouseTrackingMode(params) { // On the child's primary screen patterm owns mouse reporting so // wheel events keep flowing for in-pane scrollback — drop the // child's toggle. On the alt screen the child should be free // to enable mouse (vim, less) or disable it (codex); we forward // the toggle to the host so click-and-drag selection works for // alt-screen TUIs that don't want mouse, and mouse-aware ones // still see the events they need. if vr.childOnAlt { vr.pending.Write(vr.buf) } return } } if final == 's' && vr.lrMarginMode { return } switch final { case 'H', 'f': r, c, ok := parseTwoParams(params) if !ok { vr.pending.Write(vr.shifter.Shift(vr.buf)) return } vr.row = vr.originRow(r) vr.col = c vr.emitCursorPosition(vr.row, c) vr.clampCursor() case 'd': r, ok := parseOneParam(params, 1) if !ok { vr.pending.Write(vr.shifter.Shift(vr.buf)) return } vr.row = vr.originRow(r) vr.pending.Write(vr.shifter.Shift([]byte(fmt.Sprintf("\x1b[%dd", vr.row)))) vr.clampCursor() 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 case 'r': vr.pending.Write(vr.shifter.Shift(vr.buf)) if vr.trackScrollRegion(params) { vr.emitHomeAfterScrollRegion() } case 'A', 'B', 'E', 'F': // Relative cursor moves: CUU (A) / CUD (B) / CNL (E) / CPL (F). // The cursor shifter only rewrites absolute positioning, so a // child that asks the cursor to "go up 50" from viewport row 1 // would walk the host cursor into the tab bar (and the next // printable would write there). Clamp the step using the // renderer's tracked row so the host cursor stays inside the // viewport. E / F additionally home the column to 1. vr.emitRelativeRowMove(final, params) return default: vr.pending.Write(vr.shifter.Shift(vr.buf)) } if final != 'H' && final != 'f' && final != 'd' && final != 'r' { vr.trackCSI(final, params) } } // emitRelativeRowMove rewrites CSI A / B / E / F so the resulting host // cursor stays within rows 1..childRows in viewport coordinates. The // renderer already tracks vr.row for clear-line bookkeeping; reusing // that here avoids a second cursor model. n is normalized — a step of // 0 is treated as 1 to match xterm. After clamping, if the effective // step is zero we drop the sequence (the cursor is already pinned to // the boundary). E / F also move the cursor to column 1 even when no // row step is emitted. func (vr *viewportRenderer) emitRelativeRowMove(final byte, params []byte) { n, ok := parseOneParam(params, 1) if !ok { vr.pending.Write(vr.shifter.Shift(vr.buf)) return } if n <= 0 { n = 1 } rows := int(vr.layout.childRows()) if rows < 1 { rows = 1 } row := vr.row if row < 1 { row = 1 } if row > rows { row = rows } up := final == 'A' || final == 'F' var safe int if up { safe = row - 1 } else { safe = rows - row } if safe < 0 { safe = 0 } if n > safe { n = safe } if n > 0 { if up { vr.row -= n } else { vr.row += n } fmt.Fprintf(&vr.pending, "\x1b[%d%c", n, final) } if final == 'E' || final == 'F' { // CNL / CPL anchor the column at 1 regardless of whether the // row step was clamped to zero, matching xterm. vr.col = 1 vr.pending.WriteByte('\r') } vr.clampCursor() } 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 isOriginMode(params []byte) bool { s := string(params) if !strings.HasPrefix(s, "?") { return false } for _, p := range strings.Split(strings.TrimPrefix(s, "?"), ";") { if p == "6" { return true } } return false } func isLeftRightMarginMode(params []byte) bool { s := string(params) if !strings.HasPrefix(s, "?") { return false } for _, p := range strings.Split(strings.TrimPrefix(s, "?"), ";") { if p == "69" { return true } } return false } // isMouseTrackingMode reports whether any of the modes in a CSI ? … h/l // is a mouse-tracking or mouse-encoding DEC private mode. The host runs // with SGR mouse reporting permanently armed; we drop the child's set/ // reset for these modes from the host stream so wheel events keep // reaching patterm. func isMouseTrackingMode(params []byte) bool { s := string(params) if !strings.HasPrefix(s, "?") { return false } for _, p := range strings.Split(strings.TrimPrefix(s, "?"), ";") { switch p { case "9", "1000", "1001", "1002", "1003", "1004", "1005", "1006", "1007", "1015", "1016": 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" } } func (vr *viewportRenderer) resetScrollRegion() { vr.scrollTop = 1 vr.scrollBottom = int(vr.layout.childRows()) if vr.scrollBottom < 1 { vr.scrollBottom = 1 } } func (vr *viewportRenderer) setOriginMode(on bool) { vr.originMode = on if on { vr.row = vr.scrollTop } else { vr.row = 1 } vr.col = 1 vr.clampCursor() } func (vr *viewportRenderer) originRow(row int) int { if row < 1 { row = 1 } if !vr.originMode { return row } row = vr.scrollTop + row - 1 if row < vr.scrollTop { row = vr.scrollTop } if row > vr.scrollBottom { row = vr.scrollBottom } return row } func (vr *viewportRenderer) homeAfterScrollRegion() { if vr.originMode { vr.row = vr.scrollTop } else { vr.row = 1 } vr.col = 1 vr.clampCursor() } func (vr *viewportRenderer) emitHomeAfterScrollRegion() { vr.homeAfterScrollRegion() vr.emitCursorPosition(vr.row, vr.col) } func (vr *viewportRenderer) emitCursorPosition(row, col int) { vr.pending.Write(vr.shifter.Shift([]byte(fmt.Sprintf("\x1b[%d;%dH", row, col)))) } func (vr *viewportRenderer) lineFeed() { if vr.row >= vr.scrollTop && vr.row == vr.scrollBottom { vr.scrolled = true return } vr.row++ } // 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', '\v', '\f': vr.lineFeed() 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 = vr.originRow(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 = vr.originRow(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 } case 'r': if vr.trackScrollRegion(params) { vr.homeAfterScrollRegion() } } vr.clampCursor() } func (vr *viewportRenderer) trackScrollRegion(params []byte) bool { if len(params) == 0 { vr.resetScrollRegion() return true } top, bottom, ok := parseTwoParams(params) if !ok { return false } maxRows := int(vr.layout.childRows()) if maxRows < 1 { maxRows = 1 } if top < 1 { top = 1 } if bottom < 1 || bottom > maxRows { bottom = maxRows } if top >= bottom { return false } vr.scrollTop = top vr.scrollBottom = bottom return true } 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 } }