Initial patterm project

This commit is contained in:
2026-05-14 13:37:20 +01:00
commit 69ef09aac4
40 changed files with 6521 additions and 0 deletions

724
internal/app/app.go Normal file
View File

@@ -0,0 +1,724 @@
package app
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"strings"
"sync"
"sync/atomic"
"syscall"
cpty "github.com/creack/pty"
"golang.org/x/term"
"github.com/harrybrwn/patterm/internal/mcp"
"github.com/harrybrwn/patterm/internal/policy"
"github.com/harrybrwn/patterm/internal/preset"
"github.com/harrybrwn/patterm/internal/scratchpad"
)
// Options configures a patterm run.
type Options struct {
ProjectDir string
ProjectKey string
}
const keyCtrlK byte = 0x0b
// Run is patterm's single-process entry point. SPEC §2: one Go process
// owns everything; no daemon, no detach, no socket-based reattachment.
func Run(ctx context.Context, opts Options) error {
if opts.ProjectDir == "" {
return errors.New("app: ProjectDir required")
}
presets, err := preset.Load()
if err != nil {
return fmt.Errorf("app: load presets: %w", err)
}
pol, err := policy.Load()
if err != nil {
return fmt.Errorf("app: load policy: %w", err)
}
// Ensure the per-project scratchpad dir exists so MCP and the UI
// can read/write into it. SPEC §3.
pads, err := scratchpad.Open(opts.ProjectKey)
if err != nil {
return fmt.Errorf("app: scratchpad init: %w", err)
}
// In-process MCP server bound to the per-PID socket. Children that
// support MCP get pointed at `patterm mcp-stdio --socket=... --identity=...`.
// SPEC §10.
mcpSrv, err := mcp.Start()
if err != nil {
return fmt.Errorf("app: mcp start: %w", err)
}
defer mcpSrv.Close()
sess := NewSession(opts.ProjectDir, opts.ProjectKey)
defer sess.Shutdown()
cols, rows := hostSize()
layout := newTerminalLayout(cols, rows)
// Launcher handles preset → child translation, including MCP
// config injection for agent presets.
launcher := NewLauncher(sess, mcpSrv.Socket(), layout.childCols(), layout.childRows())
// Wire the tool host into MCP. Spawns through MCP use the host
// terminal's viewport grid for their initial PTY size; SIGWINCH paths
// resize them later.
host := newToolHost(sess, pads, launcher, presets, pol, layout.childCols(), layout.childRows())
mcpSrv.SetHost(host)
var restoreState *term.State
if term.IsTerminal(int(os.Stdin.Fd())) {
st, err := term.MakeRaw(int(os.Stdin.Fd()))
if err != nil {
return fmt.Errorf("app: stdin raw: %w", err)
}
restoreState = st
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
st := &uiState{
sess: sess,
presets: presets,
launcher: launcher,
pads: pads,
hostCols: cols,
hostRows: rows,
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
}
host.attention = st
st.lastExit.Store(-1)
sess.Subscribe(st)
st.enterScreen()
st.renderEmptyState()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
// Set initial PTY grid for any future child. The child gets the
// computed main viewport, excluding tab bar, sidebar, and status.
sess.ResizeAll(layout.childCols(), layout.childRows())
launcher.SetSize(layout.childCols(), layout.childRows())
host.SetSize(layout.childCols(), layout.childRows())
var wg sync.WaitGroup
// SIGWINCH.
wg.Add(1)
winch := make(chan os.Signal, 1)
signal.Notify(winch, syscall.SIGWINCH)
go func() {
defer wg.Done()
defer signal.Stop(winch)
for {
select {
case <-ctx.Done():
return
case <-winch:
c, r := hostSize()
if c == 0 || r == 0 {
continue
}
st.dimsMu.Lock()
st.hostCols, st.hostRows = c, r
l := st.layoutLocked()
st.dimsMu.Unlock()
st.mu.Lock()
if st.renderer != nil {
st.renderer.SetLayout(l)
}
st.mu.Unlock()
sess.ResizeAll(l.childCols(), l.childRows())
launcher.SetSize(l.childCols(), l.childRows())
host.SetSize(l.childCols(), l.childRows())
st.clearScreen()
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
}
}()
// External termination: SPEC §2 step 4 (SIGTERM/SIGHUP → graceful exit).
wg.Add(1)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP)
go func() {
defer wg.Done()
defer signal.Stop(sigCh)
select {
case <-ctx.Done():
return
case sig := <-sigCh:
st.dbgf("signal %s; tearing down", sig)
cancel()
}
}()
// Stdin loop.
go func() {
if err := st.stdinLoop(); err != nil {
st.dbgf("stdinLoop: %v", err)
}
cancel()
}()
<-ctx.Done()
wg.Wait()
st.leaveScreen()
if restoreState != nil {
_ = term.Restore(int(os.Stdin.Fd()), restoreState)
}
if st.lastExit.Load() >= 0 {
fmt.Fprintf(os.Stderr, "patterm: last child exited (%d).\n", st.lastExit.Load())
}
return nil
}
// uiState is the shared state between the SIGWINCH loop, the stdin
// loop, and the session listener callbacks.
type uiState struct {
sess *Session
presets preset.Set
launcher *Launcher
pads *scratchpad.Store
outMu sync.Mutex
mu sync.Mutex
palette *paletteState
focusedID string
focusedName string
// renderer confines focused-child live output to the main viewport.
// A fresh renderer is allocated per focused child so partial-escape
// state cannot bleed between panes.
renderer *viewportRenderer
// passthrough: when true, the next keystroke is forwarded to the
// focused PTY untouched (SPEC §4 Ctrl-K Ctrl-K).
passthroughArmed bool
// attention is the latest request_human_attention surfaced via MCP;
// rendered in the status line until cleared.
attentionText string
attentionAt string
dimsMu sync.Mutex
hostCols, hostRows uint16
stdinTTY bool
lastExit atomic.Int32
}
func (st *uiState) dbgf(format string, args ...any) {
logf(format, args...)
}
// notifyAttention is the request_human_attention sink (SPEC §7). We
// surface a one-line toast in the status row and remember the most
// recent ask so the status line keeps showing it. The sidebar-blink is
// deferred until the §4 chrome lands.
func (st *uiState) notifyAttention(childID, reason string) {
c := st.sess.FindChild(childID)
name := childID
if c != nil {
name = c.Name
}
st.mu.Lock()
st.attentionText = fmt.Sprintf("attention: %s — %s", name, reason)
st.attentionAt = childID
st.mu.Unlock()
st.drawStatusLine()
}
// OnChildSpawned auto-focuses the new child.
func (st *uiState) OnChildSpawned(c *Child) {
st.mu.Lock()
st.focusedID = c.ID
st.focusedName = c.Name
st.renderer = newViewportRenderer(st.layoutSnapshot())
if st.palette != nil {
st.palette.children = st.sess.Children()
st.palette.focused = st.focusedID
st.palette.rebuild()
st.renderPaletteLocked()
}
st.mu.Unlock()
st.moveToViewportOrigin()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// OnChildExited drops focus and shows the empty state if it was the
// focused child.
func (st *uiState) OnChildExited(c *Child) {
st.lastExit.Store(int32(c.ExitCode()))
st.mu.Lock()
if c.ID == st.focusedID {
next := firstRunningTopLevel(st.sess.Children())
if next == nil {
st.focusedID = ""
st.focusedName = ""
st.renderEmptyStateLocked()
} else {
st.focusedID = next.ID
st.focusedName = next.Name
st.renderer = newViewportRenderer(st.layoutSnapshot())
}
}
if st.palette != nil {
st.palette.children = st.sess.Children()
st.palette.focused = st.focusedID
st.palette.rebuild()
st.renderPaletteLocked()
}
st.mu.Unlock()
if st.focusedID != "" {
st.repaintFocused()
}
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// OnPTYOut writes live output for the focused child when the palette is
// not covering the screen. The viewport renderer shifts cursor movement
// into the main pane and rewrites destructive clears. Host autowrap is
// disabled only around the replay so long styled runs cannot wrap into
// the right rail.
func (st *uiState) OnPTYOut(childID string, chunk []byte) {
st.mu.Lock()
focus := st.focusedID
palOpen := st.palette != nil
renderer := st.renderer
st.mu.Unlock()
if palOpen || focus != childID || renderer == nil {
return
}
out := renderer.Render(chunk)
st.outMu.Lock()
_, _ = os.Stdout.Write([]byte("\x1b[?7l"))
_, _ = os.Stdout.Write(out)
_, _ = os.Stdout.Write([]byte("\x1b[?7h"))
st.outMu.Unlock()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
func (st *uiState) enterScreen() {
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write([]byte("\x1b[?1049h\x1b[H\x1b[2J\x1b[?25h"))
}
func (st *uiState) leaveScreen() {
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write([]byte("\x1b[?25h\x1b[?1049l"))
}
func (st *uiState) clearScreen() {
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write([]byte("\x1b[?25h\x1b[H\x1b[2J"))
}
func (st *uiState) moveToViewportOrigin() {
layout := st.layoutSnapshot()
st.outMu.Lock()
defer st.outMu.Unlock()
fmt.Fprintf(os.Stdout, "\x1b[%d;%dH", int(layout.mainTop), int(layout.mainLeft))
}
func (st *uiState) renderPaletteLocked() {
if st.palette == nil {
return
}
st.outMu.Lock()
defer st.outMu.Unlock()
cols, rows := st.hostSizeSnapshot()
st.palette.render(wrapWriter(os.Stdout), int(cols), int(rows))
}
// drawStatusLine renders SPEC §4's bottom status line. Left side: input
// ownership toast ("orchestrator driving" / "you have control") and any
// attention ask. Right side: palette hint. The PTY child occupies
// host_rows-1 rows so this row is exclusively ours.
func (st *uiState) drawStatusLine() {
st.mu.Lock()
palOpen := st.palette != nil
focusID := st.focusedID
focusName := st.focusedName
attention := st.attentionText
attentionAt := st.attentionAt
st.mu.Unlock()
if palOpen {
return
}
cols, rows := st.hostSizeSnapshot()
if cols == 0 || rows == 0 {
return
}
owner := ""
if focusID != "" {
if c := st.sess.FindChild(focusID); c != nil {
switch c.Owner() {
case OwnerOrchestrator:
owner = "orchestrator driving"
case OwnerUser:
owner = "you have control"
}
}
}
left := ""
if focusName != "" {
left = focusName
}
if owner != "" {
if left != "" {
left = left + " · " + owner
} else {
left = owner
}
}
if attention != "" && attentionAt == focusID {
left = "[!] " + attention
}
right := "Ctrl-K · palette"
pad := int(cols) - len(left) - len(right)
if pad < 1 {
pad = 1
}
line := left + strings.Repeat(" ", pad) + right
if len(line) > int(cols) {
line = line[:int(cols)]
}
st.outMu.Lock()
defer st.outMu.Unlock()
// Save cursor, move to last row col 1, write, restore.
fmt.Fprintf(os.Stdout, "\x1b7\x1b[999;1H\x1b[2m\x1b[7m%s\x1b[0m\x1b8", line)
}
// renderEmptyState is the SPEC §4 blank-canvas hint. Drawn whenever no
// child is focused.
func (st *uiState) renderEmptyState() {
st.mu.Lock()
defer st.mu.Unlock()
st.renderEmptyStateLocked()
}
func (st *uiState) renderEmptyStateLocked() {
st.outMu.Lock()
defer st.outMu.Unlock()
layout := st.layoutSnapshot()
line := "Press Ctrl-K to spawn an agent or process"
row := int(layout.mainTop) + (int(layout.childRows()) / 2)
col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2)
if row < int(layout.mainTop) {
row = int(layout.mainTop)
}
if col < int(layout.mainLeft) {
col = int(layout.mainLeft)
}
fmt.Fprintf(os.Stdout, "\x1b[?25l\x1b[H\x1b[2J\x1b[%d;%dH\x1b[2m%s\x1b[0m", row, col, line)
}
func (st *uiState) hostSizeSnapshot() (uint16, uint16) {
st.dimsMu.Lock()
defer st.dimsMu.Unlock()
return st.hostCols, st.hostRows
}
func (st *uiState) layoutSnapshot() terminalLayout {
st.dimsMu.Lock()
defer st.dimsMu.Unlock()
return st.layoutLocked()
}
func (st *uiState) layoutLocked() terminalLayout {
return newTerminalLayout(st.hostCols, st.hostRows)
}
func (st *uiState) stdinLoop() error {
buf := make([]byte, 4096)
for {
n, err := os.Stdin.Read(buf)
if n > 0 {
st.processStdin(buf[:n])
}
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return fmt.Errorf("read error %w (n=%d)", err, n)
}
}
}
// processStdin walks one read of stdin byte by byte. The palette
// intercepts everything when it's open. Otherwise Ctrl-K opens it and
// every other byte forwards to the focused PTY. The Ctrl-K Ctrl-K chord
// is SPEC §4's passthrough prefix: after the first Ctrl-K, if the very
// next byte is another Ctrl-K, both are sent to the PTY literally.
//
// NOTE on locking: a palette-close action (spawn / switch / kill /
// quit) may fire session listeners (OnChildSpawned, OnChildExited)
// synchronously. Those listeners need st.mu. We must NOT hold st.mu
// when calling closePalette — bytes after the action in the same chunk
// are dropped on the floor, which is the right behavior anyway (the
// user just decided the prior pane is gone).
func (st *uiState) processStdin(chunk []byte) {
st.mu.Lock()
forward := make([]byte, 0, len(chunk))
flushForward := func() {
if len(forward) == 0 {
return
}
if st.focusedID != "" {
if c := st.sess.FindChild(st.focusedID); c != nil && c.Status() == StatusRunning {
prev := c.Owner()
_ = c.InjectAsUser(forward)
if prev != OwnerUser {
go st.drawStatusLine()
}
}
}
forward = forward[:0]
}
var pendingAction *paletteAction
i := 0
for i < len(chunk) {
b := chunk[i]
// Passthrough armed: forward this byte literally regardless of
// what it is, then disarm.
if st.passthroughArmed {
forward = append(forward, b)
st.passthroughArmed = false
i++
continue
}
// Palette mode swallows all bytes.
if st.palette != nil {
var peek []byte
if i+1 < len(chunk) {
peek = chunk[i+1:]
}
action, done := st.palette.handleKey(b, peek)
if b == 0x1b && len(peek) >= 2 && peek[0] == '[' {
if peek[1] == 'A' || peek[1] == 'B' {
i += 3
} else {
i++
}
} else {
i++
}
if done {
a := action
pendingAction = &a
break
}
st.renderPaletteLocked()
continue
}
// Ctrl-K is the reserved app-level binding. Two cases:
// - Ctrl-K then anything except Ctrl-K → open palette.
// - Ctrl-K Ctrl-K → arm passthrough; the next byte goes raw.
if b == keyCtrlK {
// Peek at the next byte if we have it.
next := byte(0)
haveNext := i+1 < len(chunk)
if haveNext {
next = chunk[i+1]
}
if haveNext && next == keyCtrlK {
// Chord: forward both Ctrl-K bytes literally. (Some
// nested TUIs expect Ctrl-K itself.)
flushForward()
forward = append(forward, keyCtrlK, keyCtrlK)
flushForward()
i += 2
continue
}
if !haveNext {
// Could be the first byte of a chord — arm and wait.
st.passthroughArmed = true
// But we also want palette-open on a lone Ctrl-K. Resolve
// by treating "Ctrl-K at end of read" as palette open;
// any subsequent Ctrl-K in the next read still has the
// chord semantics because passthroughArmed got set first.
// To match the spec's reading, simpler model: lone Ctrl-K
// in this read opens the palette.
st.passthroughArmed = false
flushForward()
st.openPaletteLocked()
i++
continue
}
// Ctrl-K followed by something that's not Ctrl-K → palette open.
flushForward()
st.openPaletteLocked()
i++
continue
}
forward = append(forward, b)
i++
}
flushForward()
st.mu.Unlock()
if pendingAction != nil {
st.closePalette(*pendingAction)
}
}
func (st *uiState) openPaletteLocked() {
st.palette = newPalette(st.sess.Children(), st.focusedID, st.presets)
st.renderPaletteLocked()
}
// closePalette is invoked with st.mu UNLOCKED. The session-mutating
// actions below (spawn / kill) fire listeners that take st.mu, so
// holding it here would deadlock. Each helper this calls takes its own
// brief mu acquisitions as needed.
func (st *uiState) closePalette(action paletteAction) {
st.mu.Lock()
st.palette = nil
st.mu.Unlock()
st.clearScreen()
switch action.kind {
case "", "cancel":
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
case "spawn-agent":
if action.preset == nil {
st.repaintFocused()
return
}
l := st.layoutSnapshot()
st.launcher.SetSize(l.childCols(), l.childRows())
// LaunchAgent fires OnChildSpawned synchronously; it will draw
// chrome and set focus.
if _, err := st.launcher.LaunchAgent(action.preset, action.preset.Name, "", ""); err != nil {
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
}
case "spawn-process":
if action.preset == nil {
st.repaintFocused()
return
}
l := st.layoutSnapshot()
st.launcher.SetSize(l.childCols(), l.childRows())
if _, err := st.launcher.LaunchProcess(action.preset, action.preset.Name); err != nil {
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
}
case "switch":
c := st.sess.FindChild(action.childID)
if c == nil || c.Status() != StatusRunning {
st.repaintFocused()
return
}
st.mu.Lock()
st.focusedID = action.childID
st.focusedName = c.Name
st.renderer = newViewportRenderer(st.layoutSnapshot())
st.mu.Unlock()
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
case "kill":
_ = st.sess.Kill(action.childID, syscall.SIGTERM)
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
case "quit":
st.requestExit()
}
}
// flashError surfaces a spawn/etc. failure in the status line until the
// next attention update overwrites it. stderr is hidden under the alt
// screen so we can't rely on Fprintln(os.Stderr).
func (st *uiState) flashError(msg string) {
st.mu.Lock()
st.attentionText = msg
st.attentionAt = "" // shows on every focus until cleared
st.mu.Unlock()
st.renderEmptyState()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// repaintFocused redraws the current focused child's screen snapshot.
// Callers must NOT hold st.mu — repaintFocused takes it
// briefly itself.
func (st *uiState) repaintFocused() {
st.mu.Lock()
id := st.focusedID
st.mu.Unlock()
if id == "" {
st.renderEmptyState()
return
}
text, cursor, err := st.sess.SnapshotChild(id)
if err != nil {
return
}
out := renderScreenSnapshot(text, cursor, st.layoutSnapshot())
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(out)
}
func (st *uiState) requestExit() {
// Reuse SIGTERM-to-self as the cleanest way to unwind: the signal
// handler in Run() calls cancel() which exits the loop and runs
// Shutdown.
_ = syscall.Kill(os.Getpid(), syscall.SIGTERM)
}
func hostSize() (cols, rows uint16) {
ws, err := cpty.GetsizeFull(os.Stdin)
if err != nil || ws.Cols == 0 || ws.Rows == 0 {
return 120, 40
}
return ws.Cols, ws.Rows
}

235
internal/app/child.go Normal file
View File

@@ -0,0 +1,235 @@
package app
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os/exec"
"sync"
"sync/atomic"
"syscall"
"time"
pkgpty "github.com/harrybrwn/patterm/internal/pty"
"github.com/harrybrwn/patterm/internal/vt"
)
type ChildStatus string
const (
StatusRunning ChildStatus = "running"
StatusExited ChildStatus = "exited"
StatusErrored ChildStatus = "errored"
)
// ChildKind matches the two preset flavours in SPEC §10.
type ChildKind string
const (
KindAgent ChildKind = "agent"
KindProcess ChildKind = "process"
)
// Owner reflects the SPEC §6 input-ownership flag.
type Owner string
const (
OwnerUser Owner = "user"
OwnerOrchestrator Owner = "orchestrator"
)
// Child is one PTY-backed process plus its emulator. The same struct
// represents both agent presets (with MCP) and process presets (raw).
type Child struct {
ID string
Name string
Argv []string
Kind ChildKind
ParentID string // empty for top-level sessions
// Identity is the per-spawn token the mcp-stdio proxy uses to
// identify itself when calling tools. Empty for process presets.
Identity string
pty *pkgpty.PTY
em *vt.GhosttyEmulator
status atomic.Pointer[ChildStatus]
exitCode atomic.Int32
owner atomic.Pointer[Owner]
// lastWrite is the wall time of the most recent PTY-master write.
// SPEC §11 idle heuristic: a pane is idle once nothing has been
// written for the preset's threshold (default 1s).
lastWriteNS atomic.Int64
// ringMu guards ring. The ring buffer carries the last `ringCap`
// bytes the PTY produced, used by SPEC §7 read_output stream mode.
ringMu sync.Mutex
ring []byte
ringStart int64 // absolute offset of ring[0]
ringWrites int64 // cumulative bytes written
}
const ringCap = 1 << 20 // 1 MiB per SPEC §5
func newChild(id, name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID string) (*Child, error) {
if len(argv) == 0 {
return nil, errors.New("child: empty argv")
}
em, err := vt.NewGhosttyEmulator(cols, rows)
if err != nil {
return nil, fmt.Errorf("child %s emulator: %w", id, err)
}
p, err := pkgpty.Start(argv, env, cols, rows)
if err != nil {
em.Close()
return nil, fmt.Errorf("child %s pty: %w", id, err)
}
c := &Child{
ID: id,
Name: name,
Argv: argv,
Kind: kind,
ParentID: parentID,
pty: p,
em: em,
ring: make([]byte, 0, ringCap),
}
st := StatusRunning
c.status.Store(&st)
c.exitCode.Store(-1)
// Agents spawned by an orchestrator default to orchestrator-owned;
// everything else (top-level, processes) defaults to user. SPEC §6.
def := OwnerUser
if kind == KindAgent && parentID != "" {
def = OwnerOrchestrator
}
c.owner.Store(&def)
if kind == KindAgent {
c.Identity = mintIdentity()
}
em.OnWritePTY(func(b []byte) {
_, _ = p.Write(b)
})
return c, nil
}
func (c *Child) Status() ChildStatus {
st := c.status.Load()
if st == nil {
return StatusRunning
}
return *st
}
func (c *Child) ExitCode() int { return int(c.exitCode.Load()) }
func (c *Child) PID() int { return c.pty.Pid() }
func (c *Child) Owner() Owner {
o := c.owner.Load()
if o == nil {
return OwnerUser
}
return *o
}
func (c *Child) SetOwner(o Owner) { c.owner.Store(&o) }
// IdleMS returns how many milliseconds since the last PTY write.
// 0 means "no writes yet". SPEC §11.
func (c *Child) IdleMS() int64 {
last := c.lastWriteNS.Load()
if last == 0 {
return 0
}
return (time.Now().UnixNano() - last) / int64(time.Millisecond)
}
func (c *Child) recordWrite(chunk []byte) {
c.lastWriteNS.Store(time.Now().UnixNano())
c.ringMu.Lock()
defer c.ringMu.Unlock()
c.ring = append(c.ring, chunk...)
c.ringWrites += int64(len(chunk))
if len(c.ring) > ringCap {
drop := len(c.ring) - ringCap
c.ring = c.ring[drop:]
c.ringStart += int64(drop)
}
}
// StreamRead returns ring bytes from `since` to the current write head,
// plus the new offset. Offsets are absolute (cumulative bytes ever
// written). If `since` is before the ring start, the caller missed
// data; we return what we have and the new offset.
func (c *Child) StreamRead(since int64) ([]byte, int64) {
c.ringMu.Lock()
defer c.ringMu.Unlock()
if since < c.ringStart {
since = c.ringStart
}
end := c.ringStart + int64(len(c.ring))
if since >= end {
return nil, end
}
start := int(since - c.ringStart)
out := make([]byte, end-since)
copy(out, c.ring[start:])
return out, end
}
func (c *Child) signal(sig syscall.Signal) error {
pid := c.pty.Pid()
if pid <= 0 {
return errors.New("child has no pid")
}
if err := syscall.Kill(-pid, sig); err == nil {
return nil
}
return syscall.Kill(pid, sig)
}
func (c *Child) markExited(err error) {
exitCode := int32(0)
st := StatusExited
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
exitCode = int32(ee.ExitCode())
} else {
exitCode = -1
st = StatusErrored
}
}
c.exitCode.Store(exitCode)
c.status.Store(&st)
}
// InjectAsUser is the path the human takes when typing in the focused
// pane. SPEC §6: the user's first keystroke flips ownership.
func (c *Child) InjectAsUser(b []byte) error {
c.SetOwner(OwnerUser)
_, err := c.pty.Write(b)
return err
}
// InjectAsOrchestrator is the path send_message_to / report_to_parent /
// initial_prompt / timer_wait writes take. Ownership flips back to
// orchestrator. SPEC §6.
func (c *Child) InjectAsOrchestrator(b []byte) error {
c.SetOwner(OwnerOrchestrator)
_, err := c.pty.Write(b)
return err
}
func mintIdentity() string {
var buf [12]byte
_, _ = rand.Read(buf[:])
return hex.EncodeToString(buf[:])
}

