Files
patterm/internal/app/app.go
Harry Bayliss 56fd461fb3 Teach parent agents to clean up the processes they spawn
Add a `lifecycle` help topic spelling out that the caller owns the
processes it spawns and should `close_process` when a sub-agent or
spawned child is no longer needed. The `spawn_agent` and `spawn_process`
descriptions advertised via `tools/list` now restate the same duty
inline (with a pointer to `help('lifecycle')`), so vendor TUIs see the
expectation at the moment they reach for the tool. The `spawning` topic
and `topics` index cross-reference the new content.

Bundles two already-staged improvements that fall in the same area:
- OnChildSpawned primes the snapshot-replay budget for new panes so
  diff-based vendor TUIs come up clean without a manual Ctrl+W/Ctrl+S
  refresh.
- TODO drops the three items now actioned (prompt-injection preface,
  agent cleanup duty, opencode→claude view corruption) and keeps the
  unicode `<?>` entry with the investigation notes.
2026-05-14 21:17:03 +01:00

1060 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/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
// 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.renderer = newViewportRenderer(layout)
st.mu.Unlock()
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// 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()
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.renderer = newViewportRenderer(layout)
}
}
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()
}
// 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
}
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.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, -1)
i += adv
break
}
if hit, adv := matchCtrlChar(chunk, i, 's'); hit {
flushForward()
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, +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 "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.renderer = newViewportRenderer(layout)
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()
}
// 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
}