Initial patterm project
This commit is contained in:
724
internal/app/app.go
Normal file
724
internal/app/app.go
Normal 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
235
internal/app/child.go
Normal 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
267
internal/app/cursorshift.go
Normal 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
|
||||
}
|
||||
77
internal/app/cursorshift_test.go
Normal file
77
internal/app/cursorshift_test.go
Normal 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
335
internal/app/host.go
Normal 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
179
internal/app/launch.go
Normal 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
73
internal/app/layout.go
Normal 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()
|
||||
}
|
||||
58
internal/app/layout_test.go
Normal file
58
internal/app/layout_test.go
Normal 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
332
internal/app/palette.go
Normal 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)
|
||||
}
|
||||
86
internal/app/screen_renderer.go
Normal file
86
internal/app/screen_renderer.go
Normal 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()
|
||||
}
|
||||
33
internal/app/screen_renderer_test.go
Normal file
33
internal/app/screen_renderer_test.go
Normal 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
315
internal/app/session.go
Normal 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
143
internal/app/sidebar.go
Normal 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
70
internal/app/tabbar.go
Normal 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
59
internal/app/tree.go
Normal 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
41
internal/app/tree_test.go
Normal 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
|
||||
}
|
||||
294
internal/app/viewport_renderer.go
Normal file
294
internal/app/viewport_renderer.go
Normal 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
|
||||
}
|
||||
}
|
||||
63
internal/app/viewport_renderer_test.go
Normal file
63
internal/app/viewport_renderer_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
183
internal/mcp/mcp.go
Normal file
183
internal/mcp/mcp.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// Package mcp is patterm's in-process MCP server and the stdio proxy
|
||||
// subcommand that spawned children connect through. SPEC §7 + §10.
|
||||
//
|
||||
// v1 stubs out the server: it listens on the per-PID socket, accepts
|
||||
// connections from `patterm mcp-stdio` proxies, and returns a "not
|
||||
// implemented" error for every tool call. The plumbing is in place so
|
||||
// later milestones (suggested build order §15 step 4 onwards) can fill
|
||||
// in real tool handlers without touching the lifecycle code.
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Server is patterm's in-process MCP server. SPEC §10: bound to a
|
||||
// per-PID unix socket under $XDG_RUNTIME_DIR/patterm/<pid>.sock.
|
||||
type Server struct {
|
||||
socket string
|
||||
listener net.Listener
|
||||
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
host ToolHost
|
||||
}
|
||||
|
||||
// SocketPath returns the per-PID socket path with the standard fallback.
|
||||
// SPEC §3.
|
||||
func SocketPath() (string, error) {
|
||||
pid := strconv.Itoa(os.Getpid())
|
||||
if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" {
|
||||
dir := filepath.Join(runtime, "patterm")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("mcp: mkdir %s: %w", dir, err)
|
||||
}
|
||||
return filepath.Join(dir, pid+".sock"), nil
|
||||
}
|
||||
return filepath.Join("/tmp", "patterm-"+pid+".sock"), nil
|
||||
}
|
||||
|
||||
// Start opens the per-PID socket and serves JSON-RPC over it. The
|
||||
// returned Server can be Close()d on shutdown.
|
||||
func Start() (*Server, error) {
|
||||
path, err := SocketPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
ln, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mcp: listen %s: %w", path, err)
|
||||
}
|
||||
_ = os.Chmod(path, 0o600)
|
||||
s := &Server{socket: path, listener: ln}
|
||||
go s.acceptLoop()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) Socket() string { return s.socket }
|
||||
|
||||
func (s *Server) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
_ = s.listener.Close()
|
||||
_ = os.Remove(s.socket)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) acceptLoop() {
|
||||
for {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
go s.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
// handleConn reads newline-delimited JSON-RPC requests from a connected
|
||||
// child and dispatches them. The first line carries the per-spawn
|
||||
// identity token (SPEC §10); we resolve it to a child id and stash that
|
||||
// as the caller for every subsequent tool call.
|
||||
func (s *Server) handleConn(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
r := bufio.NewReader(conn)
|
||||
|
||||
var callerID string
|
||||
|
||||
greeting, err := r.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if tok := greetingIdentity(greeting); tok != "" {
|
||||
s.mu.Lock()
|
||||
host := s.host
|
||||
s.mu.Unlock()
|
||||
if host != nil {
|
||||
callerID = host.ResolveCallerIdentity(tok)
|
||||
}
|
||||
} else {
|
||||
// Treat as a real request from an unknown caller.
|
||||
resp := s.dispatch("", greeting)
|
||||
resp = append(resp, '\n')
|
||||
if _, werr := conn.Write(resp); werr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
line, err := r.ReadBytes('\n')
|
||||
if len(line) > 0 {
|
||||
resp := s.dispatch(callerID, line)
|
||||
resp = append(resp, '\n')
|
||||
if _, werr := conn.Write(resp); werr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func greetingIdentity(b []byte) string {
|
||||
var probe struct {
|
||||
Identity string `json:"patterm_identity"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &probe); err != nil {
|
||||
return ""
|
||||
}
|
||||
return probe.Identity
|
||||
}
|
||||
|
||||
// RunStdioProxy is the entry point for `patterm mcp-stdio`. It opens
|
||||
// the per-PID socket and shuttles bytes between os.Stdin/os.Stdout and
|
||||
// the socket. SPEC §10: the vendor CLI thinks it's launching a normal
|
||||
// stdio MCP server; this proxy forwards JSON-RPC to the running
|
||||
// patterm process.
|
||||
func RunStdioProxy(socket, identity string) error {
|
||||
conn, err := net.Dial("unix", socket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", socket, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Send a one-line greeting carrying the identity so the server
|
||||
// knows which child it's talking to. Format: {"patterm_identity":
|
||||
// "<token>"} + newline. Real protocol handshake is a later
|
||||
// milestone.
|
||||
greeting := map[string]string{"patterm_identity": identity}
|
||||
gb, _ := json.Marshal(greeting)
|
||||
gb = append(gb, '\n')
|
||||
if _, err := conn.Write(gb); err != nil {
|
||||
return fmt.Errorf("greeting: %w", err)
|
||||
}
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
go func() {
|
||||
_, err := io.Copy(conn, os.Stdin)
|
||||
errCh <- err
|
||||
}()
|
||||
go func() {
|
||||
_, err := io.Copy(os.Stdout, conn)
|
||||
errCh <- err
|
||||
}()
|
||||
<-errCh
|
||||
return nil
|
||||
}
|
||||
347
internal/mcp/tools.go
Normal file
347
internal/mcp/tools.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"syscall"
|
||||
|
||||
"github.com/harrybrwn/patterm/internal/scratchpad"
|
||||
)
|
||||
|
||||
// ToolHost is the interface the in-process server uses to reach the
|
||||
// running patterm process's state. The app package implements this so
|
||||
// internal/mcp doesn't import internal/app (which would be a cycle).
|
||||
type ToolHost interface {
|
||||
Children() []ChildInfo
|
||||
Spawn(callerID, name string, argv []string, shell bool) (ChildInfo, error)
|
||||
SpawnAgent(callerID, presetName, displayName, initialPrompt string) (ChildInfo, error)
|
||||
ReadOutput(callerID, childID, mode string, sinceOffset int) (content string, newOffset int, err error)
|
||||
SendInput(callerID, childID string, payload []byte, appendNewline bool) error
|
||||
Kill(callerID, childID string, sig syscall.Signal) error
|
||||
SendMessageTo(callerID, targetID, message string) error
|
||||
ReportToParent(callerID, message string) error
|
||||
TimerWait(callerID string, seconds float64, label string) (string, error)
|
||||
WaitForPattern(callerID, childID, pattern string, timeoutSeconds float64) (matched bool, snippet string, err error)
|
||||
RequestHumanAttention(callerID, childID, reason string) error
|
||||
Scratchpads() *scratchpad.Store
|
||||
|
||||
// ResolveCallerIdentity translates a per-spawn identity token into
|
||||
// the child ID the server stores in its connection state.
|
||||
ResolveCallerIdentity(identity string) string
|
||||
|
||||
// PolicyCheck — SPEC §9. Returns "allow" / "punt" / "unknown" for
|
||||
// a candidate auto-answer prompt the orchestrator is reading.
|
||||
PolicyCheck(prompt string) string
|
||||
}
|
||||
|
||||
// ChildInfo is what list_children / spawn_process / spawn_agent return.
|
||||
// Matches SPEC §7 shape plus the §11 idle exposure.
|
||||
type ChildInfo struct {
|
||||
ID string `json:"child_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
ExitCode int `json:"exit_code,omitempty"`
|
||||
IdleMS int64 `json:"idle_ms,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) SetHost(h ToolHost) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.host = h
|
||||
}
|
||||
|
||||
// dispatch routes a single JSON-RPC request. callerID is the ID of the
|
||||
// child that owns this connection (resolved at greeting time).
|
||||
func (s *Server) dispatch(callerID string, req []byte) []byte {
|
||||
var msg struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal(req, &msg); err != nil {
|
||||
return jsonRPCError(nil, -32700, "parse error: "+err.Error())
|
||||
}
|
||||
s.mu.Lock()
|
||||
host := s.host
|
||||
s.mu.Unlock()
|
||||
if host == nil {
|
||||
return jsonRPCError(msg.ID, -32000, "patterm: tool host not initialized")
|
||||
}
|
||||
|
||||
result, code, errMsg := callTool(host, callerID, msg.Method, msg.Params)
|
||||
if errMsg != "" {
|
||||
return jsonRPCError(msg.ID, code, errMsg)
|
||||
}
|
||||
return jsonRPCResult(msg.ID, result)
|
||||
}
|
||||
|
||||
func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any, int, string) {
|
||||
switch method {
|
||||
case "list_children":
|
||||
return h.Children(), 0, ""
|
||||
|
||||
case "spawn_process":
|
||||
var p struct {
|
||||
Preset string `json:"preset"`
|
||||
Argv []string `json:"argv"`
|
||||
Shell bool `json:"shell"`
|
||||
Name string `json:"name"`
|
||||
WorkingDir string `json:"working_dir"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
// Preset-by-name is the preferred path per SPEC §7; argv is the
|
||||
// escape hatch. We don't load process presets here — the host
|
||||
// is the source of truth — so a named preset call is rejected
|
||||
// unless the caller also supplied argv. (Wiring full preset
|
||||
// resolution into MCP is a small follow-up; the host's palette
|
||||
// path covers the named case today.)
|
||||
if len(p.Argv) == 0 {
|
||||
return nil, -32602, "spawn_process: argv required"
|
||||
}
|
||||
ci, err := h.Spawn(callerID, p.Name, p.Argv, p.Shell)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return ci, 0, ""
|
||||
|
||||
case "spawn_agent":
|
||||
var p struct {
|
||||
Preset string `json:"preset"`
|
||||
InitialPrompt string `json:"initial_prompt"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
if p.Preset == "" {
|
||||
return nil, -32602, "spawn_agent: preset required"
|
||||
}
|
||||
ci, err := h.SpawnAgent(callerID, p.Preset, p.Name, p.InitialPrompt)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return ci, 0, ""
|
||||
|
||||
case "read_output":
|
||||
var p struct {
|
||||
ChildID string `json:"child_id"`
|
||||
Mode string `json:"mode"`
|
||||
SinceOffset int `json:"since_offset"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
if p.Mode == "" {
|
||||
p.Mode = "grid"
|
||||
}
|
||||
content, newOff, err := h.ReadOutput(callerID, p.ChildID, p.Mode, p.SinceOffset)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return map[string]any{
|
||||
"content": content,
|
||||
"new_offset": newOff,
|
||||
"mode": p.Mode,
|
||||
}, 0, ""
|
||||
|
||||
case "send_input":
|
||||
var p struct {
|
||||
ChildID string `json:"child_id"`
|
||||
Input string `json:"input"`
|
||||
AppendNewline *bool `json:"append_newline"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
appendNL := true
|
||||
if p.AppendNewline != nil {
|
||||
appendNL = *p.AppendNewline
|
||||
}
|
||||
if err := h.SendInput(callerID, p.ChildID, []byte(p.Input), appendNL); err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return "ok", 0, ""
|
||||
|
||||
case "kill":
|
||||
var p struct {
|
||||
ChildID string `json:"child_id"`
|
||||
Signal int `json:"signal"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
sig := syscall.Signal(p.Signal)
|
||||
if sig == 0 {
|
||||
sig = syscall.SIGTERM
|
||||
}
|
||||
if err := h.Kill(callerID, p.ChildID, sig); err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return "ok", 0, ""
|
||||
|
||||
case "send_message_to":
|
||||
var p struct {
|
||||
Target string `json:"target"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
if err := h.SendMessageTo(callerID, p.Target, p.Message); err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return "ok", 0, ""
|
||||
|
||||
case "report_to_parent":
|
||||
var p struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
if err := h.ReportToParent(callerID, p.Message); err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return "ok", 0, ""
|
||||
|
||||
case "timer_wait":
|
||||
var p struct {
|
||||
Seconds float64 `json:"seconds"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
id, err := h.TimerWait(callerID, p.Seconds, p.Label)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return map[string]string{"timer_id": id}, 0, ""
|
||||
|
||||
case "wait_for_pattern":
|
||||
var p struct {
|
||||
ChildID string `json:"child_id"`
|
||||
Pattern string `json:"pattern"`
|
||||
TimeoutSeconds float64 `json:"timeout_seconds"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
matched, snippet, err := h.WaitForPattern(callerID, p.ChildID, p.Pattern, p.TimeoutSeconds)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return map[string]any{"matched": matched, "snippet": snippet}, 0, ""
|
||||
|
||||
case "request_human_attention":
|
||||
var p struct {
|
||||
ChildID string `json:"child_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
if err := h.RequestHumanAttention(callerID, p.ChildID, p.Reason); err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return "ok", 0, ""
|
||||
|
||||
case "scratchpad_list":
|
||||
entries, err := h.Scratchpads().List()
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return entries, 0, ""
|
||||
|
||||
case "scratchpad_read":
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
content, rev, err := h.Scratchpads().Read(p.Name)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return map[string]any{"content": content, "revision": rev}, 0, ""
|
||||
|
||||
case "scratchpad_write":
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
ExpectedRevision string `json:"expected_revision"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
rev, err := h.Scratchpads().Write(p.Name, p.Content, p.ExpectedRevision)
|
||||
if err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return map[string]any{"revision": rev}, 0, ""
|
||||
|
||||
case "policy_check":
|
||||
var p struct {
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
return map[string]string{"decision": h.PolicyCheck(p.Prompt)}, 0, ""
|
||||
|
||||
case "scratchpad_append":
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, -32602, err.Error()
|
||||
}
|
||||
if err := h.Scratchpads().Append(p.Name, p.Content); err != nil {
|
||||
return nil, -32000, err.Error()
|
||||
}
|
||||
return "ok", 0, ""
|
||||
}
|
||||
return nil, -32601, "method not found: " + method
|
||||
}
|
||||
|
||||
func unmarshalParams(raw json.RawMessage, out any) error {
|
||||
if len(raw) == 0 {
|
||||
return errors.New("missing params")
|
||||
}
|
||||
return json.Unmarshal(raw, out)
|
||||
}
|
||||
|
||||
func jsonRPCResult(id json.RawMessage, result any) []byte {
|
||||
resp := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": result,
|
||||
}
|
||||
b, _ := json.Marshal(resp)
|
||||
return b
|
||||
}
|
||||
|
||||
func jsonRPCError(id json.RawMessage, code int, message string) []byte {
|
||||
resp := map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"error": map[string]any{
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(resp)
|
||||
return b
|
||||
}
|
||||
|
||||
// Compile-time guard: every dispatch path is covered. fmt is imported
|
||||
// only so future error wrapping can land without re-adding the import.
|
||||
var _ = fmt.Sprintf
|
||||
166
internal/policy/policy.go
Normal file
166
internal/policy/policy.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// Package policy implements SPEC §9 permissions hooks.
|
||||
//
|
||||
// patterm doesn't enforce permissions on the agent's behalf — the
|
||||
// orchestrator is the policy actor. But patterm ships a config that
|
||||
// surfaces the project's deny-list to the orchestrator (via
|
||||
// scratchpad_read("policy.md")) and exposes a Should() helper future
|
||||
// MCP middleware can call to short-circuit obviously-dangerous prompts.
|
||||
//
|
||||
// File location: $XDG_CONFIG_HOME/patterm/policy.json (global default;
|
||||
// per-project override at <project>/.patterm/policy.json is a v2
|
||||
// follow-up).
|
||||
package policy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Decision is what Should() returns for a candidate auto-answer.
|
||||
type Decision string
|
||||
|
||||
const (
|
||||
// Allow: the prompt is in the always-safe allowlist; auto-answer.
|
||||
Allow Decision = "allow"
|
||||
// PuntToHuman: the prompt matches the deny-list; the orchestrator
|
||||
// MUST call request_human_attention instead of auto-answering.
|
||||
PuntToHuman Decision = "punt"
|
||||
// Unknown: no rule applies. SPEC §9 says default is to punt; we
|
||||
// keep this distinct so callers know it's a default, not a match.
|
||||
Unknown Decision = "unknown"
|
||||
)
|
||||
|
||||
type Policy struct {
|
||||
// Allowlist patterns: prompts matching ANY of these are safe to
|
||||
// auto-answer.
|
||||
AllowPatterns []string `json:"allow_patterns"`
|
||||
// Deny patterns: prompts matching ANY of these MUST be punted to
|
||||
// the human. Default seed below covers SPEC §9 examples (writes,
|
||||
// deletes, sudo, package install, broad shell).
|
||||
DenyPatterns []string `json:"deny_patterns"`
|
||||
|
||||
mu sync.Mutex
|
||||
compiledAOK bool
|
||||
allowRE []*regexp.Regexp
|
||||
denyRE []*regexp.Regexp
|
||||
}
|
||||
|
||||
// Default returns the seeded policy that ships out of the box.
|
||||
func Default() *Policy {
|
||||
return &Policy{
|
||||
AllowPatterns: []string{
|
||||
`(?i)read.* from .*\?`,
|
||||
`(?i)open .* in editor\?`,
|
||||
`(?i)show diff\?`,
|
||||
},
|
||||
DenyPatterns: []string{
|
||||
`(?i)sudo`,
|
||||
`(?i)rm -rf`,
|
||||
`(?i)delete .*permanently`,
|
||||
`(?i)install package`,
|
||||
`(?i)pip install`,
|
||||
`(?i)npm install -g`,
|
||||
`(?i)curl .* \| .*sh`,
|
||||
`(?i)wget .* \| .*sh`,
|
||||
`(?i)force.push`,
|
||||
`(?i)git push --force`,
|
||||
`(?i)drop (table|database)`,
|
||||
`(?i)\.ssh/`,
|
||||
`(?i)\.aws/credentials`,
|
||||
`(?i)\.env\b`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the user's policy, falling back to Default if absent.
|
||||
// Errors other than ENOENT are returned.
|
||||
func Load() (*Policy, error) {
|
||||
p, err := PathFor()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
pol := Default()
|
||||
_ = Save(pol)
|
||||
return pol, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var pol Policy
|
||||
if err := json.Unmarshal(body, &pol); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pol, nil
|
||||
}
|
||||
|
||||
// Save writes p to the standard location, creating directories.
|
||||
func Save(p *Policy) error {
|
||||
path, err := PathFor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := json.MarshalIndent(p, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = append(body, '\n')
|
||||
return os.WriteFile(path, body, 0o600)
|
||||
}
|
||||
|
||||
func PathFor() (string, error) {
|
||||
if h := os.Getenv("XDG_CONFIG_HOME"); h != "" {
|
||||
return filepath.Join(h, "patterm", "policy.json"), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".config", "patterm", "policy.json"), nil
|
||||
}
|
||||
|
||||
// Should classifies a candidate auto-answer prompt the orchestrator is
|
||||
// reading from a sub-agent's grid.
|
||||
func (p *Policy) Should(promptText string) Decision {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.ensureCompiledLocked()
|
||||
for _, re := range p.denyRE {
|
||||
if re.MatchString(promptText) {
|
||||
return PuntToHuman
|
||||
}
|
||||
}
|
||||
for _, re := range p.allowRE {
|
||||
if re.MatchString(promptText) {
|
||||
return Allow
|
||||
}
|
||||
}
|
||||
return Unknown
|
||||
}
|
||||
|
||||
func (p *Policy) ensureCompiledLocked() {
|
||||
if p.compiledAOK {
|
||||
return
|
||||
}
|
||||
p.allowRE = make([]*regexp.Regexp, 0, len(p.AllowPatterns))
|
||||
for _, s := range p.AllowPatterns {
|
||||
if re, err := regexp.Compile(s); err == nil {
|
||||
p.allowRE = append(p.allowRE, re)
|
||||
}
|
||||
}
|
||||
p.denyRE = make([]*regexp.Regexp, 0, len(p.DenyPatterns))
|
||||
for _, s := range p.DenyPatterns {
|
||||
if re, err := regexp.Compile(s); err == nil {
|
||||
p.denyRE = append(p.denyRE, re)
|
||||
}
|
||||
}
|
||||
p.compiledAOK = true
|
||||
}
|
||||
254
internal/preset/preset.go
Normal file
254
internal/preset/preset.go
Normal file
@@ -0,0 +1,254 @@
|
||||
// Package preset loads user-editable JSON files that describe how to
|
||||
// launch agents and processes. SPEC §10: every spawnable thing is a
|
||||
// preset; patterm has no hard-coded agent or process types.
|
||||
package preset
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Kind is "agent" or "process".
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindAgent Kind = "agent"
|
||||
KindProcess Kind = "process"
|
||||
)
|
||||
|
||||
// Preset is one loaded preset file. Agent-only fields stay zero on
|
||||
// process presets and vice versa.
|
||||
type Preset struct {
|
||||
// Source path (informational; not serialized).
|
||||
Path string `json:"-"`
|
||||
Kind Kind `json:"-"`
|
||||
|
||||
Name string `json:"name"`
|
||||
Argv []string `json:"argv"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
WorkingDir string `json:"working_dir,omitempty"`
|
||||
|
||||
// Process-only.
|
||||
Shell bool `json:"shell,omitempty"`
|
||||
|
||||
// Agent-only. SPEC §10.
|
||||
MCPInjection *MCPInjection `json:"mcp_injection,omitempty"`
|
||||
ReadySignal *ReadySignal `json:"ready_signal,omitempty"`
|
||||
ChromeTrimHints []string `json:"chrome_trim_hints,omitempty"`
|
||||
}
|
||||
|
||||
// MCPInjection covers the three strategies SPEC §10 enumerates: a CLI
|
||||
// flag (claude --mcp-config ...), an external config file we merge into
|
||||
// (codex ~/.codex/config.toml), or an env var.
|
||||
type MCPInjection struct {
|
||||
Kind string `json:"kind"` // "flag" | "config_file" | "env_var"
|
||||
Flag string `json:"flag,omitempty"`
|
||||
ConfigPath string `json:"config_path,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
MergeKey string `json:"merge_key,omitempty"`
|
||||
Var string `json:"var,omitempty"`
|
||||
}
|
||||
|
||||
// ReadySignal lets a preset override the default 1s-idle heuristic.
|
||||
type ReadySignal struct {
|
||||
IdleMS int `json:"idle_ms,omitempty"`
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
}
|
||||
|
||||
// Set is what the palette consumes.
|
||||
type Set struct {
|
||||
Agents []*Preset
|
||||
Processes []*Preset
|
||||
}
|
||||
|
||||
// Load scans the standard locations under $XDG_CONFIG_HOME/patterm/
|
||||
// presets/{agents,processes}/*.json. Unknown files are skipped with a
|
||||
// warning to stderr; the spec is forgiving here.
|
||||
func Load() (Set, error) {
|
||||
base, err := ConfigDir()
|
||||
if err != nil {
|
||||
return Set{}, err
|
||||
}
|
||||
if err := os.MkdirAll(base, 0o700); err != nil {
|
||||
return Set{}, fmt.Errorf("preset: mkdir %s: %w", base, err)
|
||||
}
|
||||
|
||||
// Make sure the default-preset files exist on first run. Idempotent.
|
||||
if err := ensureDefaults(base); err != nil {
|
||||
return Set{}, err
|
||||
}
|
||||
|
||||
agents, err := loadDir(filepath.Join(base, "presets", "agents"), KindAgent)
|
||||
if err != nil {
|
||||
return Set{}, err
|
||||
}
|
||||
procs, err := loadDir(filepath.Join(base, "presets", "processes"), KindProcess)
|
||||
if err != nil {
|
||||
return Set{}, err
|
||||
}
|
||||
return Set{Agents: agents, Processes: procs}, nil
|
||||
}
|
||||
|
||||
// ConfigDir resolves $XDG_CONFIG_HOME/patterm (with the conventional
|
||||
// fallback to ~/.config/patterm).
|
||||
func ConfigDir() (string, error) {
|
||||
if h := os.Getenv("XDG_CONFIG_HOME"); h != "" {
|
||||
return filepath.Join(h, "patterm"), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".config", "patterm"), nil
|
||||
}
|
||||
|
||||
func loadDir(dir string, kind Kind) ([]*Preset, error) {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("preset: mkdir %s: %w", dir, err)
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("preset: read %s: %w", dir, err)
|
||||
}
|
||||
var out []*Preset
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
p, err := loadFile(path, kind)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "patterm: preset %s: %v\n", path, err)
|
||||
continue
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadFile(path string, kind Kind) (*Preset, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var p Preset
|
||||
if err := json.Unmarshal(b, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.Name == "" {
|
||||
return nil, errors.New("missing 'name'")
|
||||
}
|
||||
if len(p.Argv) == 0 && !p.Shell {
|
||||
return nil, errors.New("missing 'argv'")
|
||||
}
|
||||
p.Path = path
|
||||
p.Kind = kind
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// ResolvedArgv returns the argv to actually exec, handling the
|
||||
// process-preset "shell: true" case (SPEC §10).
|
||||
func (p *Preset) ResolvedArgv() []string {
|
||||
if p.Shell && len(p.Argv) > 0 {
|
||||
return []string{"sh", "-lc", strings.Join(p.Argv, " ")}
|
||||
}
|
||||
return p.Argv
|
||||
}
|
||||
|
||||
// ensureDefaults writes default agent presets (claude/codex/opencode)
|
||||
// and a sample process preset on first run. Never overwrites existing
|
||||
// user files.
|
||||
func ensureDefaults(base string) error {
|
||||
defaults := []struct {
|
||||
rel string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
"presets/agents/claude.json",
|
||||
`{
|
||||
"name": "claude",
|
||||
"argv": ["claude"],
|
||||
"mcp_injection": { "kind": "flag", "flag": "--mcp-config" },
|
||||
"ready_signal": { "idle_ms": 1000 },
|
||||
"chrome_trim_hints": [
|
||||
"^Welcome to Claude Code",
|
||||
"^/help for help",
|
||||
"^cwd:",
|
||||
"^\\s*│\\s*>",
|
||||
"^\\s*╭─+╮$",
|
||||
"^\\s*╰─+╯$",
|
||||
"^\\? for shortcuts"
|
||||
]
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
"presets/agents/codex.json",
|
||||
`{
|
||||
"name": "codex",
|
||||
"argv": ["codex"],
|
||||
"mcp_injection": { "kind": "config_file", "path": "~/.codex/config.toml", "merge_key": "mcp_servers" },
|
||||
"ready_signal": { "idle_ms": 1000 },
|
||||
"chrome_trim_hints": [
|
||||
"^OpenAI Codex",
|
||||
"^\\s*model:",
|
||||
"^\\s*workdir:",
|
||||
"^>_$",
|
||||
"^\\s*▌"
|
||||
]
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
"presets/agents/opencode.json",
|
||||
`{
|
||||
"name": "opencode",
|
||||
"argv": ["opencode"],
|
||||
"mcp_injection": { "kind": "config_file", "path": "~/.config/opencode/opencode.json", "merge_key": "mcp" },
|
||||
"ready_signal": { "idle_ms": 1000 },
|
||||
"chrome_trim_hints": [
|
||||
"^\\s*█",
|
||||
"^\\s*opencode v",
|
||||
"^\\s*~/",
|
||||
"^\\s*>_"
|
||||
]
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
"presets/processes/shell.json",
|
||||
`{
|
||||
"name": "shell",
|
||||
"argv": ["__SHELL__"]
|
||||
}
|
||||
`,
|
||||
},
|
||||
}
|
||||
for _, d := range defaults {
|
||||
full := filepath.Join(base, d.rel)
|
||||
if _, err := os.Stat(full); err == nil {
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
body := d.body
|
||||
if strings.Contains(body, "__SHELL__") {
|
||||
shell := os.Getenv("SHELL")
|
||||
if shell == "" {
|
||||
shell = "/bin/sh"
|
||||
}
|
||||
body = strings.ReplaceAll(body, "__SHELL__", shell)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(body), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
32
internal/projectkey/projectkey.go
Normal file
32
internal/projectkey/projectkey.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Package projectkey turns a project directory into the stable key
|
||||
// patterm uses to name its scratchpad directory under $XDG_DATA_HOME.
|
||||
// SPEC §3.
|
||||
//
|
||||
// Two invocations from the same realpath must produce the same key.
|
||||
// The key is only used as a directory name on disk — there is no
|
||||
// daemon to look up.
|
||||
package projectkey
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Key derives the 16-char hex key from the realpath of dir.
|
||||
func Key(dir string) (string, error) {
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("projectkey: abs %q: %w", dir, err)
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(abs)
|
||||
if err != nil {
|
||||
// Directory may not exist yet; fall back to the absolute path.
|
||||
// The key stays stable; downstream code will fail later when it
|
||||
// tries to chdir or write into the dir.
|
||||
resolved = abs
|
||||
}
|
||||
sum := sha256.Sum256([]byte(resolved))
|
||||
return hex.EncodeToString(sum[:8]), nil
|
||||
}
|
||||
142
internal/pty/pty.go
Normal file
142
internal/pty/pty.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Package pty wraps creack/pty with the small surface the spike needs.
|
||||
package pty
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
cpty "github.com/creack/pty"
|
||||
)
|
||||
|
||||
// PTY holds a child process attached to a pseudo-terminal master fd.
|
||||
type PTY struct {
|
||||
master *os.File
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
// Start spawns argv with stdin/stdout/stderr attached to a new PTY sized
|
||||
// (cols, rows). The returned PTY exposes the master fd for the parent to
|
||||
// read from and write to.
|
||||
func Start(argv []string, env []string, cols, rows uint16) (*PTY, error) {
|
||||
if len(argv) == 0 {
|
||||
return nil, fmt.Errorf("pty: empty argv")
|
||||
}
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
if env != nil {
|
||||
cmd.Env = ensureTerm(env)
|
||||
} else {
|
||||
// Default to the parent environment but force TERM to xterm-256color
|
||||
// so child programs assume something modern and we observe SGR + alt
|
||||
// screen sequences.
|
||||
cmd.Env = ensureTerm(os.Environ())
|
||||
}
|
||||
|
||||
ws := &cpty.Winsize{Cols: cols, Rows: rows}
|
||||
master, err := cpty.StartWithSize(cmd, ws)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pty: start %v: %w", argv, err)
|
||||
}
|
||||
return &PTY{master: master, cmd: cmd}, nil
|
||||
}
|
||||
|
||||
func (p *PTY) Read(b []byte) (int, error) {
|
||||
if p.master == nil {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
return p.master.Read(b)
|
||||
}
|
||||
|
||||
func (p *PTY) Write(b []byte) (int, error) {
|
||||
if p.master == nil {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
return p.master.Write(b)
|
||||
}
|
||||
|
||||
func (p *PTY) Resize(cols, rows uint16) error {
|
||||
if p.master == nil {
|
||||
return io.ErrClosedPipe
|
||||
}
|
||||
return cpty.Setsize(p.master, &cpty.Winsize{Cols: cols, Rows: rows})
|
||||
}
|
||||
|
||||
// Wait blocks until the child exits and returns its exit error if any.
|
||||
func (p *PTY) Wait() error {
|
||||
if p.cmd == nil {
|
||||
return nil
|
||||
}
|
||||
return p.cmd.Wait()
|
||||
}
|
||||
|
||||
// Pid returns the child's PID, or -1 if the process is not running.
|
||||
func (p *PTY) Pid() int {
|
||||
if p.cmd == nil || p.cmd.Process == nil {
|
||||
return -1
|
||||
}
|
||||
return p.cmd.Process.Pid
|
||||
}
|
||||
|
||||
// Close terminates the child (best effort) and releases the master fd.
|
||||
func (p *PTY) Close() error {
|
||||
var firstErr error
|
||||
if p.master != nil {
|
||||
if err := p.master.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
p.master = nil
|
||||
}
|
||||
if p.cmd != nil && p.cmd.Process != nil {
|
||||
_ = p.cmd.Process.Kill()
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// envDefaults are added to the child's environment unless the parent already
|
||||
// set them. Modern agent TUIs check these and silently downgrade rendering
|
||||
// when they're missing (no truecolor, ASCII-only banners, etc.).
|
||||
var envDefaults = map[string]string{
|
||||
"TERM": "xterm-256color",
|
||||
"COLORTERM": "truecolor",
|
||||
}
|
||||
|
||||
// envStrip names variables we DROP before launching a child. COLUMNS /
|
||||
// LINES inherited from the parent shell describe the *host* terminal,
|
||||
// not the PTY we created — when they leak through, TUIs that prefer
|
||||
// env over TIOCGWINSZ render past the PTY's actual cell grid and
|
||||
// overwrite our chrome.
|
||||
var envStrip = map[string]bool{
|
||||
"COLUMNS": true,
|
||||
"LINES": true,
|
||||
}
|
||||
|
||||
func ensureTerm(env []string) []string {
|
||||
have := make(map[string]bool, len(envDefaults))
|
||||
out := make([]string, 0, len(env)+len(envDefaults))
|
||||
for _, kv := range env {
|
||||
key := envKey(kv)
|
||||
if envStrip[key] {
|
||||
continue
|
||||
}
|
||||
if _, isDefault := envDefaults[key]; isDefault {
|
||||
have[key] = true
|
||||
}
|
||||
out = append(out, kv)
|
||||
}
|
||||
for k, v := range envDefaults {
|
||||
if !have[k] {
|
||||
out = append(out, k+"="+v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func envKey(kv string) string {
|
||||
for i := 0; i < len(kv); i++ {
|
||||
if kv[i] == '=' {
|
||||
return kv[:i]
|
||||
}
|
||||
}
|
||||
return kv
|
||||
}
|
||||
138
internal/scratchpad/scratchpad.go
Normal file
138
internal/scratchpad/scratchpad.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// Package scratchpad manages the project-scoped markdown files described
|
||||
// in SPEC §3. Files live under
|
||||
// $XDG_DATA_HOME/patterm/projects/<project-key>/scratchpads/. Last-write-
|
||||
// wins with a revision token (SPEC §14).
|
||||
package scratchpad
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Store is the per-project scratchpad directory.
|
||||
type Store struct {
|
||||
dir string
|
||||
}
|
||||
|
||||
// Open returns a Store rooted at the SPEC §3 path for projectKey.
|
||||
func Open(projectKey string) (*Store, error) {
|
||||
base, err := DataDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := filepath.Join(base, "projects", projectKey, "scratchpads")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("scratchpad: mkdir %s: %w", dir, err)
|
||||
}
|
||||
return &Store{dir: dir}, nil
|
||||
}
|
||||
|
||||
// DataDir resolves $XDG_DATA_HOME/patterm with the conventional fallback.
|
||||
func DataDir() (string, error) {
|
||||
if h := os.Getenv("XDG_DATA_HOME"); h != "" {
|
||||
return filepath.Join(h, "patterm"), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".local", "share", "patterm"), nil
|
||||
}
|
||||
|
||||
// Entry is what List returns; SPEC §7 `scratchpad_list` shape.
|
||||
type Entry struct {
|
||||
Name string
|
||||
Size int64
|
||||
ModifiedAt string // RFC3339; kept as string so MCP serialization is trivial later
|
||||
}
|
||||
|
||||
func (s *Store) Dir() string { return s.dir }
|
||||
|
||||
func (s *Store) List() ([]Entry, error) {
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Entry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, Entry{
|
||||
Name: e.Name(),
|
||||
Size: info.Size(),
|
||||
ModifiedAt: info.ModTime().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) Read(name string) (content string, revision string, err error) {
|
||||
p, err := s.safePath(name)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return string(b), revisionOf(b), nil
|
||||
}
|
||||
|
||||
// Write replaces the file's contents. expectedRevision, if non-empty,
|
||||
// must match the current revision or the write is rejected (SPEC §14
|
||||
// last-write-wins-with-token).
|
||||
func (s *Store) Write(name, content, expectedRevision string) (string, error) {
|
||||
p, err := s.safePath(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if expectedRevision != "" {
|
||||
if cur, err := os.ReadFile(p); err == nil {
|
||||
if revisionOf(cur) != expectedRevision {
|
||||
return "", fmt.Errorf("scratchpad: revision mismatch")
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return revisionOf([]byte(content)), nil
|
||||
}
|
||||
|
||||
func (s *Store) Append(name, content string) error {
|
||||
p, err := s.safePath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.WriteString(content)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) safePath(name string) (string, error) {
|
||||
if name == "" || strings.ContainsAny(name, "/\\") || name == "." || name == ".." {
|
||||
return "", errors.New("scratchpad: invalid name")
|
||||
}
|
||||
return filepath.Join(s.dir, name), nil
|
||||
}
|
||||
|
||||
func revisionOf(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:6])
|
||||
}
|
||||
62
internal/vt/emulator.go
Normal file
62
internal/vt/emulator.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Package vt wraps a headless virtual terminal emulator behind a small
|
||||
// Go interface. The intent is that all cgo to libghostty-vt is confined
|
||||
// to the GhosttyEmulator implementation in this package.
|
||||
package vt
|
||||
|
||||
// Screen identifies which buffer is currently displayed.
|
||||
type Screen uint8
|
||||
|
||||
const (
|
||||
ScreenPrimary Screen = iota
|
||||
ScreenAlternate
|
||||
)
|
||||
|
||||
// CursorState is a snapshot of cursor position and visibility.
|
||||
type CursorState struct {
|
||||
Col, Row uint16
|
||||
Visible bool
|
||||
}
|
||||
|
||||
// Emulator is the headless VT used by the daemon (and by the milestone-1 spike).
|
||||
//
|
||||
// Implementations are not required to be safe for concurrent use. The spike
|
||||
// CLI funnels all calls through a single goroutine.
|
||||
type Emulator interface {
|
||||
// Write feeds bytes from the PTY master into the emulator. It returns
|
||||
// the number of bytes consumed (always len(p) on success).
|
||||
Write(p []byte) (int, error)
|
||||
|
||||
// Resize updates the emulator's cell grid. The caller is responsible
|
||||
// for issuing TIOCSWINSZ on the PTY itself.
|
||||
Resize(cols, rows uint16) error
|
||||
|
||||
// PlainText returns the active screen rendered as plain text, with
|
||||
// soft-wrapped lines unwrapped and trailing whitespace trimmed.
|
||||
PlainText() (string, error)
|
||||
|
||||
// ScreenText returns the active screen as fixed screen rows. Unlike
|
||||
// PlainText, this preserves row boundaries so a host UI can repaint
|
||||
// into a clipped viewport.
|
||||
ScreenText() (string, error)
|
||||
|
||||
// SerializeVT returns the active screen as a VT byte sequence that, when
|
||||
// written to a fresh terminal, reproduces the visible state (colours,
|
||||
// styles, cursor, hyperlinks, etc.). Used as the daemon's "catch-up
|
||||
// frame" for newly-attached clients.
|
||||
SerializeVT() ([]byte, error)
|
||||
|
||||
// Cursor returns cursor position and visibility on the active screen.
|
||||
Cursor() (CursorState, error)
|
||||
|
||||
// ActiveScreen reports whether we are on the primary or alternate buffer.
|
||||
ActiveScreen() (Screen, error)
|
||||
|
||||
// OnWritePTY registers a callback that fires when the emulator wants
|
||||
// to write bytes back to the PTY master (e.g. responses to DA / DSR
|
||||
// queries). The callback runs synchronously inside Write and must not
|
||||
// recurse into the emulator.
|
||||
OnWritePTY(fn func([]byte))
|
||||
|
||||
// Close releases any underlying resources.
|
||||
Close() error
|
||||
}
|
||||
390
internal/vt/ghostty.go
Normal file
390
internal/vt/ghostty.go
Normal file
@@ -0,0 +1,390 @@
|
||||
//go:build !nocgo
|
||||
|
||||
package vt
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -I${SRCDIR}/../../third_party/libghostty-vt/install/include -DGHOSTTY_STATIC
|
||||
#cgo LDFLAGS: -L${SRCDIR}/../../third_party/libghostty-vt/install/lib -l:libghostty-vt.a -lm -lpthread
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdlib.h>
|
||||
#include <ghostty/vt.h>
|
||||
|
||||
// Forward declaration of the exported Go callback (defined in ghostty_cgo.go).
|
||||
extern void pattermGhosttyWritePty(GhosttyTerminal terminal,
|
||||
void *userdata,
|
||||
const uint8_t *data,
|
||||
size_t len);
|
||||
|
||||
// Constant device-attributes response. vim/htop/etc. send DA1 (CSI c) on
|
||||
// startup and block waiting for a reply; without this they hang forever.
|
||||
// Conformance 62 = VT220-class with no advertised features, which is what
|
||||
// kitty advertises and is enough for every TUI we've tested.
|
||||
static bool patterm_da_cb(GhosttyTerminal terminal,
|
||||
void *userdata,
|
||||
GhosttyDeviceAttributes *out) {
|
||||
(void)terminal; (void)userdata;
|
||||
out->primary.conformance_level = 62;
|
||||
out->primary.num_features = 0;
|
||||
out->secondary.device_type = 1; // VT220
|
||||
out->secondary.firmware_version = 100; // arbitrary
|
||||
out->secondary.rom_cartridge = 0;
|
||||
out->tertiary.unit_id = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Constant XTVERSION response. Some agent TUIs query this; without a
|
||||
// response they wait. The GhosttyString memory must stay valid until the
|
||||
// callback returns — a static const string is fine.
|
||||
static GhosttyString patterm_xtversion_cb(GhosttyTerminal terminal,
|
||||
void *userdata) {
|
||||
(void)terminal; (void)userdata;
|
||||
static const char ver[] = "patterm 0.0.1";
|
||||
GhosttyString s;
|
||||
s.ptr = (const uint8_t *)ver;
|
||||
s.len = sizeof(ver) - 1;
|
||||
return s;
|
||||
}
|
||||
|
||||
// Constant ENQ response (empty). Some shells send ENQ on startup.
|
||||
static GhosttyString patterm_enq_cb(GhosttyTerminal terminal, void *userdata) {
|
||||
(void)terminal; (void)userdata;
|
||||
GhosttyString s; s.ptr = NULL; s.len = 0;
|
||||
return s;
|
||||
}
|
||||
|
||||
// Helpers that hide casts cgo can't express directly.
|
||||
|
||||
static GhosttyResult patterm_install_write_pty(GhosttyTerminal t) {
|
||||
return ghostty_terminal_set(t,
|
||||
GHOSTTY_TERMINAL_OPT_WRITE_PTY,
|
||||
(const void *)pattermGhosttyWritePty);
|
||||
}
|
||||
|
||||
static GhosttyResult patterm_install_query_handlers(GhosttyTerminal t) {
|
||||
GhosttyResult rc;
|
||||
rc = ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES,
|
||||
(const void *)patterm_da_cb);
|
||||
if (rc != GHOSTTY_SUCCESS) return rc;
|
||||
rc = ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_XTVERSION,
|
||||
(const void *)patterm_xtversion_cb);
|
||||
if (rc != GHOSTTY_SUCCESS) return rc;
|
||||
rc = ghostty_terminal_set(t, GHOSTTY_TERMINAL_OPT_ENQUIRY,
|
||||
(const void *)patterm_enq_cb);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static GhosttyResult patterm_set_userdata(GhosttyTerminal t, uintptr_t ud) {
|
||||
return ghostty_terminal_set(t,
|
||||
GHOSTTY_TERMINAL_OPT_USERDATA,
|
||||
(const void *)ud);
|
||||
}
|
||||
|
||||
static GhosttyFormatterTerminalOptions patterm_plain_fmt_opts(void) {
|
||||
GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions);
|
||||
opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN;
|
||||
opts.unwrap = true;
|
||||
opts.trim = true;
|
||||
return opts;
|
||||
}
|
||||
|
||||
static GhosttyFormatterTerminalOptions patterm_screen_fmt_opts(void) {
|
||||
GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions);
|
||||
opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN;
|
||||
opts.unwrap = false;
|
||||
opts.trim = false;
|
||||
return opts;
|
||||
}
|
||||
|
||||
// VT-format options for the daemon catch-up frame. Emits the active screen
|
||||
// as VT escape sequences with cursor, style, hyperlink, mode, and tabstop
|
||||
// state included so a freshly-attached client renders the existing screen
|
||||
// correctly. unwrap/trim are NOT set — preserving wrap state and trailing
|
||||
// cells is important for a faithful replay.
|
||||
static GhosttyFormatterTerminalOptions patterm_vt_fmt_opts(void) {
|
||||
GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions);
|
||||
opts.emit = GHOSTTY_FORMATTER_FORMAT_VT;
|
||||
opts.extra.modes = true;
|
||||
opts.extra.scrolling_region = true;
|
||||
opts.extra.tabstops = true;
|
||||
opts.extra.screen.cursor = true;
|
||||
opts.extra.screen.style = true;
|
||||
opts.extra.screen.hyperlink = true;
|
||||
return opts;
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"runtime/cgo"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// GhosttyEmulator is the libghostty-vt-backed Emulator implementation.
|
||||
//
|
||||
// The C terminal handle is not thread-safe. Callers must serialise access;
|
||||
// the spike CLI does this by running all calls on one goroutine, so the
|
||||
// mutex below is a defensive belt-and-braces rather than the primary
|
||||
// safety mechanism.
|
||||
type GhosttyEmulator struct {
|
||||
mu sync.Mutex
|
||||
term C.GhosttyTerminal
|
||||
handle cgo.Handle
|
||||
closed bool
|
||||
// onWrite is read from a cgo callback that is invoked synchronously
|
||||
// from inside Write() — i.e. while e.mu is already held by this
|
||||
// goroutine. Taking the mutex again would deadlock, so the field is
|
||||
// stored atomically and read without the mutex.
|
||||
onWrite atomic.Pointer[writeCallback]
|
||||
cols uint16
|
||||
rows uint16
|
||||
}
|
||||
|
||||
// writeCallback wraps the callback func so it can sit in atomic.Pointer.
|
||||
type writeCallback struct{ fn func([]byte) }
|
||||
|
||||
// NewGhosttyEmulator creates a new emulator with the given grid size.
|
||||
func NewGhosttyEmulator(cols, rows uint16) (*GhosttyEmulator, error) {
|
||||
if cols == 0 || rows == 0 {
|
||||
return nil, fmt.Errorf("vt: cols and rows must be > 0 (got %dx%d)", cols, rows)
|
||||
}
|
||||
|
||||
e := &GhosttyEmulator{cols: cols, rows: rows}
|
||||
|
||||
opts := C.GhosttyTerminalOptions{
|
||||
cols: C.uint16_t(cols),
|
||||
rows: C.uint16_t(rows),
|
||||
max_scrollback: 0,
|
||||
}
|
||||
|
||||
if rc := C.ghostty_terminal_new(nil, &e.term, opts); rc != C.GHOSTTY_SUCCESS {
|
||||
return nil, fmt.Errorf("vt: ghostty_terminal_new failed: %s", ghosttyResultStr(rc))
|
||||
}
|
||||
|
||||
// Park ourselves in cgo's handle table so the C callback can find us.
|
||||
e.handle = cgo.NewHandle(e)
|
||||
|
||||
if rc := C.patterm_set_userdata(e.term, C.uintptr_t(uintptr(e.handle))); rc != C.GHOSTTY_SUCCESS {
|
||||
e.handle.Delete()
|
||||
C.ghostty_terminal_free(e.term)
|
||||
return nil, fmt.Errorf("vt: set userdata failed: %s", ghosttyResultStr(rc))
|
||||
}
|
||||
|
||||
if rc := C.patterm_install_write_pty(e.term); rc != C.GHOSTTY_SUCCESS {
|
||||
e.handle.Delete()
|
||||
C.ghostty_terminal_free(e.term)
|
||||
return nil, fmt.Errorf("vt: install write_pty failed: %s", ghosttyResultStr(rc))
|
||||
}
|
||||
|
||||
if rc := C.patterm_install_query_handlers(e.term); rc != C.GHOSTTY_SUCCESS {
|
||||
e.handle.Delete()
|
||||
C.ghostty_terminal_free(e.term)
|
||||
return nil, fmt.Errorf("vt: install query handlers failed: %s", ghosttyResultStr(rc))
|
||||
}
|
||||
|
||||
// Make sure Close runs even if the caller forgets. Programs that hold
|
||||
// the emulator for their full lifetime can ignore this.
|
||||
runtime.SetFinalizer(e, func(x *GhosttyEmulator) { _ = x.Close() })
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (e *GhosttyEmulator) Write(p []byte) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.closed {
|
||||
return 0, errors.New("vt: emulator closed")
|
||||
}
|
||||
C.ghostty_terminal_vt_write(
|
||||
e.term,
|
||||
(*C.uint8_t)(unsafe.Pointer(&p[0])),
|
||||
C.size_t(len(p)),
|
||||
)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (e *GhosttyEmulator) Resize(cols, rows uint16) error {
|
||||
if cols == 0 || rows == 0 {
|
||||
return fmt.Errorf("vt: cols and rows must be > 0 (got %dx%d)", cols, rows)
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.closed {
|
||||
return errors.New("vt: emulator closed")
|
||||
}
|
||||
rc := C.ghostty_terminal_resize(e.term,
|
||||
C.uint16_t(cols), C.uint16_t(rows),
|
||||
0, 0, // pixel dimensions: we don't use image protocols in the spike
|
||||
)
|
||||
if rc != C.GHOSTTY_SUCCESS {
|
||||
return fmt.Errorf("vt: resize failed: %s", ghosttyResultStr(rc))
|
||||
}
|
||||
e.cols, e.rows = cols, rows
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *GhosttyEmulator) PlainText() (string, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.closed {
|
||||
return "", errors.New("vt: emulator closed")
|
||||
}
|
||||
|
||||
opts := C.patterm_plain_fmt_opts()
|
||||
return e.formatPlainLocked(opts)
|
||||
}
|
||||
|
||||
func (e *GhosttyEmulator) ScreenText() (string, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.closed {
|
||||
return "", errors.New("vt: emulator closed")
|
||||
}
|
||||
|
||||
opts := C.patterm_screen_fmt_opts()
|
||||
return e.formatPlainLocked(opts)
|
||||
}
|
||||
|
||||
func (e *GhosttyEmulator) formatPlainLocked(opts C.GhosttyFormatterTerminalOptions) (string, error) {
|
||||
var fmtr C.GhosttyFormatter
|
||||
if rc := C.ghostty_formatter_terminal_new(nil, &fmtr, e.term, opts); rc != C.GHOSTTY_SUCCESS {
|
||||
return "", fmt.Errorf("vt: formatter_terminal_new failed: %s", ghosttyResultStr(rc))
|
||||
}
|
||||
defer C.ghostty_formatter_free(fmtr)
|
||||
|
||||
var buf *C.uint8_t
|
||||
var n C.size_t
|
||||
if rc := C.ghostty_formatter_format_alloc(fmtr, nil, &buf, &n); rc != C.GHOSTTY_SUCCESS {
|
||||
return "", fmt.Errorf("vt: format_alloc failed: %s", ghosttyResultStr(rc))
|
||||
}
|
||||
defer C.ghostty_free(nil, buf, n)
|
||||
|
||||
if buf == nil || n == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return C.GoStringN((*C.char)(unsafe.Pointer(buf)), C.int(n)), nil
|
||||
}
|
||||
|
||||
func (e *GhosttyEmulator) SerializeVT() ([]byte, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.closed {
|
||||
return nil, errors.New("vt: emulator closed")
|
||||
}
|
||||
|
||||
opts := C.patterm_vt_fmt_opts()
|
||||
var fmtr C.GhosttyFormatter
|
||||
if rc := C.ghostty_formatter_terminal_new(nil, &fmtr, e.term, opts); rc != C.GHOSTTY_SUCCESS {
|
||||
return nil, fmt.Errorf("vt: formatter_terminal_new (vt) failed: %s", ghosttyResultStr(rc))
|
||||
}
|
||||
defer C.ghostty_formatter_free(fmtr)
|
||||
|
||||
var buf *C.uint8_t
|
||||
var n C.size_t
|
||||
if rc := C.ghostty_formatter_format_alloc(fmtr, nil, &buf, &n); rc != C.GHOSTTY_SUCCESS {
|
||||
return nil, fmt.Errorf("vt: format_alloc (vt) failed: %s", ghosttyResultStr(rc))
|
||||
}
|
||||
defer C.ghostty_free(nil, buf, n)
|
||||
|
||||
if buf == nil || n == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return C.GoBytes(unsafe.Pointer(buf), C.int(n)), nil
|
||||
}
|
||||
|
||||
func (e *GhosttyEmulator) Cursor() (CursorState, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.closed {
|
||||
return CursorState{}, errors.New("vt: emulator closed")
|
||||
}
|
||||
var col, row C.uint16_t
|
||||
var visible C.bool
|
||||
if rc := C.ghostty_terminal_get(e.term, C.GHOSTTY_TERMINAL_DATA_CURSOR_X, unsafe.Pointer(&col)); rc != C.GHOSTTY_SUCCESS {
|
||||
return CursorState{}, fmt.Errorf("vt: get cursor_x failed: %s", ghosttyResultStr(rc))
|
||||
}
|
||||
if rc := C.ghostty_terminal_get(e.term, C.GHOSTTY_TERMINAL_DATA_CURSOR_Y, unsafe.Pointer(&row)); rc != C.GHOSTTY_SUCCESS {
|
||||
return CursorState{}, fmt.Errorf("vt: get cursor_y failed: %s", ghosttyResultStr(rc))
|
||||
}
|
||||
if rc := C.ghostty_terminal_get(e.term, C.GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE, unsafe.Pointer(&visible)); rc != C.GHOSTTY_SUCCESS {
|
||||
return CursorState{}, fmt.Errorf("vt: get cursor_visible failed: %s", ghosttyResultStr(rc))
|
||||
}
|
||||
return CursorState{Col: uint16(col), Row: uint16(row), Visible: bool(visible)}, nil
|
||||
}
|
||||
|
||||
func (e *GhosttyEmulator) ActiveScreen() (Screen, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.closed {
|
||||
return 0, errors.New("vt: emulator closed")
|
||||
}
|
||||
var s C.GhosttyTerminalScreen
|
||||
if rc := C.ghostty_terminal_get(e.term, C.GHOSTTY_TERMINAL_DATA_ACTIVE_SCREEN, unsafe.Pointer(&s)); rc != C.GHOSTTY_SUCCESS {
|
||||
return 0, fmt.Errorf("vt: get active_screen failed: %s", ghosttyResultStr(rc))
|
||||
}
|
||||
if s == C.GHOSTTY_TERMINAL_SCREEN_ALTERNATE {
|
||||
return ScreenAlternate, nil
|
||||
}
|
||||
return ScreenPrimary, nil
|
||||
}
|
||||
|
||||
func (e *GhosttyEmulator) OnWritePTY(fn func([]byte)) {
|
||||
if fn == nil {
|
||||
e.onWrite.Store(nil)
|
||||
return
|
||||
}
|
||||
e.onWrite.Store(&writeCallback{fn: fn})
|
||||
}
|
||||
|
||||
// writePTYCallback is called from the exported cgo shim. It runs inside a
|
||||
// vt_write() that already owns e.mu, so it MUST NOT take the mutex.
|
||||
func (e *GhosttyEmulator) writePTYCallback() func([]byte) {
|
||||
cb := e.onWrite.Load()
|
||||
if cb == nil {
|
||||
return nil
|
||||
}
|
||||
return cb.fn
|
||||
}
|
||||
|
||||
func (e *GhosttyEmulator) Close() error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.closed {
|
||||
return nil
|
||||
}
|
||||
e.closed = true
|
||||
runtime.SetFinalizer(e, nil)
|
||||
C.ghostty_terminal_free(e.term)
|
||||
e.term = nil
|
||||
e.handle.Delete()
|
||||
return nil
|
||||
}
|
||||
|
||||
func ghosttyResultStr(rc C.GhosttyResult) string {
|
||||
switch rc {
|
||||
case C.GHOSTTY_SUCCESS:
|
||||
return "SUCCESS"
|
||||
case C.GHOSTTY_OUT_OF_MEMORY:
|
||||
return "OUT_OF_MEMORY"
|
||||
case C.GHOSTTY_INVALID_VALUE:
|
||||
return "INVALID_VALUE"
|
||||
case C.GHOSTTY_OUT_OF_SPACE:
|
||||
return "OUT_OF_SPACE"
|
||||
case C.GHOSTTY_NO_VALUE:
|
||||
return "NO_VALUE"
|
||||
default:
|
||||
return fmt.Sprintf("unknown(%d)", int(rc))
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time assertion that GhosttyEmulator satisfies Emulator.
|
||||
var _ Emulator = (*GhosttyEmulator)(nil)
|
||||
38
internal/vt/ghostty_cgo.go
Normal file
38
internal/vt/ghostty_cgo.go
Normal file
@@ -0,0 +1,38 @@
|
||||
//go:build !nocgo
|
||||
|
||||
package vt
|
||||
|
||||
/*
|
||||
// This preamble must contain DECLARATIONS ONLY — cgo refuses to compile
|
||||
// a file that both defines functions in its preamble and has //export
|
||||
// directives. The helper definitions live in ghostty.go's preamble.
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <ghostty/vt.h>
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"runtime/cgo"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
//export pattermGhosttyWritePty
|
||||
func pattermGhosttyWritePty(_ C.GhosttyTerminal, userdata unsafe.Pointer, data *C.uint8_t, length C.size_t) {
|
||||
if userdata == nil || data == nil || length == 0 {
|
||||
return
|
||||
}
|
||||
h := cgo.Handle(uintptr(userdata))
|
||||
v := h.Value()
|
||||
e, ok := v.(*GhosttyEmulator)
|
||||
if !ok || e == nil {
|
||||
return
|
||||
}
|
||||
cb := e.writePTYCallback()
|
||||
if cb == nil {
|
||||
return
|
||||
}
|
||||
buf := C.GoBytes(unsafe.Pointer(data), C.int(length))
|
||||
cb(buf)
|
||||
}
|
||||
30
internal/vt/ghostty_nocgo.go
Normal file
30
internal/vt/ghostty_nocgo.go
Normal file
@@ -0,0 +1,30 @@
|
||||
//go:build nocgo
|
||||
|
||||
// This file provides a stub GhosttyEmulator for `go vet` / `go build`
|
||||
// invocations that pass the `nocgo` build tag, so the rest of the Go code can
|
||||
// be checked without `libghostty-vt` being installed. The stub fails at
|
||||
// construction time — there is no functional emulator in `nocgo` builds.
|
||||
|
||||
package vt
|
||||
|
||||
import "errors"
|
||||
|
||||
type GhosttyEmulator struct{}
|
||||
|
||||
func NewGhosttyEmulator(cols, rows uint16) (*GhosttyEmulator, error) {
|
||||
return nil, errors.New("vt: built with -tags nocgo; libghostty-vt is unavailable")
|
||||
}
|
||||
|
||||
func (e *GhosttyEmulator) Write(p []byte) (int, error) { return 0, errStub }
|
||||
func (e *GhosttyEmulator) Resize(cols, rows uint16) error { return errStub }
|
||||
func (e *GhosttyEmulator) PlainText() (string, error) { return "", errStub }
|
||||
func (e *GhosttyEmulator) ScreenText() (string, error) { return "", errStub }
|
||||
func (e *GhosttyEmulator) SerializeVT() ([]byte, error) { return nil, errStub }
|
||||
func (e *GhosttyEmulator) Cursor() (CursorState, error) { return CursorState{}, errStub }
|
||||
func (e *GhosttyEmulator) ActiveScreen() (Screen, error) { return 0, errStub }
|
||||
func (e *GhosttyEmulator) OnWritePTY(fn func([]byte)) {}
|
||||
func (e *GhosttyEmulator) Close() error { return nil }
|
||||
|
||||
var errStub = errors.New("vt: built with -tags nocgo")
|
||||
|
||||
var _ Emulator = (*GhosttyEmulator)(nil)
|
||||
Reference in New Issue
Block a user