267
internal/app/cursorshift.go Normal file
View File

@@ -0,0 +1,267 @@
package app
import (
"strconv"
"strings"
)
// cursorShifter rewrites cursor-positioning ANSI escapes in a PTY byte
// stream so the child's "row 1" becomes the host's "row 1+offset".
// This lets patterm reserve top rows for chrome (SPEC §4 tab bar)
// while keeping the child unaware.
//
// Sequences rewritten:
// - CSI H, CSI <r> H, CSI <r>;<c> H — CUP
// - CSI <r>;<c> f — HVP
// - CSI <n> d — VPA (line position absolute)
// - CSI <t>;<b> r — DECSTBM (scrolling region)
//
// Other sequences (SGR, mode set, OSC titles, DCS, alt-screen toggles)
// are forwarded byte-for-byte. The parser tracks OSC/DCS/SOS/PM/APC
// state so byte sequences inside those wrappers are NOT misread as
// CSI commands.
type cursorShifter struct {
rowOffset int
state shifterState
buf []byte // bytes accumulated in current escape sequence (incl. introducer)
csiPrefix []byte // private prefix bytes (?, >, =) after CSI
pending strings.Builder
}
type shifterState int
const (
stNormal shifterState = iota
stEsc
stCSI
stCSIPrefix // CSI <private-prefix>... — private prefix means we DON'T rewrite
stOSC
stOSCEsc // we saw ESC inside OSC; expect '\' to close ST
stDCS
stDCSEsc
stSOSPMAPC // SOS/PM/APC body — terminator is ESC \
stSOSPMAPCEsc
)
func newCursorShifter(rowOffset int) *cursorShifter {
return &cursorShifter{rowOffset: rowOffset}
}
func (cs *cursorShifter) SetRowOffset(off int) {
cs.rowOffset = off
}
// Shift consumes a chunk of PTY-master bytes, applies row offsets to
// any complete CUP/HVP/VPA/DECSTBM sequences, and returns the rewritten
// bytes. Partial sequences are buffered across calls so a CSI that
// straddles two PTY reads still gets rewritten.
func (cs *cursorShifter) Shift(in []byte) []byte {
cs.pending.Reset()
for _, b := range in {
cs.feed(b)
}
out := cs.pending.String()
return []byte(out)
}
func (cs *cursorShifter) feed(b byte) {
switch cs.state {
case stNormal:
if b == 0x1b {
cs.state = stEsc
cs.buf = cs.buf[:0]
cs.buf = append(cs.buf, b)
return
}
cs.pending.WriteByte(b)
case stEsc:
cs.buf = append(cs.buf, b)
switch b {
case '[':
cs.state = stCSI
cs.csiPrefix = cs.csiPrefix[:0]
case ']':
cs.state = stOSC
case 'P':
cs.state = stDCS
case 'X', '^', '_':
cs.state = stSOSPMAPC
default:
// Two-byte ESC sequence: ESC <something>. Forward as-is.
cs.pending.Write(cs.buf)
cs.state = stNormal
cs.buf = cs.buf[:0]
}
case stCSI:
// First non-param byte after CSI might be a private prefix
// (?, >, =, etc., 0x3c..0x3f). If so, switch to CSIPrefix and
// don't rewrite this sequence.
if len(cs.csiPrefix) == 0 && len(cs.buf) == 2 && b >= 0x3c && b <= 0x3f {
cs.csiPrefix = append(cs.csiPrefix, b)
cs.buf = append(cs.buf, b)
cs.state = stCSIPrefix
return
}
cs.buf = append(cs.buf, b)
if isCSIFinal(b) {
cs.emitCSI()
cs.state = stNormal
cs.buf = cs.buf[:0]
}
case stCSIPrefix:
cs.buf = append(cs.buf, b)
if isCSIFinal(b) {
// Private CSI; forward unchanged.
cs.pending.Write(cs.buf)
cs.state = stNormal
cs.buf = cs.buf[:0]
}
case stOSC:
cs.buf = append(cs.buf, b)
switch b {
case 0x07: // BEL
cs.pending.Write(cs.buf)
cs.state = stNormal
cs.buf = cs.buf[:0]
case 0x1b:
cs.state = stOSCEsc
}
case stOSCEsc:
cs.buf = append(cs.buf, b)
// ESC \ terminates ST.
cs.pending.Write(cs.buf)
cs.state = stNormal
cs.buf = cs.buf[:0]
case stDCS:
cs.buf = append(cs.buf, b)
if b == 0x1b {
cs.state = stDCSEsc
}
case stDCSEsc:
cs.buf = append(cs.buf, b)
cs.pending.Write(cs.buf)
cs.state = stNormal
cs.buf = cs.buf[:0]
case stSOSPMAPC:
cs.buf = append(cs.buf, b)
if b == 0x1b {
cs.state = stSOSPMAPCEsc
}
case stSOSPMAPCEsc:
cs.buf = append(cs.buf, b)
cs.pending.Write(cs.buf)
cs.state = stNormal
cs.buf = cs.buf[:0]
}
}
// emitCSI writes the buffered CSI sequence to pending, rewriting row
// coordinates for CUP/HVP/VPA/DECSTBM.
func (cs *cursorShifter) emitCSI() {
// cs.buf is ESC [ <params...> <final>. Slice out params + final.
if len(cs.buf) < 3 {
cs.pending.Write(cs.buf)
return
}
final := cs.buf[len(cs.buf)-1]
paramsRaw := cs.buf[2 : len(cs.buf)-1]
// Intermediate bytes can appear before the final (rare). Skip
// rewriting if any are present.
for _, b := range paramsRaw {
if b >= 0x20 && b <= 0x2f {
cs.pending.Write(cs.buf)
return
}
}
switch final {
case 'H', 'f':
// CUP/HVP: r;c (both default 1).
r, c, ok := parseTwoParams(paramsRaw)
if !ok {
cs.pending.Write(cs.buf)
return
}
r += cs.rowOffset
cs.pending.WriteString("\x1b[")
cs.pending.WriteString(strconv.Itoa(r))
cs.pending.WriteByte(';')
cs.pending.WriteString(strconv.Itoa(c))
cs.pending.WriteByte(final)
case 'd':
// VPA: row.
r, ok := parseOneParam(paramsRaw, 1)
if !ok {
cs.pending.Write(cs.buf)
return
}
r += cs.rowOffset
cs.pending.WriteString("\x1b[")
cs.pending.WriteString(strconv.Itoa(r))
cs.pending.WriteByte(final)
case 'r':
// DECSTBM: top;bot. Empty resets to full region; we still
// shift to keep the chrome row reserved.
top, bot, ok := parseTwoParams(paramsRaw)
if !ok {
cs.pending.Write(cs.buf)
return
}
top += cs.rowOffset
bot += cs.rowOffset
cs.pending.WriteString("\x1b[")
cs.pending.WriteString(strconv.Itoa(top))
cs.pending.WriteByte(';')
cs.pending.WriteString(strconv.Itoa(bot))
cs.pending.WriteByte(final)
default:
cs.pending.Write(cs.buf)
}
}
func isCSIFinal(b byte) bool { return b >= 0x40 && b <= 0x7e }
func parseTwoParams(raw []byte) (int, int, bool) {
parts := strings.Split(string(raw), ";")
if len(parts) > 2 {
return 0, 0, false
}
a := 1
b := 1
if len(parts) >= 1 && parts[0] != "" {
n, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, false
}
a = n
}
if len(parts) >= 2 && parts[1] != "" {
n, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, false
}
b = n
}
return a, b, true
}
func parseOneParam(raw []byte, def int) (int, bool) {
s := string(raw)
if s == "" {
return def, true
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, false
}
return n, true
}

