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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user