Some checks failed
release / build-linux-amd64 (push) Failing after 10m52s
Bundles the in-flight work into the first tagged release. See CHANGELOG.md `[0.0.1] - 2026-05-14` for the full per-change list. Highlights: - Sidebar / chrome stability: clamp absolute cursor positioning and printable bytes to the viewport so long-running TUIs (claude, codex) can't spray into the right rail; bound tab bar's row clear to the viewport width so the rail isn't wiped on every tab redraw; flag scroll escapes (RI/IND/NEL/SU/SD/IL/DL) and clamp `CSI 0/1/2 J`/`K` to viewport columns. - Palette: "Spawn process…" form, macros (`sw `, `k `, `sp `), kill entries mark the focused tab, dead agents drop out of the switch list. - Sidebar: split into Processes (session-wide) + Agent Tree (per-active-agent) sections; relaunch indicator; Ctrl+W/S walks the combined list, Ctrl+A/D steps tabs. - MCP: protocol handshake (`initialize`, `tools/list`, `tools/call`, `ping`), `mcp_injection.kind = cli_override / config_env` so codex and opencode pick up the server with no file writes, `lifecycle` help topic and tool-description cleanup-duty pointers. - Lifecycle: orchestrator-spawned children cascade-killed when the parent dies; orchestrator-injected prompts end with CR + delayed Enter so claude submits cleanly.
1183 lines
33 KiB
Go
1183 lines
33 KiB
Go
package app
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"os/signal"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"syscall"
|
||
"time"
|
||
|
||
cpty "github.com/creack/pty"
|
||
"golang.org/x/term"
|
||
|
||
"github.com/hjbdev/patterm/internal/mcp"
|
||
"github.com/hjbdev/patterm/internal/preset"
|
||
"github.com/hjbdev/patterm/internal/scratchpad"
|
||
"github.com/hjbdev/patterm/internal/trust"
|
||
)
|
||
|
||
// 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)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// Per-project trust store for command-preset trust gating (SPEC §7).
|
||
trustStore, err := trust.Open(opts.ProjectKey)
|
||
if err != nil {
|
||
return fmt.Errorf("app: trust 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, trustStore, 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,
|
||
trust: trustStore,
|
||
hostCols: cols,
|
||
hostRows: rows,
|
||
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
|
||
}
|
||
host.attention = st
|
||
host.focus = st
|
||
host.prompter = st
|
||
host.scratch = 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
|
||
trust *trust.Store
|
||
|
||
outMu sync.Mutex
|
||
|
||
mu sync.Mutex
|
||
palette *paletteState
|
||
focusedID string
|
||
focusedName string
|
||
// activeAgentID tracks which top-level agent tab "owns" the agent
|
||
// tree section of the sidebar. It only updates when focus lands on
|
||
// an agent (or one of its sub-agents), so the agent tree stays
|
||
// visible even when the user steps into the Processes pane.
|
||
activeAgentID 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
|
||
repaintNextPTY string
|
||
repaintNextPTYBudget int
|
||
|
||
// attention is the latest request_human_attention surfaced via MCP;
|
||
// rendered in the status line until cleared.
|
||
attentionText string
|
||
attentionAt string
|
||
|
||
// pendingTrust is the most recent trust prompt — surfaced in the
|
||
// status line until the user resolves it with Ctrl-K. v1 keeps the
|
||
// confirmation modal minimal: the user opens the palette and picks
|
||
// "Trust preset <name>" / "Deny preset <name>". A future iteration
|
||
// can promote this to a dedicated inline modal.
|
||
pendingTrust *trustRequest
|
||
|
||
dimsMu sync.Mutex
|
||
hostCols, hostRows uint16
|
||
stdinTTY bool
|
||
|
||
// chromeCacheMu guards the last-rendered byte cache for each chrome
|
||
// element. The tab bar, sidebar, and status line all repaint on
|
||
// many state changes and on every PTY chunk, but their content
|
||
// usually doesn't change between calls — caching the rendered
|
||
// output and skipping a write when it matches eliminates the
|
||
// flicker (especially in the sidebar's session tree).
|
||
chromeCacheMu sync.Mutex
|
||
tabBarCache string
|
||
sidebarCache string
|
||
statusLineCache string
|
||
|
||
lastExit atomic.Int32
|
||
}
|
||
|
||
func (st *uiState) dbgf(format string, args ...any) {
|
||
logf(format, args...)
|
||
}
|
||
|
||
// trustRequest is one outstanding SPEC §7 trust prompt: an agent tried
|
||
// to spawn / start / restart against an untrusted command preset and
|
||
// the host wants user confirmation before the next attempt succeeds.
|
||
type trustRequest struct {
|
||
processID string
|
||
presetName string
|
||
reason string
|
||
}
|
||
|
||
// promptTrust is the SPEC §7 trust gate UI hook. Replaces any prior
|
||
// pending request — the most recent prompt wins.
|
||
func (st *uiState) promptTrust(processID, presetName, reason string) {
|
||
st.mu.Lock()
|
||
st.pendingTrust = &trustRequest{processID: processID, presetName: presetName, reason: reason}
|
||
st.mu.Unlock()
|
||
st.drawStatusLine()
|
||
}
|
||
|
||
// focusProcess is the SPEC §7 select_process hook. Routes through the
|
||
// normal focus-change path; only takes effect if the process exists.
|
||
func (st *uiState) focusProcess(processID string) {
|
||
c := st.sess.FindChild(processID)
|
||
if c == nil {
|
||
return
|
||
}
|
||
layout := st.layoutSnapshot()
|
||
st.mu.Lock()
|
||
st.focusedID = c.ID
|
||
st.focusedName = c.DisplayName()
|
||
st.updateActiveAgentLocked(c)
|
||
st.renderer = newViewportRenderer(layout)
|
||
st.mu.Unlock()
|
||
st.repaintFocused()
|
||
st.drawTabBar()
|
||
st.drawSidebar()
|
||
st.drawStatusLine()
|
||
}
|
||
|
||
// updateActiveAgentLocked records the active agent root for the agent
|
||
// tree section whenever focus lands on an agent or one of its
|
||
// sub-agents. Focusing a top-level command process leaves the previous
|
||
// active agent intact, so the user can hop between the Processes pane
|
||
// and the agent tree without losing context. Caller holds st.mu.
|
||
func (st *uiState) updateActiveAgentLocked(c *Child) {
|
||
if c.Kind != KindAgent {
|
||
return
|
||
}
|
||
if c.ParentID == "" {
|
||
st.activeAgentID = c.ID
|
||
return
|
||
}
|
||
// Walk up to the top-level agent.
|
||
root := c
|
||
for root.ParentID != "" {
|
||
parent := st.sess.FindChild(root.ParentID)
|
||
if parent == nil {
|
||
break
|
||
}
|
||
root = parent
|
||
}
|
||
if root.Kind == KindAgent && root.ParentID == "" {
|
||
st.activeAgentID = root.ID
|
||
}
|
||
}
|
||
|
||
// 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.DisplayName()
|
||
}
|
||
st.mu.Lock()
|
||
st.attentionText = fmt.Sprintf("attention: %s — %s", name, reason)
|
||
st.attentionAt = childID
|
||
st.mu.Unlock()
|
||
st.drawStatusLine()
|
||
}
|
||
|
||
func (st *uiState) scratchpadsChanged() {
|
||
st.chromeCacheMu.Lock()
|
||
st.sidebarCache = ""
|
||
st.chromeCacheMu.Unlock()
|
||
st.drawSidebar()
|
||
}
|
||
|
||
// OnChildSpawned auto-focuses the new child.
|
||
func (st *uiState) OnChildSpawned(c *Child) {
|
||
layout := st.layoutSnapshot()
|
||
st.mu.Lock()
|
||
st.focusedID = c.ID
|
||
st.focusedName = c.DisplayName()
|
||
st.updateActiveAgentLocked(c)
|
||
renderer := newViewportRenderer(layout)
|
||
st.renderer = renderer
|
||
palOpen := st.palette != nil
|
||
if palOpen {
|
||
st.palette.children = st.sess.Children()
|
||
st.palette.focused = st.focusedID
|
||
st.palette.rebuild()
|
||
st.renderPaletteLocked()
|
||
}
|
||
// Prime the snapshot-replay budget for the new child. Diff-based
|
||
// vendor TUIs (claude/codex/opencode) emit incremental updates that
|
||
// assume the host display already matches their internal "last
|
||
// frame" model. On a fresh spawn the host viewport was just cleared,
|
||
// so incremental ops target cells that aren't populated yet —
|
||
// leaving the corrupted pane the user works around by toggling
|
||
// focus (which routes through repaintFocused). Setting the budget
|
||
// here makes the next ~8 PTY chunks render from the full styled
|
||
// emulator grid, so the host display tracks the emulator state
|
||
// without needing a manual focus cycle.
|
||
st.repaintNextPTY = c.ID
|
||
st.repaintNextPTYBudget = 8
|
||
st.mu.Unlock()
|
||
|
||
// Wipe the viewport area so the previous focused child's PTY
|
||
// output doesn't bleed through beneath the new pane. The palette
|
||
// branch is skipped because the palette overlay covers the whole
|
||
// screen and is about to take focus back to OnChildSpawned's
|
||
// caller path.
|
||
if !palOpen {
|
||
st.outMu.Lock()
|
||
_, _ = os.Stdout.Write(renderer.ClearViewport())
|
||
st.outMu.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()))
|
||
layout := st.layoutSnapshot()
|
||
renderEmpty := false
|
||
st.mu.Lock()
|
||
if c.ID == st.focusedID {
|
||
next := firstRunningTopLevel(st.sess.Children())
|
||
if next == nil {
|
||
st.focusedID = ""
|
||
st.focusedName = ""
|
||
renderEmpty = true
|
||
} else {
|
||
st.focusedID = next.ID
|
||
st.focusedName = next.DisplayName()
|
||
st.updateActiveAgentLocked(next)
|
||
st.renderer = newViewportRenderer(layout)
|
||
}
|
||
}
|
||
if c.ID == st.activeAgentID {
|
||
// The active agent died; pin the agent tree to whatever agent
|
||
// root is still running, or clear it if none remain.
|
||
st.activeAgentID = firstRunningAgentID(st.sess.Children())
|
||
}
|
||
if st.palette != nil {
|
||
st.palette.children = st.sess.Children()
|
||
st.palette.focused = st.focusedID
|
||
st.palette.rebuild()
|
||
st.renderPaletteLocked()
|
||
}
|
||
repaint := st.focusedID != ""
|
||
st.mu.Unlock()
|
||
if renderEmpty {
|
||
st.renderEmptyState()
|
||
}
|
||
if repaint {
|
||
st.repaintFocused()
|
||
}
|
||
st.drawTabBar()
|
||
st.drawSidebar()
|
||
st.drawStatusLine()
|
||
|
||
// Auto-restart kicks in for command entries the user marked "relaunch
|
||
// on exit". A short backoff (1s) avoids hot-spinning on processes
|
||
// that fail immediately. The user can clear the flag by killing the
|
||
// process from the palette.
|
||
if c.Kind == KindCommand && c.AutoRestart() {
|
||
go st.scheduleAutoRestart(c)
|
||
}
|
||
}
|
||
|
||
// scheduleAutoRestart re-Starts a command entry after a brief backoff.
|
||
// Bails out if the user cleared the flag, closed the process, or the
|
||
// entry came back to life through some other path while we were
|
||
// waiting. Called as a goroutine from OnChildExited.
|
||
func (st *uiState) scheduleAutoRestart(c *Child) {
|
||
time.Sleep(1 * time.Second)
|
||
if !c.AutoRestart() {
|
||
return
|
||
}
|
||
if st.sess.FindChild(c.ID) == nil {
|
||
return
|
||
}
|
||
if c.IsLive() {
|
||
return
|
||
}
|
||
l := st.layoutSnapshot()
|
||
if err := st.sess.Start(c.ID, l.childCols(), l.childRows()); err != nil {
|
||
st.dbgf("auto-restart %s: %v", c.ID, err)
|
||
return
|
||
}
|
||
// Start doesn't fire emitSpawn, so we have to nudge the chrome
|
||
// ourselves — the status flipped from exited back to running and
|
||
// the sidebar's cached frame still shows the exited glyph.
|
||
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) {
|
||
layout := st.layoutSnapshot()
|
||
st.mu.Lock()
|
||
focus := st.focusedID
|
||
palOpen := st.palette != nil
|
||
renderer := st.renderer
|
||
forceRepaint := focus == childID && st.repaintNextPTY == childID && st.repaintNextPTYBudget > 0
|
||
if forceRepaint {
|
||
renderer = newViewportRenderer(layout)
|
||
st.renderer = renderer
|
||
st.repaintNextPTYBudget--
|
||
if st.repaintNextPTYBudget == 0 {
|
||
st.repaintNextPTY = ""
|
||
}
|
||
}
|
||
st.mu.Unlock()
|
||
if palOpen || focus != childID || renderer == nil {
|
||
return
|
||
}
|
||
var out []byte
|
||
if forceRepaint {
|
||
out = st.renderFocusedSnapshot(childID, renderer, layout)
|
||
if len(out) == 0 {
|
||
return
|
||
}
|
||
} else {
|
||
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()
|
||
// RI / IND / NEL / SU / SD / IL / DL scroll content within the host's
|
||
// scroll region, which spans every column — so any of them drags the
|
||
// right-hand sidebar's session-tree entries downward along with the
|
||
// main pane. (Codex emits an 8× RI burst on startup, which produced
|
||
// the original report.) The viewport renderer flags any chunk that
|
||
// contained one of those escapes; when set, drop the sidebar cache
|
||
// so the next drawSidebar repaints over the clobber instead of
|
||
// hitting the cache and leaving the gap visible.
|
||
scrolled := renderer.TookScrollAction()
|
||
if scrolled {
|
||
st.chromeCacheMu.Lock()
|
||
st.sidebarCache = ""
|
||
st.chromeCacheMu.Unlock()
|
||
}
|
||
st.drawTabBar()
|
||
if scrolled {
|
||
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.invalidateChromeCache()
|
||
st.outMu.Lock()
|
||
defer st.outMu.Unlock()
|
||
_, _ = os.Stdout.Write([]byte("\x1b[?25h\x1b[H\x1b[2J"))
|
||
}
|
||
|
||
// invalidateChromeCache forces the next drawTabBar / drawSidebar /
|
||
// drawStatusLine call to actually emit bytes, regardless of cached
|
||
// content. Anything that clears or repaints the screen (resize, focus
|
||
// change, full repaint) must call this — otherwise the chrome stays
|
||
// blank because the cached frame still matches the unchanged state
|
||
// even though the wire was cleared.
|
||
func (st *uiState) invalidateChromeCache() {
|
||
st.chromeCacheMu.Lock()
|
||
st.tabBarCache = ""
|
||
st.sidebarCache = ""
|
||
st.statusLineCache = ""
|
||
st.chromeCacheMu.Unlock()
|
||
}
|
||
|
||
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
|
||
var trustMsg string
|
||
if st.pendingTrust != nil {
|
||
trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName)
|
||
}
|
||
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
|
||
}
|
||
if attention != "" && attentionAt == "" {
|
||
// Sticky attention/flash from somewhere outside the focused pane.
|
||
left = "[!] " + attention
|
||
}
|
||
if trustMsg != "" {
|
||
left = "[trust] " + trustMsg
|
||
}
|
||
// Hints decay shortest-first when the host is narrow so the focused
|
||
// child name + ownership note on the left side never get clipped.
|
||
hints := []string{
|
||
"Ctrl-A/D · tabs",
|
||
"Ctrl-W/S · tree",
|
||
"Ctrl-K · palette",
|
||
}
|
||
right := strings.Join(hints, " · ")
|
||
for len(hints) > 1 && int(cols)-len(left)-len(right) < 1 {
|
||
hints = hints[1:]
|
||
right = strings.Join(hints, " · ")
|
||
}
|
||
|
||
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.chromeCacheMu.Lock()
|
||
if line == st.statusLineCache {
|
||
st.chromeCacheMu.Unlock()
|
||
return
|
||
}
|
||
st.statusLineCache = line
|
||
st.chromeCacheMu.Unlock()
|
||
|
||
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() {
|
||
layout := st.layoutSnapshot()
|
||
st.outMu.Lock()
|
||
defer st.outMu.Unlock()
|
||
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)
|
||
}
|
||
|
||
// splitOnEnter walks input and returns each Enter byte (CR or LF) as
|
||
// its own slice, with the surrounding non-Enter bytes batched between.
|
||
// Empty pieces are dropped. The result preserves byte order, so
|
||
// "hello\rworld\n" yields ["hello", "\r", "world", "\n"]. Callers use
|
||
// this to keep Enter keystrokes from getting bundled into the same
|
||
// PTY write as the text that preceded them — TUI agents' paste
|
||
// detection (claude/codex/opencode) otherwise swallows the CR as
|
||
// literal content instead of treating it as a key event.
|
||
func splitOnEnter(in []byte) [][]byte {
|
||
if len(in) == 0 {
|
||
return nil
|
||
}
|
||
var out [][]byte
|
||
start := 0
|
||
for i, b := range in {
|
||
if b != '\r' && b != '\n' {
|
||
continue
|
||
}
|
||
if i > start {
|
||
out = append(out, in[start:i])
|
||
}
|
||
out = append(out, in[i:i+1])
|
||
start = i + 1
|
||
}
|
||
if start < len(in) {
|
||
out = append(out, in[start:])
|
||
}
|
||
return out
|
||
}
|
||
|
||
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()
|
||
|
||
// Trust modal is modal: y/Y accepts, n/N or ESC denies. Everything
|
||
// else is ignored so a typo doesn't leak into the focused PTY while
|
||
// the prompt is up. SPEC §7 trust gate.
|
||
if st.pendingTrust != nil {
|
||
req := *st.pendingTrust
|
||
consumed := 0
|
||
var resolved string
|
||
for _, b := range chunk {
|
||
consumed++
|
||
switch b {
|
||
case 'y', 'Y':
|
||
resolved = "accept"
|
||
case 'n', 'N', 0x1b: // ESC
|
||
resolved = "deny"
|
||
default:
|
||
continue
|
||
}
|
||
break
|
||
}
|
||
if resolved != "" {
|
||
st.pendingTrust = nil
|
||
st.mu.Unlock()
|
||
if resolved == "accept" {
|
||
if err := st.trust.Grant(req.presetName); err != nil {
|
||
st.flashError(fmt.Sprintf("trust grant: %v", err))
|
||
} else {
|
||
st.flashTransient(fmt.Sprintf("trusted preset %q (retry the call)", req.presetName))
|
||
}
|
||
} else {
|
||
st.flashTransient(fmt.Sprintf("denied trust for preset %q", req.presetName))
|
||
}
|
||
st.drawStatusLine()
|
||
// Discard the rest of the chunk; we intentionally don't
|
||
// recurse into the regular handler so a stray Enter doesn't
|
||
// submit anything to the focused PTY.
|
||
_ = consumed
|
||
return
|
||
}
|
||
st.mu.Unlock()
|
||
return
|
||
}
|
||
|
||
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()
|
||
// InjectAsUser splits Enter bytes onto their own
|
||
// writes so claude / codex / opencode don't treat a
|
||
// "text\r" batch as a paste.
|
||
_ = c.InjectAsUser(forward)
|
||
if prev != OwnerUser {
|
||
go st.drawStatusLine()
|
||
}
|
||
}
|
||
}
|
||
forward = forward[:0]
|
||
}
|
||
|
||
var pendingAction *paletteAction
|
||
var pendingNavID string
|
||
|
||
// Tracks the last arrow direction and the byte offset immediately
|
||
// after its CSI sequence. Some terminals emit a duplicate adjacent
|
||
// arrow event for one physical keypress (legacy `CSI B` + kitty
|
||
// `CSI 57353 u`, or two of the same form back-to-back). We collapse
|
||
// those into a single navigation step. Any non-arrow byte resets the
|
||
// tracker so genuine consecutive presses across other input still
|
||
// register normally.
|
||
var lastNav byte
|
||
var lastNavEnd int
|
||
|
||
i := 0
|
||
for i < len(chunk) {
|
||
b := chunk[i]
|
||
|
||
// Palette mode swallows all bytes.
|
||
if st.palette != nil {
|
||
if nav, navLen := peekArrowEvent(chunk, i); nav != 0 {
|
||
if i == lastNavEnd && nav == lastNav {
|
||
i += navLen
|
||
continue
|
||
}
|
||
lastNav = nav
|
||
lastNavEnd = i + navLen
|
||
} else {
|
||
lastNav = 0
|
||
lastNavEnd = -1
|
||
}
|
||
|
||
action, done, adv := st.palette.handleInput(chunk, i)
|
||
if adv <= 0 {
|
||
adv = 1
|
||
}
|
||
i += adv
|
||
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 → forward both keystrokes to the child raw.
|
||
//
|
||
// Ctrl-K is recognised in legacy (0x0B), kitty CSI u, and xterm
|
||
// modifyOtherKeys encodings — see matchCtrlK. The chord forwards
|
||
// the bytes the terminal actually emitted, so a child that asked
|
||
// for kitty input gets kitty input.
|
||
if hit, adv := matchCtrlK(chunk, i); hit {
|
||
if hit2, adv2 := matchCtrlK(chunk, i+adv); hit2 {
|
||
flushForward()
|
||
forward = append(forward, chunk[i:i+adv+adv2]...)
|
||
flushForward()
|
||
i += adv + adv2
|
||
continue
|
||
}
|
||
flushForward()
|
||
st.openPaletteLocked()
|
||
i += adv
|
||
continue
|
||
}
|
||
|
||
// Ctrl+WASD: directional focus navigation, matching the four
|
||
// arrow keys you'd expect in a tiling layout. A/D step between
|
||
// top-level tabs; W/S step through the current tab's process
|
||
// list (root first, then sub-agents). Bytes after the chord
|
||
// in the same chunk are dropped — the focus change makes
|
||
// further forwarding ambiguous between old and new pane.
|
||
if hit, adv := matchCtrlChar(chunk, i, 'a'); hit {
|
||
flushForward()
|
||
pendingNavID = nextTabID(st.sess.Children(), st.focusedID, -1)
|
||
i += adv
|
||
break
|
||
}
|
||
if hit, adv := matchCtrlChar(chunk, i, 'd'); hit {
|
||
flushForward()
|
||
pendingNavID = nextTabID(st.sess.Children(), st.focusedID, +1)
|
||
i += adv
|
||
break
|
||
}
|
||
if hit, adv := matchCtrlChar(chunk, i, 'w'); hit {
|
||
flushForward()
|
||
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, st.activeAgentID, -1)
|
||
i += adv
|
||
break
|
||
}
|
||
if hit, adv := matchCtrlChar(chunk, i, 's'); hit {
|
||
flushForward()
|
||
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, st.activeAgentID, +1)
|
||
i += adv
|
||
break
|
||
}
|
||
|
||
forward = append(forward, b)
|
||
i++
|
||
}
|
||
flushForward()
|
||
st.mu.Unlock()
|
||
|
||
if pendingAction != nil {
|
||
st.closePalette(*pendingAction)
|
||
}
|
||
if pendingNavID != "" {
|
||
st.focusProcess(pendingNavID)
|
||
}
|
||
}
|
||
|
||
func (st *uiState) openPaletteLocked() {
|
||
st.palette = newPalette(st.sess.Children(), st.focusedID, st.presets)
|
||
// Push a "no kitty flags" entry onto the host terminal's keyboard
|
||
// stack so palette input arrives in plain legacy form regardless of
|
||
// what the focused child pushed. Codex/ratatui enables kitty mode
|
||
// for its own PTY; that push gets forwarded to the host and leaves
|
||
// the host emitting arrow keys in multiple forms, which manifests
|
||
// as the palette double-stepping on Down/Up. Popped on close.
|
||
st.outMu.Lock()
|
||
_, _ = os.Stdout.WriteString("\x1b[>0u")
|
||
st.outMu.Unlock()
|
||
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()
|
||
// Pair with the push in openPaletteLocked: restore whatever
|
||
// keyboard flags the focused child had configured.
|
||
st.outMu.Lock()
|
||
_, _ = os.Stdout.WriteString("\x1b[<u")
|
||
st.outMu.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.LaunchCommandPreset(action.preset, action.preset.Name, ""); err != nil {
|
||
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
|
||
}
|
||
|
||
case "spawn-process-submit":
|
||
if action.command == "" {
|
||
st.repaintFocused()
|
||
return
|
||
}
|
||
l := st.layoutSnapshot()
|
||
st.launcher.SetSize(l.childCols(), l.childRows())
|
||
display := action.command
|
||
if len(display) > 32 {
|
||
display = display[:31] + "…"
|
||
}
|
||
// shell=true so multi-word commands like "bun run dev" pass
|
||
// through `sh -lc` and the user's PATH resolves binaries the
|
||
// way they expect from an interactive shell.
|
||
c, err := st.launcher.LaunchCommandArgv([]string{action.command}, display, "", "", nil, true)
|
||
if err != nil {
|
||
st.flashError(fmt.Sprintf("spawn: %v", err))
|
||
return
|
||
}
|
||
c.SetAutoRestart(action.relaunch)
|
||
// LaunchCommandArgv fires OnChildSpawned synchronously, which
|
||
// drew the sidebar before AutoRestart was set. Invalidate so the
|
||
// ⟳ marker shows up on the next paint.
|
||
if action.relaunch {
|
||
st.chromeCacheMu.Lock()
|
||
st.sidebarCache = ""
|
||
st.chromeCacheMu.Unlock()
|
||
st.drawSidebar()
|
||
}
|
||
|
||
case "switch":
|
||
c := st.sess.FindChild(action.childID)
|
||
if c == nil || c.Status() != StatusRunning {
|
||
st.repaintFocused()
|
||
return
|
||
}
|
||
layout := st.layoutSnapshot()
|
||
st.mu.Lock()
|
||
st.focusedID = action.childID
|
||
st.focusedName = c.DisplayName()
|
||
st.updateActiveAgentLocked(c)
|
||
st.renderer = newViewportRenderer(layout)
|
||
st.mu.Unlock()
|
||
st.repaintFocused()
|
||
st.drawTabBar()
|
||
st.drawSidebar()
|
||
st.drawStatusLine()
|
||
|
||
case "kill":
|
||
// User-initiated kill cancels any pending auto-restart so the
|
||
// process doesn't immediately come back.
|
||
if c := st.sess.FindChild(action.childID); c != nil {
|
||
c.SetAutoRestart(false)
|
||
}
|
||
_ = 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()
|
||
}
|
||
|
||
// flashTransient is the softer cousin of flashError used for
|
||
// trust-prompt resolutions. Same status-line surface; the prefix differs.
|
||
func (st *uiState) flashTransient(msg string) {
|
||
st.mu.Lock()
|
||
st.attentionText = msg
|
||
st.attentionAt = ""
|
||
st.mu.Unlock()
|
||
st.drawStatusLine()
|
||
}
|
||
|
||
// repaintFocused redraws the current focused child's screen snapshot.
|
||
// Callers must NOT hold st.mu — repaintFocused takes it
|
||
// briefly itself.
|
||
//
|
||
// We replay the emulator's padded grid snapshot rather than its VT
|
||
// serialization. SerializeVT can preserve style, but for diff-based TUIs
|
||
// we've seen it replay stale prompt layout that no longer matches the
|
||
// emulator grid; the padded snapshot is the source of truth for visible
|
||
// cells.
|
||
func (st *uiState) repaintFocused() {
|
||
layout := st.layoutSnapshot()
|
||
st.mu.Lock()
|
||
id := st.focusedID
|
||
renderer := st.renderer
|
||
st.mu.Unlock()
|
||
if id == "" {
|
||
st.renderEmptyState()
|
||
return
|
||
}
|
||
|
||
// Ratatui (codex) and other diff-based renderers can drift between
|
||
// their internal "last frame" model and the emulator state when they
|
||
// run unfocused, leaving incremental updates that target the wrong
|
||
// cells after we replay. Nudge the focused child to redraw fully so
|
||
// its next frame matches what we just put on the host.
|
||
if c := st.sess.FindChild(id); c != nil && c.Status() == StatusRunning {
|
||
cols, rows := layout.childCols(), layout.childRows()
|
||
defer c.NudgeRedraw(cols, rows)
|
||
}
|
||
|
||
out := st.renderFocusedSnapshot(id, renderer, layout)
|
||
if len(out) == 0 {
|
||
return
|
||
}
|
||
st.mu.Lock()
|
||
if st.focusedID == id {
|
||
st.repaintNextPTY = id
|
||
st.repaintNextPTYBudget = 8
|
||
}
|
||
st.mu.Unlock()
|
||
st.outMu.Lock()
|
||
defer st.outMu.Unlock()
|
||
_, _ = os.Stdout.Write(out)
|
||
}
|
||
|
||
func (st *uiState) renderFocusedSnapshot(id string, renderer *viewportRenderer, layout terminalLayout) []byte {
|
||
text, cursor, err := st.sess.SnapshotChild(id)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
if renderer != nil {
|
||
if styled, err := st.sess.StyledSnapshotChild(id); err == nil && len(styled) > 0 {
|
||
mainBottom := int(layout.statusRow) - statusRows
|
||
prelude := fmt.Sprintf(
|
||
"\x1b[0m\x1b[?6l\x1b[%d;%dr\x1b[?25h\x1b[%d;%dH",
|
||
int(layout.mainTop), mainBottom,
|
||
int(layout.mainTop), int(layout.mainLeft),
|
||
)
|
||
out := []byte(prelude)
|
||
out = append(out, renderer.ClearViewport()...)
|
||
out = append(out, renderer.Render(styled)...)
|
||
cup := fmt.Sprintf("\x1b[%d;%dH", int(cursor.Row)+1, int(cursor.Col)+1)
|
||
out = append(out, renderer.Render([]byte(cup))...)
|
||
return out
|
||
}
|
||
}
|
||
out := renderScreenSnapshot(text, cursor, layout)
|
||
if renderer != nil {
|
||
cup := fmt.Sprintf("\x1b[%d;%dH", int(cursor.Row)+1, int(cursor.Col)+1)
|
||
out = append(out, renderer.Render([]byte(cup))...)
|
||
}
|
||
return 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
|
||
}
|