View File

@@ -0,0 +1,77 @@
package app
import (
"bytes"
"testing"
)
func TestCursorShifterCUP(t *testing.T) {
cs := newCursorShifter(1)
got := cs.Shift([]byte("\x1b[H"))
want := []byte("\x1b[2;1H")
if !bytes.Equal(got, want) {
t.Fatalf("CUP home: got %q want %q", got, want)
}
}
func TestCursorShifterCUPRowCol(t *testing.T) {
cs := newCursorShifter(1)
got := cs.Shift([]byte("\x1b[10;5H"))
if string(got) != "\x1b[11;5H" {
t.Fatalf("CUP 10;5: got %q", got)
}
}
func TestCursorShifterVPA(t *testing.T) {
cs := newCursorShifter(1)
got := cs.Shift([]byte("\x1b[7d"))
if string(got) != "\x1b[8d" {
t.Fatalf("VPA 7: got %q", got)
}
}
func TestCursorShifterDECSTBM(t *testing.T) {
cs := newCursorShifter(1)
got := cs.Shift([]byte("\x1b[2;20r"))
if string(got) != "\x1b[3;21r" {
t.Fatalf("DECSTBM: got %q", got)
}
}
func TestCursorShifterPrivateCSIPassthrough(t *testing.T) {
cs := newCursorShifter(1)
// Alt-screen toggle — private CSI.
got := cs.Shift([]byte("\x1b[?1049h"))
if string(got) != "\x1b[?1049h" {
t.Fatalf("alt-screen: got %q", got)
}
}
func TestCursorShifterSGRPassthrough(t *testing.T) {
cs := newCursorShifter(1)
got := cs.Shift([]byte("\x1b[1;31mhello\x1b[0m"))
if string(got) != "\x1b[1;31mhello\x1b[0m" {
t.Fatalf("SGR: got %q", got)
}
}
func TestCursorShifterStraddleChunks(t *testing.T) {
cs := newCursorShifter(1)
a := cs.Shift([]byte("\x1b["))
b := cs.Shift([]byte("5;3H"))
got := string(a) + string(b)
if got != "\x1b[6;3H" {
t.Fatalf("straddle: got %q", got)
}
}
func TestCursorShifterOSCNotRewritten(t *testing.T) {
cs := newCursorShifter(1)
// OSC body containing what looks like a CSI cursor move — should
// NOT be rewritten.
in := []byte("\x1b]0;\x1b[5;3Htitle\x07")
got := cs.Shift(in)
if string(got) != string(in) {
t.Fatalf("OSC: got %q want %q", got, in)
}
}

