Files
patterm/internal/app/viewport_renderer.go
Harry Bayliss 3622c41fd0 Land staged session/MCP/chrome work + sidebar clear-J fix
This batches the in-flight [Unreleased] block from CHANGELOG.md into a
single commit. Highlights:

- Real MCP protocol layer (initialize / tools/list / tools/call) so
  vendor MCP clients can complete the handshake against the per-PID
  socket. Legacy direct-dispatch preserved for the harness.
- New mcp_injection kinds — cli_override for codex, config_env for
  opencode — joining the existing env-var and config_file paths so
  patterm can slot into more agents without touching their real
  config or auth.
- Ctrl+A/D and Ctrl+W/S focus navigation across tabs and intra-tab
  process lists, recognised in legacy / kitty CSI u / xterm
  modifyOtherKeys encodings.
- Palette macros (sw / k / sp ) and reordering so open sessions
  surface above spawn-new entries.
- Two-row tab bar, sidebar/tabbar/status chrome cache, viewport-wipe
  on agent spawn, CR-terminated orchestrator injections, and split-
  Enter PTY writes so paste-detecting TUIs see Enter as a key event.

Also fixes the bug logged in TODO: claude's Ctrl+O tool-call expansion
emits CSI 0 J, which the viewport renderer was forwarding verbatim —
wiping the sidebar to the right of the cursor and leaving the chrome
cache convinced nothing had changed. CSI 0 J and CSI 1 J are now
translated into per-row ECH sequences clamped to the viewport, same
as CSI 2 J and CSI K already were.

Agent guides (CLAUDE.md / AGENTS.md) now spell out the
TODO->CHANGELOG workflow so completed items land in the changelog
rather than as ticked entries left behind in TODO.
2026-05-14 19:09:35 +01:00

353 lines
6.9 KiB
Go

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
}
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())),
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()))
}
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())
}
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.pending.WriteByte(b)
vr.advancePrintable(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
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))
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
}
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) 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 {
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
}
if max := int(vr.layout.childCols()); vr.col > max {
vr.col = max
}
}