335
internal/app/host.go Normal file
View File

@@ -0,0 +1,335 @@
package app
import (
"fmt"
"regexp"
"strings"
"sync"
"syscall"
"time"
"github.com/harrybrwn/patterm/internal/mcp"
"github.com/harrybrwn/patterm/internal/policy"
"github.com/harrybrwn/patterm/internal/preset"
"github.com/harrybrwn/patterm/internal/scratchpad"
)
// attentionSink is implemented by uiState to surface
// request_human_attention notifications.
type attentionSink interface {
notifyAttention(childID, reason string)
}
// toolHost adapts the running session + scratchpad store to the MCP
// ToolHost interface. SPEC §7 tools route through here.
type toolHost struct {
sess *Session
pads *scratchpad.Store
launcher *Launcher
presets preset.Set
policy *policy.Policy
sizeMu sync.Mutex
defaultRow uint16
defaultCol uint16
attention attentionSink
// timersMu guards timers.
timersMu sync.Mutex
nextTimer int
}
func (h *toolHost) SetSize(cols, rows uint16) {
h.sizeMu.Lock()
defer h.sizeMu.Unlock()
h.defaultCol = cols
h.defaultRow = rows
}
func (h *toolHost) size() (uint16, uint16) {
h.sizeMu.Lock()
defer h.sizeMu.Unlock()
return h.defaultCol, h.defaultRow
}
func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, presets preset.Set, pol *policy.Policy, cols, rows uint16) *toolHost {
return &toolHost{
sess: sess,
pads: pads,
launcher: launcher,
presets: presets,
policy: pol,
defaultCol: cols,
defaultRow: rows,
}
}
// PolicyCheck — SPEC §9 hook. Lets an orchestrator ask whether a
// prompt-looking string is safe to auto-answer.
func (h *toolHost) PolicyCheck(prompt string) string {
if h.policy == nil {
return string(policy.Unknown)
}
return string(h.policy.Should(prompt))
}
// Children — SPEC §7 list_children. The idle_ms field gives the
// orchestrator the SPEC §11 done-signal without needing to poll bytes.
func (h *toolHost) Children() []mcp.ChildInfo {
cs := h.sess.Children()
out := make([]mcp.ChildInfo, 0, len(cs))
for _, c := range cs {
out = append(out, mcp.ChildInfo{
ID: c.ID,
Name: c.Name,
Type: string(c.Kind),
Status: string(c.Status()),
ExitCode: c.ExitCode(),
IdleMS: c.IdleMS(),
ParentID: c.ParentID,
})
}
return out
}
func (h *toolHost) Spawn(callerID, name string, argv []string, shell bool) (mcp.ChildInfo, error) {
if shell && len(argv) > 0 {
argv = []string{"sh", "-lc", strings.Join(argv, " ")}
}
parent := callerID
cols, rows := h.size()
c, err := h.sess.Spawn(name, KindProcess, argv, nil, cols, rows, parent)
if err != nil {
return mcp.ChildInfo{}, err
}
return mcp.ChildInfo{
ID: c.ID,
Name: c.Name,
Type: string(c.Kind),
Status: string(c.Status()),
ParentID: c.ParentID,
}, nil
}
func (h *toolHost) SpawnAgent(callerID, presetName, displayName, initialPrompt string) (mcp.ChildInfo, error) {
var p *preset.Preset
for _, ap := range h.presets.Agents {
if ap.Name == presetName {
p = ap
break
}
}
if p == nil {
return mcp.ChildInfo{}, fmt.Errorf("unknown agent preset %q", presetName)
}
if displayName == "" {
displayName = presetName
}
c, err := h.launcher.LaunchAgent(p, displayName, initialPrompt, callerID)
if err != nil {
return mcp.ChildInfo{}, err
}
return mcp.ChildInfo{
ID: c.ID,
Name: c.Name,
Type: string(c.Kind),
Status: string(c.Status()),
ParentID: c.ParentID,
}, nil
}
// ReadOutput — SPEC §7. Grid uses the emulator's PlainText; stream uses
// the per-child ring buffer. For grid reads on agents we apply the
// preset's chrome_trim_hints (SPEC §10) so banners/input-box noise
// doesn't pollute orchestrator parsing.
func (h *toolHost) ReadOutput(callerID, childID, mode string, sinceOffset int) (string, int, error) {
c := h.sess.FindChild(childID)
if c == nil {
return "", 0, fmt.Errorf("no such child %q", childID)
}
switch mode {
case "grid":
txt, err := c.em.PlainText()
if err != nil {
return "", 0, err
}
if c.Kind == KindAgent {
txt = applyChromeTrim(txt, h.chromeHintsFor(c.Name))
}
return txt, 0, nil
case "stream":
b, off := c.StreamRead(int64(sinceOffset))
return string(b), int(off), nil
default:
return "", 0, fmt.Errorf("unknown read_output mode %q", mode)
}
}
func (h *toolHost) chromeHintsFor(presetName string) []string {
for _, p := range h.presets.Agents {
if p.Name == presetName {
return p.ChromeTrimHints
}
}
return nil
}
// applyChromeTrim deletes every line that matches any of the given
// regexes. Compiled regexes are cached per call; the agent preset list
// is small enough that recompilation cost is negligible.
func applyChromeTrim(txt string, hints []string) string {
if len(hints) == 0 {
return txt
}
res := make([]*regexp.Regexp, 0, len(hints))
for _, h := range hints {
re, err := regexp.Compile(h)
if err != nil {
continue
}
res = append(res, re)
}
if len(res) == 0 {
return txt
}
out := make([]string, 0, 64)
for _, line := range strings.Split(txt, "\n") {
drop := false
for _, re := range res {
if re.MatchString(line) {
drop = true
break
}
}
if !drop {
out = append(out, line)
}
}
return strings.Join(out, "\n")
}
func (h *toolHost) SendInput(callerID, childID string, payload []byte, appendNewline bool) error {
if appendNewline {
payload = append(payload, '\n')
}
c := h.sess.FindChild(childID)
if c == nil {
return fmt.Errorf("no such child %q", childID)
}
if c.Status() != StatusRunning {
return fmt.Errorf("child %q is %s", childID, c.Status())
}
return c.InjectAsOrchestrator(payload)
}
func (h *toolHost) Kill(callerID, childID string, sig syscall.Signal) error {
return h.sess.Kill(childID, sig)
}
// SendMessageTo — SPEC §7 + §8. Injects "[orchestrator] <msg>\n" into
// the target's PTY.
func (h *toolHost) SendMessageTo(callerID, targetID, message string) error {
target := h.sess.FindChild(targetID)
if target == nil {
return fmt.Errorf("no such child %q", targetID)
}
line := "[orchestrator] " + message + "\n"
return target.InjectAsOrchestrator([]byte(line))
}
// ReportToParent — SPEC §8. Injects "[sub-agent:<name>] <msg>\n" into
// the calling agent's parent pane.
func (h *toolHost) ReportToParent(callerID, message string) error {
caller := h.sess.FindChild(callerID)
if caller == nil {
return fmt.Errorf("caller %q not known to patterm", callerID)
}
if caller.ParentID == "" {
return fmt.Errorf("caller %q has no parent", callerID)
}
parent := h.sess.FindChild(caller.ParentID)
if parent == nil {
return fmt.Errorf("parent %q gone", caller.ParentID)
}
line := fmt.Sprintf("[sub-agent:%s] %s\n", caller.Name, message)
return parent.InjectAsOrchestrator([]byte(line))
}
// TimerWait — SPEC §7. Returns immediately with a timer_id. After
// seconds elapse, injects "[system] Your timer [<label>] has completed.\n"
// into the calling agent's pane.
func (h *toolHost) TimerWait(callerID string, seconds float64, label string) (string, error) {
caller := h.sess.FindChild(callerID)
if caller == nil {
return "", fmt.Errorf("caller %q not known to patterm", callerID)
}
h.timersMu.Lock()
h.nextTimer++
id := fmt.Sprintf("t%d", h.nextTimer)
h.timersMu.Unlock()
if label == "" {
label = id
}
go func() {
time.Sleep(time.Duration(seconds * float64(time.Second)))
if caller.Status() != StatusRunning {
return
}
line := fmt.Sprintf("[system] Your timer [%s] has completed.\n", label)
_ = caller.InjectAsOrchestrator([]byte(line))
}()
return id, nil
}
// WaitForPattern — SPEC §7. Polls the child's plain-text grid at ~50ms
// until the regex matches or the timeout expires.
func (h *toolHost) WaitForPattern(callerID, childID, pattern string, timeoutSeconds float64) (bool, string, error) {
c := h.sess.FindChild(childID)
if c == nil {
return false, "", fmt.Errorf("no such child %q", childID)
}
re, err := regexp.Compile(pattern)
if err != nil {
return false, "", fmt.Errorf("regex: %w", err)
}
deadline := time.Now().Add(time.Duration(timeoutSeconds * float64(time.Second)))
tick := time.NewTicker(50 * time.Millisecond)
defer tick.Stop()
for {
txt, err := c.em.PlainText()
if err == nil {
if m := re.FindString(txt); m != "" {
return true, m, nil
}
}
if time.Now().After(deadline) {
return false, "", nil
}
<-tick.C
if c.Status() != StatusRunning {
return false, "", nil
}
}
}
func (h *toolHost) RequestHumanAttention(callerID, childID, reason string) error {
if h.attention != nil {
h.attention.notifyAttention(childID, reason)
}
return nil
}
func (h *toolHost) Scratchpads() *scratchpad.Store {
return h.pads
}
// ResolveCallerIdentity maps the per-spawn identity token back to a
// child ID so the tools above can use it as a parent pointer / inject
// target.
func (h *toolHost) ResolveCallerIdentity(identity string) string {
c := h.sess.FindChildByIdentity(identity)
if c == nil {
return ""
}
return c.ID
}

179
internal/app/launch.go Normal file
View File

@@ -0,0 +1,179 @@
package app
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/harrybrwn/patterm/internal/preset"
)
// Launcher knows how to turn a preset into a running child. Both the
// palette and the MCP spawn_agent tool route through here so MCP
// injection happens consistently. SPEC §10.
type Launcher struct {
sess *Session
mcpSocket string
bin string // path to this binary (for the mcp-stdio subcommand)
sizeMu sync.Mutex
cols, rows uint16
}
func NewLauncher(sess *Session, mcpSocket string, cols, rows uint16) *Launcher {
bin, err := os.Executable()
if err != nil {
bin = "patterm"
}
return &Launcher{sess: sess, mcpSocket: mcpSocket, bin: bin, cols: cols, rows: rows}
}
func (l *Launcher) SetSize(cols, rows uint16) {
l.sizeMu.Lock()
defer l.sizeMu.Unlock()
l.cols, l.rows = cols, rows
}
func (l *Launcher) size() (uint16, uint16) {
l.sizeMu.Lock()
defer l.sizeMu.Unlock()
return l.cols, l.rows
}
// LaunchAgent spawns the agent preset, applies the preset's MCP
// injection, waits for the ready signal, and types initial_prompt into
// the PTY. SPEC §7 spawn_agent, §8 conversation protocol.
func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, parentID string) (*Child, error) {
if p.Kind != preset.KindAgent {
return nil, fmt.Errorf("launch: %q is not an agent preset", p.Name)
}
argv := append([]string(nil), p.Argv...)
env := l.sess.ChildEnv()
for k, v := range p.Env {
env = append(env, k+"="+v)
}
// Mint a per-spawn MCP config file pointing at the mcp-stdio proxy
// with the new child's identity. We don't know the identity until
// we've created the child, but the child needs the env/argv at
// creation time — so we reserve the identity by pre-creating the
// MCP config with a placeholder, then patching it post-spawn.
identity, mcpConfigPath, err := l.writeMCPConfig()
if err != nil {
return nil, err
}
if p.MCPInjection != nil {
switch p.MCPInjection.Kind {
case "flag":
if p.MCPInjection.Flag == "" {
return nil, fmt.Errorf("preset %s: mcp_injection.flag required for kind=flag", p.Name)
}
argv = append(argv, p.MCPInjection.Flag, mcpConfigPath)
case "env_var":
if p.MCPInjection.Var == "" {
return nil, fmt.Errorf("preset %s: mcp_injection.var required for kind=env_var", p.Name)
}
env = append(env, p.MCPInjection.Var+"="+mcpConfigPath)
case "config_file":
// SPEC §10 mentions merging into an external config file. We
// expose the config_path via an env var the user can read
// at preset-creation time; full merge is deferred.
env = append(env, "PATTERM_MCP_CONFIG="+mcpConfigPath)
default:
return nil, fmt.Errorf("preset %s: unknown mcp_injection.kind %q", p.Name, p.MCPInjection.Kind)
}
}
// Spawn with the chosen identity.
cols, rows := l.size()
c, err := l.sess.spawnWithIdentity(displayName, KindAgent, argv, env, cols, rows, parentID, identity)
if err != nil {
_ = os.Remove(mcpConfigPath)
return nil, err
}
// Wait for the preset's ready signal, then type the initial prompt.
idle := time.Duration(1000) * time.Millisecond
if p.ReadySignal != nil && p.ReadySignal.IdleMS > 0 {
idle = time.Duration(p.ReadySignal.IdleMS) * time.Millisecond
}
go func() {
waitForIdle(c, idle, 30*time.Second)
if initialPrompt == "" {
return
}
_ = c.InjectAsOrchestrator([]byte(initialPrompt + "\n"))
}()
return c, nil
}
// LaunchProcess spawns a process preset. No MCP injection; just argv.
func (l *Launcher) LaunchProcess(p *preset.Preset, displayName string) (*Child, error) {
if p.Kind != preset.KindProcess {
return nil, fmt.Errorf("launch: %q is not a process preset", p.Name)
}
env := l.sess.ChildEnv()
for k, v := range p.Env {
env = append(env, k+"="+v)
}
cols, rows := l.size()
return l.sess.Spawn(displayName, KindProcess, p.ResolvedArgv(), env, cols, rows, "")
}
func (l *Launcher) writeMCPConfig() (identity, path string, err error) {
identity = mintIdentity()
dir, err := preset.ConfigDir()
if err != nil {
return "", "", err
}
dir = filepath.Join(dir, "mcp")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", "", err
}
path = filepath.Join(dir, identity+".json")
cfg := map[string]any{
"mcpServers": map[string]any{
"patterm": map[string]any{
"command": l.bin,
"args": []string{"mcp-stdio", "--socket", l.mcpSocket, "--identity", identity},
},
},
}
body, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return "", "", err
}
body = append(body, '\n')
if err := os.WriteFile(path, body, 0o600); err != nil {
return "", "", err
}
return identity, path, nil
}
// waitForIdle polls the child's IdleMS until it exceeds idle, or until
// max elapses.
func waitForIdle(c *Child, idle, max time.Duration) {
deadline := time.Now().Add(max)
tick := time.NewTicker(100 * time.Millisecond)
defer tick.Stop()
for {
<-tick.C
if c.Status() != StatusRunning {
return
}
if c.IdleMS() >= idle.Milliseconds() && c.lastWriteNS.Load() != 0 {
return
}
if time.Now().After(deadline) {
return
}
}
}
// joinArgs flattens an argv slice into a single line (used for display
// hints).
func joinArgs(argv []string) string { return strings.Join(argv, " ") }

73
internal/app/layout.go Normal file
View File

@@ -0,0 +1,73 @@
package app
// terminalLayout is the single source of truth for host chrome and the
// child PTY viewport.
type terminalLayout struct {
hostCols uint16
hostRows uint16
mainLeft uint16
mainTop uint16
mainCols uint16
mainRows uint16
sidebarVisible bool
sidebarLeft uint16
sidebarWidth uint16
statusRow uint16
}
func newTerminalLayout(cols, rows uint16) terminalLayout {
if cols == 0 {
cols = 1
}
if rows == 0 {
rows = 1
}
l := terminalLayout{
hostCols: cols,
hostRows: rows,
mainLeft: 1,
mainTop: tabBarRows + 1,
mainCols: cols,
mainRows: 1,
statusRow: rows,
}
if int(cols) > sidebarCols+10 {
l.sidebarVisible = true
l.sidebarWidth = sidebarCols
l.sidebarLeft = cols - sidebarCols + 1
l.mainCols = cols - sidebarCols
}
reservedRows := tabBarRows + statusRows
if int(rows) > reservedRows {
l.mainRows = rows - uint16(reservedRows)
}
return l
}
func (l terminalLayout) childCols() uint16 {
if l.mainCols == 0 {
return 1
}
return l.mainCols
}
func (l terminalLayout) childRows() uint16 {
if l.mainRows == 0 {
return 1
}
return l.mainRows
}
func ptyRows(hostRows uint16) uint16 {
return newTerminalLayout(1, hostRows).childRows()
}
func ptyCols(hostCols uint16) uint16 {
return newTerminalLayout(hostCols, 1).childCols()
}

View File

@@ -0,0 +1,58 @@
package app
import (
"testing"
"github.com/harrybrwn/patterm/internal/preset"
)
func TestTerminalLayoutWideUsesMainViewport(t *testing.T) {
l := newTerminalLayout(120, 40)
if !l.sidebarVisible {
t.Fatal("wide layout should show sidebar")
}
if l.childCols() != 92 {
t.Fatalf("child cols: got %d want 92", l.childCols())
}
if l.childRows() != 38 {
t.Fatalf("child rows: got %d want 38", l.childRows())
}
if l.mainTop != 2 || l.statusRow != 40 {
t.Fatalf("unexpected vertical chrome: mainTop=%d statusRow=%d", l.mainTop, l.statusRow)
}
}
func TestTerminalLayoutNarrowHidesSidebar(t *testing.T) {
l := newTerminalLayout(38, 12)
if l.sidebarVisible {
t.Fatal("narrow layout should hide sidebar")
}
if l.childCols() != 38 {
t.Fatalf("child cols: got %d want 38", l.childCols())
}
if l.childRows() != 10 {
t.Fatalf("child rows: got %d want 10", l.childRows())
}
}
func TestTerminalLayoutTinyClampsChildSize(t *testing.T) {
l := newTerminalLayout(0, 1)
if l.childCols() != 1 || l.childRows() != 1 {
t.Fatalf("child size: got %dx%d want 1x1", l.childCols(), l.childRows())
}
}
func TestSpawnSizingUsesViewportDimensions(t *testing.T) {
l := newTerminalLayout(120, 40)
launcher := NewLauncher(nil, "", l.childCols(), l.childRows())
cols, rows := launcher.size()
if cols != 92 || rows != 38 {
t.Fatalf("launcher size: got %dx%d want 92x38", cols, rows)
}
host := newToolHost(nil, nil, nil, preset.Set{}, nil, l.childCols(), l.childRows())
cols, rows = host.size()
if cols != 92 || rows != 38 {
t.Fatalf("tool host size: got %dx%d want 92x38", cols, rows)
}
}

332
internal/app/palette.go Normal file
View File

@@ -0,0 +1,332 @@
package app
import (
"fmt"
"strings"
"unicode/utf8"
"github.com/harrybrwn/patterm/internal/preset"
)
// paletteAction is what the palette returns when the user picks an item.
type paletteAction struct {
// kind: "spawn-agent" | "spawn-process" | "switch" | "kill" | "quit" | "cancel"
kind string
// For spawn-*, the preset to launch.
preset *preset.Preset
// For "switch" and "kill", the target child id.
childID string
}
type paletteItem struct {
label string
hint string
action paletteAction
}
// paletteState is the in-memory model for the overlay. SPEC §4: a
// single fuzzy-searchable list of commands scoped to the current focus.
type paletteState struct {
query []rune
cursor int
children []*Child
focused string
presets preset.Set
items []paletteItem
}
func newPalette(children []*Child, focused string, presets preset.Set) *paletteState {
p := &paletteState{children: children, focused: focused, presets: presets}
p.rebuild()
return p
}
func (p *paletteState) rebuild() {
all := p.allItems()
q := strings.ToLower(string(p.query))
if q == "" {
p.items = all
} else {
p.items = p.items[:0]
for _, it := range all {
if fuzzyMatch(strings.ToLower(it.label+" "+it.hint), q) {
p.items = append(p.items, it)
}
}
}
if p.cursor >= len(p.items) {
p.cursor = len(p.items) - 1
}
if p.cursor < 0 {
p.cursor = 0
}
}
func (p *paletteState) allItems() []paletteItem {
var out []paletteItem
// Preset commands first — SPEC §4 calls these out as the primary
// way to spawn anything. One entry per file under presets/.
for _, pr := range p.presets.Agents {
out = append(out, paletteItem{
label: "Spawn agent: " + pr.Name,
hint: strings.Join(pr.Argv, " "),
action: paletteAction{kind: "spawn-agent", preset: pr},
})
}
for _, pr := range p.presets.Processes {
out = append(out, paletteItem{
label: "Run process: " + pr.Name,
hint: strings.Join(pr.Argv, " "),
action: paletteAction{kind: "spawn-process", preset: pr},
})
}
// Switch / Kill entries — one per existing child.
for _, c := range p.children {
label := "Switch to " + c.Name
hint := strings.Join(c.Argv, " ")
if c.ID == p.focused {
label = "• " + label + " (current)"
}
if c.Status() != StatusRunning {
label = label + " [" + string(c.Status()) + "]"
}
out = append(out, paletteItem{
label: label,
hint: hint,
action: paletteAction{kind: "switch", childID: c.ID},
})
}
for _, c := range p.children {
if c.Status() != StatusRunning {
continue
}
out = append(out, paletteItem{
label: "Kill " + c.Name,
hint: "SIGTERM " + strings.Join(c.Argv, " "),
action: paletteAction{kind: "kill", childID: c.ID},
})
}
out = append(out, paletteItem{
label: "Quit",
hint: "exit patterm; SIGTERM every child",
action: paletteAction{kind: "quit"},
})
return out
}
func fuzzyMatch(hay, needle string) bool {
if needle == "" {
return true
}
hi := 0
for _, r := range needle {
idx := strings.IndexRune(hay[hi:], r)
if idx < 0 {
return false
}
hi += idx + utf8.RuneLen(r)
}
return true
}
func (p *paletteState) handleKey(b byte, peek []byte) (paletteAction, bool) {
if b == 0x1b {
// Pure Esc cancels; Esc [ A/B is up/down arrow.
if len(peek) >= 2 && peek[0] == '[' {
switch peek[1] {
case 'A':
p.cursor--
if p.cursor < 0 {
p.cursor = 0
}
return paletteAction{}, false
case 'B':
p.cursor++
if p.cursor >= len(p.items) {
p.cursor = len(p.items) - 1
}
return paletteAction{}, false
}
}
return paletteAction{kind: "cancel"}, true
}
switch b {
case '\r', '\n':
if p.cursor >= 0 && p.cursor < len(p.items) {
return p.items[p.cursor].action, true
}
return paletteAction{kind: "cancel"}, true
case 0x7f, 0x08:
if len(p.query) > 0 {
p.query = p.query[:len(p.query)-1]
p.rebuild()
}
case 0x15: // Ctrl-U
p.query = p.query[:0]
p.rebuild()
case 0x0e: // Ctrl-N
p.cursor++
if p.cursor >= len(p.items) {
p.cursor = len(p.items) - 1
}
case 0x10: // Ctrl-P inside palette: cursor up.
p.cursor--
if p.cursor < 0 {
p.cursor = 0
}
case 0x0b: // Ctrl-K inside palette is a no-op (would re-open); ignore.
case 0x16: // Ctrl-V literal-paste — ignore in palette.
default:
if b >= 0x20 && b < 0x7f {
p.query = append(p.query, rune(b))
p.rebuild()
}
}
return paletteAction{}, false
}
// render draws the palette onto out. Geometry: title bar + filter line +
// items + footer, centred. The caller is responsible for the screen
// clear before the first render.
func (p *paletteState) render(out writeFlusher, cols, rows int) {
if cols < 20 {
cols = 20
}
if rows < 6 {
rows = 6
}
width := cols - 4
if width > 80 {
width = 80
}
if width < 40 {
width = cols - 2
}
leftPad := (cols - width) / 2
if leftPad < 1 {
leftPad = 1
}
row := 2
var b strings.Builder
b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J")
moveTo(&b, row, leftPad)
b.WriteString("\x1b[1;7m")
b.WriteString(padRight(" patterm — Ctrl-K", width))
b.WriteString("\x1b[0m")
row++
moveTo(&b, row, leftPad)
b.WriteString("\x1b[7m")
b.WriteString(padRight(" "+string(p.query)+"_", width))
b.WriteString("\x1b[0m")
row++
maxItems := rows - 6
if maxItems > 12 {
maxItems = 12
}
if maxItems < 1 {
maxItems = 1
}
start := 0
if p.cursor >= maxItems {
start = p.cursor - maxItems + 1
}
end := start + maxItems
if end > len(p.items) {
end = len(p.items)
}
for i := start; i < end; i++ {
it := p.items[i]
moveTo(&b, row, leftPad)
if i == p.cursor {
b.WriteString("\x1b[7m")
} else {
b.WriteString("\x1b[0m")
}
line := " " + it.label
if it.hint != "" {
line += " \x1b[2m— " + it.hint + "\x1b[0m"
if i == p.cursor {
line += "\x1b[7m"
}
}
b.WriteString(padRight(line, width+countAnsi(line)))
b.WriteString("\x1b[0m")
row++
}
if len(p.items) == 0 {
moveTo(&b, row, leftPad)
b.WriteString("\x1b[2m no matches\x1b[0m")
row++
}
moveTo(&b, row, leftPad)
b.WriteString("\x1b[2m")
b.WriteString(padRight(" Enter to run · Esc to close · ↑↓ to navigate", width))
b.WriteString("\x1b[0m")
moveTo(&b, 3, leftPad+4+utf8.RuneCountInString(string(p.query)))
b.WriteString("\x1b[?25h")
_, _ = out.Write([]byte(b.String()))
_ = out.Flush()
}
type writeFlusher interface {
Write(p []byte) (int, error)
Flush() error
}
type writeFlusherBase interface {
Write(p []byte) (int, error)
}
type nopFlusher struct{ io writeFlusherBase }
func wrapWriter(w writeFlusherBase) writeFlusher { return nopFlusher{io: w} }
func (n nopFlusher) Write(p []byte) (int, error) { return n.io.Write(p) }
func (n nopFlusher) Flush() error { return nil }
func moveTo(b *strings.Builder, row, col int) {
fmt.Fprintf(b, "\x1b[%d;%dH", row, col)
}
func padRight(s string, width int) string {
w := width - visibleLen(s)
if w <= 0 {
return s
}
return s + strings.Repeat(" ", w)
}
func visibleLen(s string) int {
n := 0
in := false
for _, r := range s {
if r == 0x1b {
in = true
continue
}
if in {
if r == 'm' || r == 'H' {
in = false
}
continue
}
n++
}
return n
}
func countAnsi(s string) int {
return len(s) - visibleLen(s)
}

View File

@@ -0,0 +1,86 @@
package app
import (
"fmt"
"strings"
"github.com/harrybrwn/patterm/internal/vt"
)
func renderScreenSnapshot(text string, cursor vt.CursorState, layout terminalLayout) []byte {
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
cols := int(layout.childCols())
rows := int(layout.childRows())
var b strings.Builder
b.WriteString("\x1b[?25l")
for r := 0; r < rows; r++ {
line := ""
if r < len(lines) {
line = truncateCells(lines[r], cols)
}
fmt.Fprintf(&b, "\x1b[%d;%dH%s", int(layout.mainTop)+r, int(layout.mainLeft), padRight(line, cols))
}
if cursor.Visible {
row := int(layout.mainTop) + int(cursor.Row)
col := int(layout.mainLeft) + int(cursor.Col)
if row < int(layout.mainTop) {
row = int(layout.mainTop)
}
maxRow := int(layout.mainTop) + rows - 1
if row > maxRow {
row = maxRow
}
if col < int(layout.mainLeft) {
col = int(layout.mainLeft)
}
maxCol := int(layout.mainLeft) + cols - 1
if col > maxCol {
col = maxCol
}
fmt.Fprintf(&b, "\x1b[?25h\x1b[%d;%dH", row, col)
}
return []byte(b.String())
}
func renderCursor(cursor vt.CursorState, layout terminalLayout) []byte {
cols := int(layout.childCols())
rows := int(layout.childRows())
row := int(layout.mainTop) + int(cursor.Row)
col := int(layout.mainLeft) + int(cursor.Col)
if row < int(layout.mainTop) {
row = int(layout.mainTop)
}
maxRow := int(layout.mainTop) + rows - 1
if row > maxRow {
row = maxRow
}
if col < int(layout.mainLeft) {
col = int(layout.mainLeft)
}
maxCol := int(layout.mainLeft) + cols - 1
if col > maxCol {
col = maxCol
}
return []byte(fmt.Sprintf("\x1b[?25h\x1b[%d;%dH", row, col))
}
func truncateCells(s string, width int) string {
if width <= 0 {
return ""
}
if visibleLen(s) <= width {
return s
}
var b strings.Builder
n := 0
for _, r := range s {
if n >= width {
break
}
b.WriteRune(r)
n++
}
return b.String()
}

View File

@@ -0,0 +1,33 @@
package app
import (
"strings"
"testing"
"github.com/harrybrwn/patterm/internal/vt"
)
func TestRenderScreenSnapshotClipsRowsToViewport(t *testing.T) {
layout := newTerminalLayout(20, 5)
got := string(renderScreenSnapshot("abcdefghijklmnopqrstuvwxy\nsecond", vt.CursorState{}, layout))
if strings.Contains(got, "uvwxy") {
t.Fatalf("line leaked past viewport width: %q", got)
}
if !strings.Contains(got, "\x1b[2;1Habcdefghijklmnopqrst") {
t.Fatalf("first row not drawn at viewport top: %q", got)
}
if !strings.Contains(got, "\x1b[3;1Hsecond ") {
t.Fatalf("second row not padded in viewport: %q", got)
}
if !strings.Contains(got, "\x1b[4;1H ") {
t.Fatalf("blank viewport row not cleared: %q", got)
}
}
func TestRenderScreenSnapshotPlacesCursorInsideViewport(t *testing.T) {
layout := newTerminalLayout(20, 5)
got := string(renderScreenSnapshot("abc", vt.CursorState{Col: 2, Row: 1, Visible: true}, layout))
if !strings.HasSuffix(got, "\x1b[?25h\x1b[3;3H") {
t.Fatalf("cursor not placed inside viewport: %q", got)
}
}

315
internal/app/session.go Normal file
View File

@@ -0,0 +1,315 @@
// Package app is patterm's single foreground process. It owns the TUI,
// every PTY, every emulator, the in-process MCP server, and the
// scratchpad/preset state.
//
// There is no daemon, no detach, no socket-based client/daemon split
// (SPEC §2). One process owns everything; closing the terminal window
// ends the session and tears down every child.
package app
import (
"errors"
"fmt"
"os"
"sync"
"sync/atomic"
"syscall"
"github.com/harrybrwn/patterm/internal/vt"
)
// Session is the in-memory state for the running patterm process.
// In SPEC §4 terms each top-level tab is a session; v1 ships with a
// single implicit session and reserves room to grow.
type Session struct {
projectDir string
projectKey string
mu sync.Mutex
children map[string]*Child
order []string
nextChildSeq atomic.Int64
// listeners is the set of UI listeners that want to hear about child
// lifecycle events (spawn/exit) — exactly one (the TUI) in v1.
listenersMu sync.Mutex
listeners []ChildEventListener
}
// ChildEventListener is implemented by the TUI to react to lifecycle
// events without polling.
type ChildEventListener interface {
OnChildSpawned(*Child)
OnChildExited(*Child)
// OnPTYOut is called for every chunk the child writes to its PTY.
// Only the focused-child chunk should reach the screen — the TUI
// filters by id.
OnPTYOut(childID string, chunk []byte)
}
func NewSession(projectDir, projectKey string) *Session {
return &Session{
projectDir: projectDir,
projectKey: projectKey,
children: make(map[string]*Child),
}
}
func (s *Session) Subscribe(l ChildEventListener) {
s.listenersMu.Lock()
defer s.listenersMu.Unlock()
s.listeners = append(s.listeners, l)
}
func (s *Session) emitSpawn(c *Child) {
s.listenersMu.Lock()
ls := append([]ChildEventListener(nil), s.listeners...)
s.listenersMu.Unlock()
for _, l := range ls {
l.OnChildSpawned(c)
}
}
func (s *Session) emitExit(c *Child) {
s.listenersMu.Lock()
ls := append([]ChildEventListener(nil), s.listeners...)
s.listenersMu.Unlock()
for _, l := range ls {
l.OnChildExited(c)
}
}
func (s *Session) emitPTYOut(id string, chunk []byte) {
s.listenersMu.Lock()
ls := append([]ChildEventListener(nil), s.listeners...)
s.listenersMu.Unlock()
for _, l := range ls {
l.OnPTYOut(id, chunk)
}
}
func (s *Session) ChildEnv() []string {
env := os.Environ()
// Mark patterm-owned PTYs so a recursive `patterm` invocation can
// detect it and degrade. The MCP socket is per-PID and lives under
// $XDG_RUNTIME_DIR — see internal/mcp.
env = append(env,
"PATTERM=1",
"PATTERM_PROJECT_KEY="+s.projectKey,
"PATTERM_PROJECT_DIR="+s.projectDir,
)
return env
}
// Spawn launches a new child with the given argv. kind is "agent" or
// "process". parentID names the calling session/child for orchestrator
// trees ("" for top-level). env is the full child environment; the
// caller is responsible for adding preset-specific overrides.
func (s *Session) Spawn(name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID string) (*Child, error) {
s.mu.Lock()
id := fmt.Sprintf("c%d", s.nextChildSeq.Add(1))
if name == "" {
name = fmt.Sprintf("%s-%s", kind, id)
}
s.mu.Unlock()
if env == nil {
env = s.ChildEnv()
}
c, err := newChild(id, name, kind, argv, env, cols, rows, parentID)
if err != nil {
return nil, err
}
s.mu.Lock()
s.children[id] = c
s.order = append(s.order, id)
s.mu.Unlock()
s.emitSpawn(c)
go s.pumpChild(c)
go s.reapChild(c)
return c, nil
}
// spawnWithIdentity is like Spawn but lets the launcher pre-mint the
// MCP identity so the config file can be written before the process
// starts.
func (s *Session) spawnWithIdentity(name string, kind ChildKind, argv, env []string, cols, rows uint16, parentID, identity string) (*Child, error) {
c, err := s.Spawn(name, kind, argv, env, cols, rows, parentID)
if err != nil {
return nil, err
}
c.Identity = identity
return c, nil
}
func (s *Session) pumpChild(c *Child) {
buf := make([]byte, 64*1024)
for {
n, err := c.pty.Read(buf)
if n > 0 {
chunk := make([]byte, n)
copy(chunk, buf[:n])
if _, werr := c.em.Write(chunk); werr != nil {
logf("emulator.Write(child %s): %v", c.ID, werr)
}
c.recordWrite(chunk)
s.emitPTYOut(c.ID, chunk)
}
if err != nil {
if !errors.Is(err, syscall.EIO) && !errors.Is(err, os.ErrClosed) {
logf("pty read (child %s): %v", c.ID, err)
}
return
}
}
}
func (s *Session) reapChild(c *Child) {
err := c.pty.Wait()
c.markExited(err)
logf("child %s exited (err=%v)", c.ID, err)
s.emitExit(c)
}
// Children returns a snapshot of children in spawn order.
func (s *Session) Children() []*Child {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]*Child, 0, len(s.order))
for _, id := range s.order {
if c, ok := s.children[id]; ok {
out = append(out, c)
}
}
return out
}
// FindChild looks up a child by id; returns nil if not present.
func (s *Session) FindChild(id string) *Child {
s.mu.Lock()
defer s.mu.Unlock()
return s.children[id]
}
// FindChildByIdentity finds the child whose Identity matches token.
// Used by MCP to bind a mcp-stdio greeting to its caller. Returns nil
// if no match.
func (s *Session) FindChildByIdentity(token string) *Child {
if token == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
for _, c := range s.children {
if c.Identity == token {
return c
}
}
return nil
}
// Kill sends a signal (default SIGTERM) to a child by id.
func (s *Session) Kill(id string, sig syscall.Signal) error {
c := s.FindChild(id)
if c == nil {
return fmt.Errorf("no such child %q", id)
}
if sig == 0 {
sig = syscall.SIGTERM
}
return c.signal(sig)
}
// WriteInput pipes bytes to a child's PTY stdin.
func (s *Session) WriteInput(id string, b []byte) error {
c := s.FindChild(id)
if c == nil {
return fmt.Errorf("no such child %q", id)
}
if c.Status() != StatusRunning {
return fmt.Errorf("child %q is %s", id, c.Status())
}
_, err := c.pty.Write(b)
return err
}
// ResizeAll updates every child's PTY + emulator to the same cell grid.
// SPEC §5 says one viewport, no multi-client resize negotiation.
func (s *Session) ResizeAll(cols, rows uint16) {
if cols == 0 || rows == 0 {
return
}
s.mu.Lock()
cs := make([]*Child, 0, len(s.children))
for _, c := range s.children {
cs = append(cs, c)
}
s.mu.Unlock()
for _, c := range cs {
_ = c.pty.Resize(cols, rows)
_ = c.em.Resize(cols, rows)
}
}
// SerializeChild returns the VT bytes that reproduce the child's
// current screen state. Used to repaint a child after the user switches
// focus or closes the palette.
func (s *Session) SerializeChild(id string) ([]byte, error) {
c := s.FindChild(id)
if c == nil {
return nil, fmt.Errorf("no such child %q", id)
}
return c.em.SerializeVT()
}
func (s *Session) SnapshotChild(id string) (string, vt.CursorState, error) {
c := s.FindChild(id)
if c == nil {
return "", vt.CursorState{}, fmt.Errorf("no such child %q", id)
}
text, err := c.em.ScreenText()
if err != nil {
return "", vt.CursorState{}, err
}
cursor, err := c.em.Cursor()
if err != nil {
return "", vt.CursorState{}, err
}
return text, cursor, nil
}
// Shutdown kills every child and waits briefly for them to drain.
// Called on Ctrl-D / SIGTERM / SIGHUP. SPEC §2 step 4.
func (s *Session) Shutdown() {
s.mu.Lock()
cs := make([]*Child, 0, len(s.children))
for _, c := range s.children {
cs = append(cs, c)
}
s.mu.Unlock()
for _, c := range cs {
_ = c.signal(syscall.SIGTERM)
}
// Close emulators and PTY masters. The reaper goroutines will fire
// emitExit as Wait() returns.
for _, c := range cs {
_ = c.pty.Close()
_ = c.em.Close()
}
}
func logf(format string, args ...any) {
if os.Getenv("PATTERM_DEBUG_LOG") == "" {
return
}
f, err := os.OpenFile(os.Getenv("PATTERM_DEBUG_LOG"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return
}
defer f.Close()
fmt.Fprintf(f, "patterm: "+format+"\n", args...)
}

143
internal/app/sidebar.go Normal file
View File

@@ -0,0 +1,143 @@
package app
import (
"fmt"
"os"
"strings"
)
const (
sidebarCols = 28
statusRows = 1
)
// drawSidebar paints the right-rail session tree + scratchpad list.
// SPEC §4: the rail is the active session's child hierarchy on top and
// the scratchpad list (with preview) on the bottom.
//
// Implementation note: the PTY child's winsize is constrained to the
// computed main viewport, so the sidebar region is outside the child's
// cursor range. We can redraw freely without fighting the child for cells.
func (st *uiState) drawSidebar() {
st.mu.Lock()
palOpen := st.palette != nil
focus := st.focusedID
st.mu.Unlock()
if palOpen {
return
}
layout := st.layoutSnapshot()
if !layout.sidebarVisible || layout.hostRows < 4 {
return
}
left := int(layout.sidebarLeft)
width := int(layout.sidebarWidth) - 1
maxRow := int(layout.statusRow) - statusRows
var b strings.Builder
// Border column at left-1: a single vertical pipe.
for r := 1; r <= maxRow; r++ {
fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[2m│\x1b[0m", r, left-1)
}
row := 1
writeLine := func(s string, style string) {
if row > maxRow {
return
}
if len(s) > width {
s = s[:width]
}
fmt.Fprintf(&b, "\x1b[%d;%dH%s%s\x1b[0m\x1b[K", row, left, style, padRight(s, width))
row++
}
writeLine(" Session tree", "\x1b[1m")
writeLine(strings.Repeat("─", width-1), "\x1b[2m")
children := visibleSessionTree(st.sess.Children(), focus)
if len(children) == 0 {
writeLine(" (empty)", "\x1b[2m")
}
for _, c := range children {
glyph := "◉"
marker := " "
if c.ID == focus {
marker = "▶ "
}
indent := ""
if c.ParentID != "" {
indent = " "
}
line := fmt.Sprintf(" %s%s%s %s", marker, indent, glyph, c.Name)
style := ""
if c.ID == focus {
style = "\x1b[1m"
}
writeLine(line, style)
}
// Scratchpads list — pick the most-recently-modified one as the
// preview target. SPEC §4.
var previewName string
if row+2 <= maxRow {
row++
writeLine(" Scratchpads", "\x1b[1m")
writeLine(strings.Repeat("─", width-1), "\x1b[2m")
entries, err := st.pads.List()
if err == nil {
if len(entries) == 0 {
writeLine(" (none)", "\x1b[2m")
}
var newest string
var newestTS string
for _, e := range entries {
if e.ModifiedAt > newestTS {
newestTS = e.ModifiedAt
newest = e.Name
}
}
previewName = newest
for _, e := range entries {
if row > maxRow {
break
}
marker := " "
style := ""
if e.Name == previewName {
marker = " ▸ "
style = "\x1b[1m"
}
writeLine(marker+e.Name, style)
}
}
}
// Preview pane at the bottom of the rail. Reserve up to 8 rows.
if previewName != "" && row+2 <= maxRow {
row++
writeLine(strings.Repeat("─", width-1), "\x1b[2m")
writeLine(" "+previewName, "\x1b[1m")
content, _, err := st.pads.Read(previewName)
if err == nil {
for _, line := range strings.Split(content, "\n") {
if row > maxRow {
break
}
writeLine(" "+line, "\x1b[2m")
}
}
}
// Blank-fill any rows the rail content didn't cover so stale
// content from a previous redraw doesn't linger.
for row <= maxRow {
writeLine("", "")
}
st.outMu.Lock()
// Save cursor; emit the sidebar; restore.
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", b.String())
st.outMu.Unlock()
}

70
internal/app/tabbar.go Normal file
View File

@@ -0,0 +1,70 @@
package app
import (
"fmt"
"os"
"strings"
)
const tabBarRows = 1
// drawTabBar renders SPEC §4's top tab bar at row 1. Tabs are top-level
// children (ParentID == ""); the focused tab is highlighted. The PTY
// region begins at row 2.
func (st *uiState) drawTabBar() {
st.mu.Lock()
palOpen := st.palette != nil
focus := st.focusedID
st.mu.Unlock()
if palOpen {
return
}
layout := st.layoutSnapshot()
width := int(layout.childCols())
var sessions []*Child
for _, c := range st.sess.Children() {
if c.ParentID == "" && c.Status() == StatusRunning {
sessions = append(sessions, c)
}
}
var b strings.Builder
b.WriteString("\x1b[1;1H")
cur := 0
for _, c := range sessions {
label := c.Name
seg := " " + label + " "
if cur+len(seg) > width-2 {
break
}
if c.ID == focus {
b.WriteString("\x1b[7m")
} else {
b.WriteString("\x1b[2m")
}
b.WriteString(seg)
b.WriteString("\x1b[0m")
cur += len(seg)
}
// "+" hint at end.
hint := "+"
if cur > 0 {
hint = " +"
}
if cur+len(hint) <= width {
b.WriteString("\x1b[2m")
b.WriteString(hint)
b.WriteString("\x1b[0m")
cur += len(hint)
}
// Fill the rest of the tab-bar row so stale chars don't linger.
if width-cur > 0 {
b.WriteString(strings.Repeat(" ", width-cur))
}
st.outMu.Lock()
defer st.outMu.Unlock()
// Save cursor, paint, restore.
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", b.String())
}

59
internal/app/tree.go Normal file
View File

@@ -0,0 +1,59 @@
package app
func visibleSessionTree(children []*Child, focusID string) []*Child {
rootID := activeRootID(children, focusID)
if rootID == "" {
return nil
}
out := make([]*Child, 0, len(children))
for _, c := range children {
if c.Status() != StatusRunning {
continue
}
if c.ID == rootID || c.ParentID == rootID {
out = append(out, c)
}
}
return out
}
func activeRootID(children []*Child, focusID string) string {
if focusID != "" {
for _, c := range children {
if c.ID != focusID {
continue
}
if c.ParentID == "" {
return c.ID
}
if parent := findChildInSnapshot(children, c.ParentID); parent != nil {
return parent.ID
}
return ""
}
}
for _, c := range children {
if c.ParentID == "" && c.Status() == StatusRunning {
return c.ID
}
}
return ""
}
func findChildInSnapshot(children []*Child, id string) *Child {
for _, c := range children {
if c.ID == id {
return c
}
}
return nil
}
func firstRunningTopLevel(children []*Child) *Child {
for _, c := range children {
if c.ParentID == "" && c.Status() == StatusRunning {
return c
}
}
return nil
}

41
internal/app/tree_test.go Normal file
View File

@@ -0,0 +1,41 @@
package app
import "testing"
func TestVisibleSessionTreeScopesToFocusedRoot(t *testing.T) {
root1 := testChild("c1", "root1", "", StatusRunning)
child1 := testChild("c2", "child1", "c1", StatusRunning)
root2 := testChild("c3", "root2", "", StatusRunning)
child2 := testChild("c4", "child2", "c3", StatusRunning)
got := visibleSessionTree([]*Child{root1, child1, root2, child2}, "c4")
if len(got) != 2 || got[0].ID != "c3" || got[1].ID != "c4" {
t.Fatalf("visible tree = %v, want root2 + child2", childIDs(got))
}
}
func TestVisibleSessionTreeOmitsExited(t *testing.T) {
root := testChild("c1", "root", "", StatusRunning)
exitedRoot := testChild("c2", "dead-root", "", StatusExited)
runningChild := testChild("c3", "child", "c1", StatusRunning)
exitedChild := testChild("c4", "dead-child", "c1", StatusExited)
got := visibleSessionTree([]*Child{root, exitedRoot, runningChild, exitedChild}, "c1")
if len(got) != 2 || got[0].ID != "c1" || got[1].ID != "c3" {
t.Fatalf("visible tree = %v, want running root + running child", childIDs(got))
}
}
func testChild(id, name, parent string, status ChildStatus) *Child {
c := &Child{ID: id, Name: name, ParentID: parent}
c.status.Store(&status)
return c
}
func childIDs(cs []*Child) []string {
ids := make([]string, 0, len(cs))
for _, c := range cs {
ids = append(ids, c.ID)
}
return ids
}

View File

@@ -0,0 +1,294 @@
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),
layout: l,
row: 1,
col: 1,
}
}
func (vr *viewportRenderer) SetLayout(l terminalLayout) {
vr.mu.Lock()
defer vr.mu.Unlock()
vr.layout = l
vr.shifter.SetRowOffset(int(l.mainTop) - 1)
}
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) 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 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%s", int(vr.layout.mainTop+r), int(vr.layout.mainLeft), strings.Repeat(" ", int(vr.layout.childCols())))
}
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
}
}

View File

@@ -0,0 +1,63 @@
package app
import (
"strings"
"testing"
)
func TestViewportRendererShiftsCursor(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[H")))
if got != "\x1b[2;1H" {
t.Fatalf("CUP home: got %q", got)
}
}
func TestViewportRendererSwallowsAltScreenToggles(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("a\x1b[?1049hb\x1b[?1049lc")))
if got != "abc" {
t.Fatalf("alt-screen toggles: got %q", got)
}
}
func TestViewportRendererClearScreenIsViewportOnly(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(20, 5))
got := string(vr.Render([]byte("\x1b[2J")))
if strings.Contains(got, "\x1b[2J") {
t.Fatalf("host clear-screen leaked through: %q", got)
}
if strings.Count(got, " ") != 3 {
t.Fatalf("clear rows: got %q", got)
}
if !strings.Contains(got, "\x1b[2;1H") || !strings.Contains(got, "\x1b[4;1H") {
t.Fatalf("clear did not target viewport rows: %q", got)
}
}
func TestViewportRendererClearLineUsesEraseChars(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(20, 5))
got := string(vr.Render([]byte("\x1b[K")))
if strings.Contains(got, "\x1b[K") {
t.Fatalf("host clear-line leaked through: %q", got)
}
if got != "\x1b[20X" {
t.Fatalf("clear-line: got %q want ECH", got)
}
}
func TestViewportRendererClearLineStopsAtViewportRight(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(20, 5))
got := string(vr.Render([]byte("\x1b[10G\x1b[K")))
if !strings.HasSuffix(got, "\x1b[11X") {
t.Fatalf("clear-line from col 10 should erase 11 cells: %q", got)
}
}
func TestViewportRendererTracksPrintableCursor(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(20, 5))
got := string(vr.Render([]byte("hello\x1b[K")))
if !strings.HasSuffix(got, "\x1b[15X") {
t.Fatalf("clear-line after five chars should erase 15 cells: %q", got)
}
}