Files
patterm/internal/app/app.go
Harry Bayliss b5dfaf39c4 Marquee long sidebar names; truncate with ellipsis otherwise
Sidebar rows that overflow the rail width used to spill characters
into the main viewport. They now truncate with a trailing "…"
when unfocused (or when the focused name still fits). The focused
row whose name overflows runs a pause-scroll-pause marquee: 1 s
hold on the head, ~150 ms per cell scroll, 1 s hold on the tail,
snap back. The row's geometry never moves while it animates, so
nothing below shifts.

A dedicated 150 ms goroutine flips sidebarDirty only while a row
is actively animating; the chrome ticker does the actual repaint.
Idle is a single cheap wakeup. focus / spawn / exit / restart all
reset the marquee state so the new focused row starts from frame
zero. When the row's budget is tight, the trailing timer
indicator drops before the name ellipses since the name is the
only identifier the row carries.

clampVisible() is a defensive net inside write(): even if a row's
decoration size were mis-computed, it will not spill past the
sidebar band into the PTY area.
2026-05-15 15:33:39 +01:00

2350 lines
68 KiB
Go

package app
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"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/persist"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/scratchpad"
"github.com/hjbdev/patterm/internal/trust"
"github.com/hjbdev/patterm/internal/vt"
)
// Options configures a patterm run.
type Options struct {
ProjectDir string
ProjectKey string
// DebugDir, when non-empty, enables verbose debug logging to
// <DebugDir>/patterm.log and per-child raw PTY output capture to
// <DebugDir>/<child-id>.raw. The dir is created if missing. Events
// (spawn / exit / state change) land in <DebugDir>/events.jsonl.
DebugDir string
// ProfileDir, when non-empty, enables in-process performance
// counters. patterm writes a per-second JSONL snapshot stream to
// <ProfileDir>/metrics.jsonl, a final aggregate to metrics.json,
// and a human-readable summary.txt on shutdown. The pprof files
// written by --profile sit alongside these in the same dir.
ProfileDir 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)
}
// Per-project persisted-process store. Survives across patterm
// restarts so user-created top-level command processes come back
// after a relaunch.
persistStore, err := persist.Open(opts.ProjectKey)
if err != nil {
return fmt.Errorf("app: persist 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()
// Debug capture: when --debug=<dir> is set, write a verbose log
// (patterm.log), per-child raw PTY output (<id>.raw), and a
// JSONL event stream (events.jsonl). Installed before the TUI
// listener so the very first OnChildSpawned / OnPTYOut event
// is captured.
if opts.DebugDir != "" {
dc, err := openDebugCapture(opts.DebugDir)
if err != nil {
return fmt.Errorf("app: debug capture: %w", err)
}
os.Setenv("PATTERM_DEBUG_LOG", dc.LogPath())
sess.Subscribe(dc)
defer dc.Close()
logf("debug capture enabled at %s", opts.DebugDir)
}
// Snapshot persisted processes BEFORE attaching the store: Spawn
// mints fresh ids, so the old records would otherwise linger
// alongside the new ones. Drop them up front; the restore loop
// below re-saves each entry under its new id.
savedProcesses := persistStore.List()
for _, e := range savedProcesses {
_ = persistStore.Remove(e.ID)
}
sess.SetPersistStore(persistStore)
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()
// Performance tracker — instrumented hot-path timings written to
// <ProfileDir>. nil when --profile is off, in which case every
// record*() call is a fast nil check.
metrics, err := newMetricsTracker(opts.ProfileDir)
if err != nil {
return fmt.Errorf("app: metrics tracker: %w", err)
}
if metrics != nil {
go metrics.run(ctx)
defer metrics.close()
}
// Per-session idle-detection classifier. One goroutine ticks every
// 250ms over every live child and updates IdleState. It stops when
// ctx is cancelled.
go sess.runClassifier(ctx)
st := &uiState{
sess: sess,
presets: presets,
launcher: launcher,
pads: pads,
chromeWake: make(chan struct{}, 1),
trust: trustStore,
timers: host.timers,
hostCols: cols,
hostRows: rows,
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
metrics: metrics,
}
sess.SetMetrics(metrics)
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())
// Replay persisted top-level command processes. Failures are
// logged and skipped so a stale entry (preset deleted, binary
// missing) doesn't block startup.
for _, e := range savedProcesses {
c, err := launcher.RestoreCommand(e, presets)
if err != nil {
st.dbgf("restore process %s (%s): %v", e.Name, e.ID, err)
continue
}
if e.AutoRestart {
c.SetAutoRestart(true)
}
}
var wg sync.WaitGroup
// SIGWINCH. The kernel emits one signal per kernel-side resize, and
// drag-resizes produce tens of them per second. The full
// resize-redraw pipeline (ResizeAll + clearScreen + repaintFocused +
// chrome) is expensive enough that running it per signal causes
// visible scroll-jumping in diff-based TUIs like codex. Coalesce:
// reset an ~80ms timer on every event, then run the pipeline once
// when the timer fires. Skip repaintFocused on this path — the
// child's own SIGWINCH-driven redraw fills the viewport; running
// our snapshot replay over a child that's mid-reflow is what
// produces the "crazy" scroll.
wg.Add(1)
winch := make(chan os.Signal, 1)
signal.Notify(winch, syscall.SIGWINCH)
go func() {
defer wg.Done()
defer signal.Stop(winch)
const debounce = 80 * time.Millisecond
var timer *time.Timer
var timerC <-chan time.Time
doResize := func() {
c, r := hostSize()
if c == 0 || r == 0 {
return
}
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.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
for {
select {
case <-ctx.Done():
if timer != nil {
timer.Stop()
}
return
case <-winch:
if timer == nil {
timer = time.NewTimer(debounce)
timerC = timer.C
} else {
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(debounce)
}
case <-timerC:
timer = nil
timerC = nil
doResize()
}
}
}()
// Chrome ticker: drain the dirty flag at ~60 Hz so per-chunk PTY
// output doesn't pay tabbar/statusline rebuild cost on every chunk.
wg.Add(1)
go func() {
defer wg.Done()
ticker := time.NewTicker(16 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-st.chromeWake:
case <-ticker.C:
}
chromeChanged := st.chromeDirty.Swap(false)
sidebarChanged := st.sidebarDirty.Swap(false)
didWork := chromeChanged || sidebarChanged
st.metrics.recordTickerFire(didWork)
if !didWork {
continue
}
if chromeChanged {
st.drawTabBar()
st.drawStatusLine()
}
if sidebarChanged {
st.drawSidebar()
}
}
}()
// Marquee ticker: while a focused sidebar row's name overflows the
// rail width, advance the pause-scroll-pause animation by marking
// the sidebar dirty every marqueeStep. The chrome ticker above does
// the actual repaint. When no row is animating, this is a single
// cheap wakeup with no work.
wg.Add(1)
go func() {
defer wg.Done()
ticker := time.NewTicker(marqueeStep)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
if st.marquee.active() {
st.markSidebarDirty()
}
}
}()
// 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
timers *timerManager
outMu sync.Mutex
mu sync.Mutex
palette *paletteState
focusedID string
focusedName string
// focusedPad names the scratchpad currently rendered in the main
// viewport. When non-empty, focusedID is "" and the host renders
// pad content instead of forwarding child PTY output. Mutually
// exclusive with focusedID. The palette also reads this to surface
// scratchpad-specific actions at the top of the command list.
focusedPad string
// padOffset is the index of the top-most rendered row in the
// markdown-formatted view of focusedPad. Reset when focus moves to
// a different pad; preserved across content changes for the same
// pad so writes from MCP don't snap the user's view back to the
// top.
padOffset int
// padOffsetName tracks which pad padOffset belongs to so a focus
// switch resets the offset cleanly.
padOffsetName 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
// metrics is the optional performance tracker. nil when --profile
// is off. Hot paths call metrics.recordX which is a fast nil
// check on the disabled path.
metrics *metricsTracker
// 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
// chromeDirty defers tab-bar and status-line repaints off the
// per-PTY-chunk hot path. OnPTYOut sets it; a ticker goroutine
// drains it at ~60 Hz and runs the actual draw calls. Latency-
// sensitive paths (owner flip, attention, trust, focus change)
// continue to call drawStatusLine / drawTabBar synchronously.
chromeDirty atomic.Bool
// sidebarDirty defers sidebar repaints off the per-chunk hot path
// in the same way. A long claude session resume — where every PTY
// chunk scrolls the viewport — used to call drawSidebar()
// synchronously per chunk, which dominated the resume's wall time
// (hundreds of full-sidebar rebuilds for a frame that was almost
// always cache-equal).
sidebarDirty atomic.Bool
chromeWake chan struct{}
// marquee animates the focused sidebar row's name when it overflows
// the rail width. The dedicated 150ms ticker below flips
// sidebarDirty while a row is animating; idle case is free.
marquee marqueeState
// padsCacheMu guards the cached scratchpad listing. The sidebar
// and palette/sidebar nav helpers read it on every chunk-driven
// repaint; the cache invalidates in scratchpadsChanged() which is
// the canonical "pads mutated" signal from MCP write/append. nil
// means "never read yet" — next caller refreshes.
padsCacheMu sync.Mutex
padsCache []scratchpad.Entry
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
}
st.marquee.reset()
layout := st.layoutSnapshot()
onAlt := childIsOnAlt(c)
st.mu.Lock()
leavingPad := st.focusedPad != ""
st.focusedPad = ""
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c)
r := newViewportRenderer(layout)
r.SetChildOnAlt(onAlt)
st.renderer = r
st.mu.Unlock()
st.syncHostMouseForChild(onAlt)
// Wipe whatever the previous focus (PTY child or pad view) left in
// the viewport before painting the new child's snapshot.
if leavingPad {
st.clearViewportArea()
}
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// childIsOnAlt reports whether the child's emulator is currently on
// its alternate screen. Returns false if the emulator is gone or the
// query fails.
func childIsOnAlt(c *Child) bool {
if c == nil {
return false
}
em := c.Emulator()
if em == nil {
return false
}
sc, err := em.ActiveScreen()
if err != nil {
return false
}
return sc == vt.ScreenAlternate
}
// syncHostMouseForChild emits the host mouse-reporting toggle that
// matches a newly-focused child's screen side. Primary-screen children
// want host mouse armed so the wheel drives inline scrollback; alt-
// screen children get host mouse disabled by default so click-and-drag
// selection works. Alt-screen TUIs that need mouse (vim, ranger, etc.)
// re-enable it themselves, and the viewport renderer forwards those
// toggles back to the host.
func (st *uiState) syncHostMouseForChild(onAlt bool) {
st.outMu.Lock()
defer st.outMu.Unlock()
if onAlt {
_, _ = os.Stdout.WriteString("\x1b[?1000l\x1b[?1006l")
} else {
_, _ = os.Stdout.WriteString("\x1b[?1000h\x1b[?1006h")
}
}
// focusScratchpad shifts focus to a scratchpad. The main viewport
// renders the pad's text instead of any child PTY; PTY output for the
// previously focused child is dropped until focus moves back to a
// child. Empty name clears scratchpad focus.
func (st *uiState) focusScratchpad(name string) {
if name == "" {
return
}
st.marquee.reset()
st.mu.Lock()
if st.padOffsetName != name {
st.padOffset = 0
st.padOffsetName = name
}
st.focusedPad = name
st.focusedID = ""
st.focusedName = name
st.renderer = nil
st.mu.Unlock()
st.clearViewportArea()
st.repaintFocusedPad()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// clearViewportArea wipes the rectangle the focused-child PTY (or pad
// view) paints into so the next paint starts on a clean canvas. Used
// when transitioning between pad and child focus.
func (st *uiState) clearViewportArea() {
layout := st.layoutSnapshot()
mainBottom := int(layout.statusRow) - statusRows
if mainBottom < int(layout.mainTop) {
return
}
var b strings.Builder
// ECH clears `mainCols` cells from each row in the viewport without
// touching the sidebar columns.
width := int(layout.childCols())
for r := int(layout.mainTop); r <= mainBottom; r++ {
fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX", r, int(layout.mainLeft), width)
}
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.WriteString(b.String())
}
func (st *uiState) restartFocusedCommand(processID string) {
c := st.sess.FindChild(processID)
if c == nil || c.Kind != KindCommand {
return
}
st.marquee.reset()
layout := st.layoutSnapshot()
renderer := newViewportRenderer(layout)
st.mu.Lock()
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.renderer = renderer
st.repaintNextPTY = c.ID
st.repaintNextPTYBudget = 2
st.mu.Unlock()
st.outMu.Lock()
_, _ = os.Stdout.Write(renderer.ClearViewport())
st.outMu.Unlock()
if err := st.sess.Restart(c.ID, syscall.SIGTERM, layout.childCols(), layout.childRows()); err != nil {
st.flashError(fmt.Sprintf("restart %s: %v", c.DisplayName(), err))
return
}
st.moveToViewportOrigin()
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.padsCacheMu.Lock()
st.padsCache = nil
st.padsCacheMu.Unlock()
st.chromeCacheMu.Lock()
st.sidebarCache = ""
st.chromeCacheMu.Unlock()
st.drawSidebar()
st.mu.Lock()
focusedPad := st.focusedPad
st.mu.Unlock()
if focusedPad != "" {
st.repaintFocusedPad()
}
}
// OnChildSpawned auto-focuses the new child.
func (st *uiState) OnChildSpawned(c *Child) {
st.marquee.reset()
layout := st.layoutSnapshot()
onAlt := childIsOnAlt(c)
st.mu.Lock()
st.focusedPad = ""
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c)
renderer := newViewportRenderer(layout)
renderer.SetChildOnAlt(onAlt)
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 = 2
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.syncHostMouseForChild(onAlt)
st.moveToViewportOrigin()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// OnChildStateChanged repaints the sidebar whenever a child's
// idle-state badge flips. Cheap — the badge is the only chrome that
// reflects state today, and drawSidebar bails when the cached frame
// hasn't changed.
func (st *uiState) OnChildStateChanged(string, IdleState) {
st.drawSidebar()
}
// 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.marquee.reset()
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) {
var entry time.Time
if st.metrics != nil {
entry = time.Now()
}
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 {
st.metrics.recordPTYOutDrop()
return
}
var out []byte
if forceRepaint {
var snapStart time.Time
if st.metrics != nil {
snapStart = time.Now()
}
out = st.renderFocusedSnapshot(childID, renderer, layout)
if st.metrics != nil {
st.metrics.recordSnapshot(time.Since(snapStart))
}
if len(out) == 0 {
return
}
} else {
var rstart time.Time
if st.metrics != nil {
rstart = time.Now()
}
out = renderer.Render(chunk)
if st.metrics != nil {
st.metrics.recordRender(time.Since(rstart))
}
}
// One write covers the autowrap-disable prelude, the chunk, and the
// autowrap-restore postlude — three syscalls collapsed into one
// under outMu. The three sequences were already emitted atomically
// under the lock; coalescing just halves the syscall count.
wrapped := make([]byte, 0, len(out)+10)
wrapped = append(wrapped, "\x1b[?7l"...)
wrapped = append(wrapped, out...)
wrapped = append(wrapped, "\x1b[?7h"...)
var wstart time.Time
if st.metrics != nil {
wstart = time.Now()
}
st.outMu.Lock()
_, _ = os.Stdout.Write(wrapped)
st.outMu.Unlock()
if st.metrics != nil {
st.metrics.recordStdout(time.Since(wstart), len(wrapped))
}
// RI / IND / NEL / SU / SD / IL / DL and bottom-margin LF / VT / FF
// 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 along with the main pane. The viewport renderer flags any
// chunk that scrolls; 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()
// Defer the sidebar repaint to the chrome ticker. On a long
// session resume every PTY chunk scrolls, and a synchronous
// drawSidebar() per chunk dominates wall time even when the
// frame ends up cache-equal — the rebuild work is unconditional.
// The chrome ticker drains the dirty flag at ~60 Hz, so the
// visible gap a scrolled chunk can leave in the sidebar columns
// is bounded by one frame.
st.markSidebarDirty()
}
// Defer the tab bar + status line repaint to the chrome ticker.
// The cached frame already short-circuits the wire write, but
// avoiding the string build, FindChild, and locking on every
// chunk pulls steady-state CPU off the hot path.
st.markChromeDirty()
if st.metrics != nil {
st.metrics.recordPTYOut(time.Since(entry), len(chunk))
}
}
func (st *uiState) enterScreen() {
st.outMu.Lock()
// SGR mouse reporting (?1000h ?1006h) stays on the entire time patterm
// is on the alt screen so we always receive wheel events. The focused
// child's wheel handling in processStdin decides whether each event
// scrolls the viewport (primary screen) or forwards to the child
// (alt screen / pad / palette).
_, _ = os.Stdout.Write([]byte("\x1b[?1049h\x1b[H\x1b[2J\x1b[?25h\x1b[?1000h\x1b[?1006h"))
st.outMu.Unlock()
st.installHostScrollRegion()
}
func (st *uiState) leaveScreen() {
st.outMu.Lock()
defer st.outMu.Unlock()
// Tear down any mouse reporting patterm enabled before leaving the
// alt screen; otherwise the calling shell can be left with a host
// that still emits SGR mouse events. Reset DECSTBM so the calling
// shell isn't stuck with a constrained scroll region.
_, _ = os.Stdout.Write([]byte("\x1b[r\x1b[?6l\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l"))
}
func (st *uiState) clearScreen() {
st.invalidateChromeCache()
st.outMu.Lock()
_, _ = os.Stdout.Write([]byte("\x1b[?25h\x1b[H\x1b[2J"))
st.outMu.Unlock()
// Re-arm the host scroll region so the post-clear paint inherits
// the viewport bounds. Without this, a SIGWINCH-driven clearScreen
// followed by a long burst of child output (no DECSTBM of its own)
// would scroll the host's full screen — chrome included — every
// time the cursor reached the bottom row.
st.installHostScrollRegion()
}
// installHostScrollRegion writes DECSTBM to bound the host's scroll
// region to mainTop..mainBottom, then disables origin mode and CUPs
// back to viewport-top. With this in place a child that emits LF / IND
// / NEL / RI / SU / SD / IL / DL at the bottom of the viewport scrolls
// only within the viewport rows — the tab bar and status row never see
// the scroll. renderFocusedSnapshot already emits the same prelude for
// snapshot replays; this method covers the windows in between (initial
// startup, post-SIGWINCH, post-clearScreen) when no snapshot fires.
func (st *uiState) installHostScrollRegion() {
layout := st.layoutSnapshot()
mainBottom := int(layout.statusRow) - statusRows
if mainBottom < int(layout.mainTop) {
return
}
st.outMu.Lock()
defer st.outMu.Unlock()
fmt.Fprintf(os.Stdout, "\x1b[?6l\x1b[%d;%dr\x1b[%d;%dH",
int(layout.mainTop), mainBottom,
int(layout.mainTop), int(layout.mainLeft))
}
// 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.
// padsList returns the cached scratchpad listing, refreshing from
// disk on the first call after invalidation. Callers must not mutate
// the returned slice — it is shared.
func (st *uiState) padsList() []scratchpad.Entry {
st.padsCacheMu.Lock()
if st.padsCache != nil {
out := st.padsCache
st.padsCacheMu.Unlock()
return out
}
st.padsCacheMu.Unlock()
entries, err := st.pads.List()
if err != nil {
return nil
}
st.padsCacheMu.Lock()
st.padsCache = entries
st.padsCacheMu.Unlock()
return entries
}
// markChromeDirty schedules a chrome (tab bar + status line) repaint
// on the next ticker frame. Cheap to call from the per-PTY-chunk hot
// path. Latency-sensitive sites (focus change, owner flip, attention,
// trust prompts) keep calling drawTabBar / drawStatusLine directly.
func (st *uiState) markChromeDirty() {
st.chromeDirty.Store(true)
select {
case st.chromeWake <- struct{}{}:
default:
}
}
// markSidebarDirty schedules a sidebar repaint on the next ticker
// frame. Hot path — every scrolled PTY chunk lands here. Synchronous
// repaints from latency-sensitive sites (spawn, exit, focus, state
// change, trust) keep calling drawSidebar directly.
func (st *uiState) markSidebarDirty() {
st.sidebarDirty.Store(true)
select {
case st.chromeWake <- struct{}{}:
default:
}
}
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() {
var entry time.Time
if st.metrics != nil {
entry = time.Now()
}
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
}
// Resolve the focused child once — drawStatusLine fires on every
// PTY chunk and ticker tick, and FindChild takes the session
// mutex.
var focusedChild *Child
if focusID != "" {
focusedChild = st.sess.FindChild(focusID)
}
owner := ""
if focusedChild != nil {
switch focusedChild.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 left-to-right when the host is narrow so the focused
// child name + ownership note on the left side never get clipped.
// Context-specific hints are appended so they survive longest.
hints := []string{
"Ctrl-A/D · tabs",
"Ctrl-W/S · tree",
"Ctrl-K · palette",
}
if focusedChild != nil {
hints = append(hints, "Ctrl-B · scroll")
if focusedChild.Kind == KindCommand {
hints = append(hints, "Ctrl-R · restart")
}
}
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()
if st.metrics != nil {
st.metrics.recordStatus(time.Since(entry), true)
}
return
}
st.statusLineCache = line
st.chromeCacheMu.Unlock()
if st.metrics != nil {
defer func() { st.metrics.recordStatus(time.Since(entry), false) }()
}
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 pendingNav navEntry
var pendingRestartID string
var pendingViewportDelta int
var pendingViewportBottom bool
var pendingPadStep int
var pendingPadExit bool
// childOnPrimary captures whether the focused child is on its primary
// screen at the start of this chunk. Wheel events on the primary
// screen scroll the emulator viewport (inline scrollback); on the
// alternate screen they fall through to the child PTY so vim / less /
// codex can consume them.
childOnPrimary := false
if st.focusedID != "" {
if c := st.sess.FindChild(st.focusedID); c != nil {
if em := c.Emulator(); em != nil {
if sc, err := em.ActiveScreen(); err == nil && sc == vt.ScreenPrimary {
childOnPrimary = true
}
}
}
}
// 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]
// Scratchpad mode: pad has no PTY destination, so input is
// repurposed for scrolling the rendered markdown view.
// Scroll-wheel events are the primary control (we enable SGR
// mouse reporting in focusScratchpad); arrow keys / PgUp/PgDn /
// Home / End work for keyboard users. App-level chords (Ctrl-K
// palette, Ctrl-WASD focus, Ctrl-B scrollback) fall through to
// the handlers below; everything else is swallowed silently so
// typing into a pad view can't leak to a child PTY.
//
// When the palette is open we skip this block entirely so the
// palette handler below receives every byte. Otherwise typing
// (and Esc) get swallowed here and the palette appears wedged.
if st.focusedPad != "" && st.palette == nil {
if b == 0x1b { // ESC or CSI
if n := csiLen(chunk, i); n > 0 {
final := chunk[i+n-1]
params := chunk[i+2 : i+n-1]
// SGR mouse: `CSI < button ; col ; row M/m`. We
// enabled 1006 reporting on focus, so the host emits
// this form. Wheel-up = 64, wheel-down = 65; +shift
// adds 4 → 68/69; +ctrl adds 16 → 80/81. We treat
// any wheel button as a 3-row step.
if final == 'M' && len(params) > 0 && params[0] == '<' {
if step, ok := parseSGRMouseWheel(params[1:]); ok {
pendingPadStep += step
i += n
continue
}
// Non-wheel mouse event (click/drag/release):
// drop silently. Pads don't have a click model
// yet, and forwarding to a child would be
// confusing while the pad view is up.
i += n
continue
}
if final == 'm' && len(params) > 0 && params[0] == '<' {
// SGR release event — always drop.
i += n
continue
}
switch final {
case 'A':
pendingPadStep -= 1
i += n
continue
case 'B':
pendingPadStep += 1
i += n
continue
case '~':
pstr := string(params)
layout := st.layoutLocked()
page := int(layout.childRows()) - 2
if page < 1 {
page = 1
}
switch pstr {
case "5":
pendingPadStep -= page
i += n
continue
case "6":
pendingPadStep += page
i += n
continue
case "1", "7":
pendingPadStep -= 1 << 30
i += n
continue
case "4", "8":
pendingPadStep += 1 << 30
i += n
continue
}
case 'u':
if k, ok := decodeCSIu(string(params)); ok && k.event == 1 {
switch k.key {
case kittyKeyUp:
pendingPadStep -= 1
i += n
continue
case kittyKeyDown:
pendingPadStep += 1
i += n
continue
}
}
}
// Unhandled CSI: drop so the pad view stays stable
// instead of letting stray escapes hit the next
// handler block.
i += n
continue
}
// Legacy X10 mouse: `CSI M Cb Cx Cy`, three raw bytes
// after the M. csiLen consumed only up to 'M'; pick up
// the three trailing bytes here. Cb is button + 32;
// wheel = 64 → byte 96, wheel-down = 65 → byte 97.
if i+5 < len(chunk) && chunk[i+1] == '[' && chunk[i+2] == 'M' {
cb := chunk[i+3]
switch cb {
case 96, 100, 112: // 64, 68, 80 — wheel up variants
pendingPadStep -= 3
i += 6
continue
case 97, 101, 113: // 65, 69, 81 — wheel down variants
pendingPadStep += 3
i += 6
continue
}
// Non-wheel legacy mouse: drop the 6-byte event.
i += 6
continue
}
// Bare ESC exits the pad view.
pendingPadExit = true
i++
break
}
// Plain bytes (letters, control chars other than ESC) drop
// silently except for the app-level chords we explicitly
// allow through below.
if hit, _ := matchCtrlK(chunk, i); hit {
// fall through to the app-level handler
} else if hit, _ := matchCtrlChar(chunk, i, 'a'); hit {
} else if hit, _ := matchCtrlChar(chunk, i, 'd'); hit {
} else if hit, _ := matchCtrlChar(chunk, i, 'w'); hit {
} else if hit, _ := matchCtrlChar(chunk, i, 's'); hit {
} else {
i++
continue
}
}
// 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()
if id := nextTabID(st.sess.Children(), st.focusedID, -1); id != "" {
pendingNav = navEntry{childID: id}
}
i += adv
break
}
if hit, adv := matchCtrlChar(chunk, i, 'd'); hit {
flushForward()
if id := nextTabID(st.sess.Children(), st.focusedID, +1); id != "" {
pendingNav = navEntry{childID: id}
}
i += adv
break
}
if hit, adv := matchCtrlChar(chunk, i, 'w'); hit {
flushForward()
pendingNav = nextNavEntry(st.sess.Children(), st.focusedID, st.focusedPad, st.activeAgentID, st.padsList(), -1)
i += adv
break
}
if hit, adv := matchCtrlChar(chunk, i, 's'); hit {
flushForward()
pendingNav = nextNavEntry(st.sess.Children(), st.focusedID, st.focusedPad, st.activeAgentID, st.padsList(), +1)
i += adv
break
}
if hit, adv := matchCtrlChar(chunk, i, 'r'); hit {
if c := st.sess.FindChild(st.focusedID); c != nil && c.Kind == KindCommand {
flushForward()
pendingRestartID = c.ID
i += adv
break
}
}
// Ctrl-B snaps the focused child's emulator viewport back to the
// active area. Use this as the escape hatch from a scrolled-up
// state — wheel scrolls move the viewport into the libghostty
// scrollback history; Ctrl-B brings it back. The chord is
// intercepted before forwarding so the child shell doesn't see a
// stray Ctrl-B (readline backward-char).
if hit, adv := matchCtrlChar(chunk, i, 'b'); hit {
if st.focusedID != "" {
flushForward()
pendingViewportBottom = true
i += adv
continue
}
}
// Inline wheel scrollback for a focused child on the primary
// screen. The host always has SGR mouse reporting armed (see
// enterScreen), so wheel events arrive here even when the child
// shell never asked for mouse input. On the alternate screen we
// let the bytes fall through to forward so vim / less / codex
// receive the wheel event as input.
if childOnPrimary && b == 0x1b {
if n := csiLen(chunk, i); n > 0 {
final := chunk[i+n-1]
params := chunk[i+2 : i+n-1]
if final == 'M' && len(params) > 0 && params[0] == '<' {
if step, ok := parseSGRMouseWheel(params[1:]); ok {
pendingViewportDelta += step
i += n
continue
}
}
}
// Legacy X10 mouse wheel: `CSI M Cb Cx Cy`.
if i+5 < len(chunk) && chunk[i+1] == '[' && chunk[i+2] == 'M' {
cb := chunk[i+3]
switch cb {
case 96, 100, 112:
pendingViewportDelta -= 3
i += 6
continue
case 97, 101, 113:
pendingViewportDelta += 3
i += 6
continue
}
}
}
forward = append(forward, b)
i++
}
flushForward()
st.mu.Unlock()
if pendingAction != nil {
st.closePalette(*pendingAction)
}
if !pendingNav.empty() {
switch {
case pendingNav.isPad():
st.focusScratchpad(pendingNav.pad)
case pendingNav.isChild():
st.focusProcess(pendingNav.childID)
}
}
if pendingRestartID != "" {
st.restartFocusedCommand(pendingRestartID)
}
if pendingViewportDelta != 0 {
st.scrollFocusedViewport(pendingViewportDelta)
}
if pendingViewportBottom {
st.scrollFocusedViewportToBottom()
}
if pendingPadStep != 0 {
st.padScroll(pendingPadStep)
}
if pendingPadExit {
st.exitPadView()
}
}
// scrollFocusedViewport scrolls the focused child's emulator viewport by
// `delta` rows (negative is up into scrollback history, positive is down
// towards the active area) and repaints the main pane against the new
// snapshot. No-op if no child is focused or the emulator isn't live yet.
func (st *uiState) scrollFocusedViewport(delta int) {
st.mu.Lock()
id := st.focusedID
st.mu.Unlock()
if id == "" {
return
}
c := st.sess.FindChild(id)
if c == nil {
return
}
em := c.Emulator()
if em == nil {
return
}
if err := em.ScrollViewportDelta(delta); err != nil {
return
}
st.repaintFocused()
}
// scrollFocusedViewportToBottom snaps the focused child's emulator
// viewport back to the active (live) area. Bound to Ctrl-B as the escape
// hatch from a scrolled-up state.
func (st *uiState) scrollFocusedViewportToBottom() {
st.mu.Lock()
id := st.focusedID
st.mu.Unlock()
if id == "" {
return
}
c := st.sess.FindChild(id)
if c == nil {
return
}
em := c.Emulator()
if em == nil {
return
}
if err := em.ScrollViewportBottom(); err != nil {
return
}
st.repaintFocused()
}
func (st *uiState) openPaletteLocked() {
st.palette = newPalette(st.sess.Children(), st.focusedID, st.focusedPad, 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()
// When a scratchpad is focused, the "main viewport" is showing the
// pad's rendered body, not a child PTY. repaintFocused() would draw
// an empty state (focusedID == "") and leave the previous palette's
// top border visible in the pad area. Use the pad-aware helper for
// any branch below that wants to restore the prior view.
restoreView := func() {
st.mu.Lock()
padFocused := st.focusedPad != ""
st.mu.Unlock()
if padFocused {
st.repaintFocusedPad()
return
}
st.repaintFocused()
}
switch action.kind {
case "", "cancel":
restoreView()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
case "spawn-agent":
if action.preset == nil {
restoreView()
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 {
restoreView()
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-terminal":
l := st.layoutSnapshot()
st.launcher.SetSize(l.childCols(), l.childRows())
if _, err := st.launcher.LaunchTerminal(nil, "terminal", "", "", nil); err != nil {
st.flashError(fmt.Sprintf("spawn terminal: %v", err))
}
case "spawn-process-submit":
if action.command == "" {
restoreView()
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.Kind == KindAgent && c.Status() != StatusRunning) {
restoreView()
return
}
layout := st.layoutSnapshot()
st.mu.Lock()
leavingPad := st.focusedPad != ""
st.focusedPad = ""
st.focusedID = action.childID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c)
st.renderer = newViewportRenderer(layout)
st.mu.Unlock()
// Switching from a pad to a child: wipe the pad body so the
// child's snapshot paints onto a clean canvas, mirroring
// focusProcess.
if leavingPad {
st.clearViewportArea()
}
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()
case "pad-delete":
st.handlePadDelete(action.padName)
case "pad-rename-submit":
st.handlePadRename(action.padName, action.newName)
case "pad-edit":
st.handlePadEdit(action.padName)
case "agent-rename-submit", "proc-rename-submit":
st.handleChildRename(action.childID, action.newName)
case "agent-close", "proc-delete":
st.handleChildClose(action.childID, action.kind == "proc-delete")
case "proc-stop":
st.handleProcStop(action.childID)
case "proc-restart":
st.handleProcRestart(action.childID)
}
}
func (st *uiState) handlePadDelete(name string) {
if name == "" || st.pads == nil {
st.repaintFocused()
return
}
if err := st.pads.Delete(name); err != nil {
st.flashError(fmt.Sprintf("delete %s: %v", name, err))
return
}
st.mu.Lock()
if st.focusedPad == name {
st.focusedPad = ""
}
st.mu.Unlock()
st.scratchpadsChanged()
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
func (st *uiState) handlePadRename(oldName, newName string) {
if oldName == "" || newName == "" || st.pads == nil {
st.repaintFocused()
return
}
if oldName == newName {
st.repaintFocused()
return
}
if err := st.pads.Rename(oldName, newName); err != nil {
st.flashError(fmt.Sprintf("rename %s: %v", oldName, err))
return
}
st.mu.Lock()
if st.focusedPad == oldName {
st.focusedPad = newName
}
st.mu.Unlock()
st.scratchpadsChanged()
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// handlePadEdit launches an external editor (zed) on the focused
// scratchpad file. Fire-and-forget: we Start() the editor with
// stdin/stdout/stderr redirected to /dev/null and call Process.Release()
// so the patterm process doesn't accumulate zombies. The editor opens
// in its own window without suspending the TUI.
func (st *uiState) handlePadEdit(name string) {
if name == "" || st.pads == nil {
st.repaintFocused()
return
}
path, err := st.pads.Path(name)
if err != nil {
st.flashError(fmt.Sprintf("edit %s: %v", name, err))
return
}
null, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
if err != nil {
st.flashError(fmt.Sprintf("edit %s: open /dev/null: %v", name, err))
return
}
cmd := exec.Command("zed", path)
cmd.Stdin = null
cmd.Stdout = null
cmd.Stderr = null
if err := cmd.Start(); err != nil {
_ = null.Close()
st.flashError(fmt.Sprintf("edit %s: %v", name, err))
return
}
if cmd.Process != nil {
_ = cmd.Process.Release()
}
_ = null.Close()
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
func (st *uiState) handleChildRename(childID, newName string) {
if childID == "" || newName == "" {
st.repaintFocused()
return
}
c := st.sess.FindChild(childID)
if c == nil {
st.repaintFocused()
return
}
c.SetName(newName)
st.mu.Lock()
if st.focusedID == childID {
st.focusedName = newName
}
st.mu.Unlock()
st.chromeCacheMu.Lock()
st.tabBarCache = ""
st.sidebarCache = ""
st.chromeCacheMu.Unlock()
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// handleChildClose removes a child entry entirely. For agents this is
// equivalent to a SIGTERM kill (the entry is ephemeral and disappears
// from the session once the PTY exits). For command processes it's
// equivalent to the MCP close_process tool: SIGKILL if alive, then
// drop the entry so it stops appearing in the switch/restart lists.
func (st *uiState) handleChildClose(childID string, kill bool) {
if childID == "" {
st.repaintFocused()
return
}
c := st.sess.FindChild(childID)
if c == nil {
st.repaintFocused()
return
}
c.SetAutoRestart(false)
if kill {
_ = st.sess.Close(childID, syscall.SIGKILL)
} else {
_ = st.sess.Kill(childID, syscall.SIGTERM)
}
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
func (st *uiState) handleProcStop(childID string) {
if childID == "" {
st.repaintFocused()
return
}
c := st.sess.FindChild(childID)
if c == nil {
st.repaintFocused()
return
}
c.SetAutoRestart(false)
_ = st.sess.Kill(childID, syscall.SIGTERM)
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
func (st *uiState) handleProcRestart(childID string) {
if childID == "" {
st.repaintFocused()
return
}
c := st.sess.FindChild(childID)
if c == nil {
st.repaintFocused()
return
}
layout := st.layoutSnapshot()
if err := st.sess.Restart(childID, syscall.SIGTERM, layout.childCols(), layout.childRows()); err != nil {
st.flashError(fmt.Sprintf("restart %s: %v", c.DisplayName(), err))
return
}
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// 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 = 2
}
st.mu.Unlock()
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(out)
}
// repaintFocusedPad paints the focused scratchpad's content into the
// main viewport, honouring the per-pad scroll offset and clamping it
// to the rendered body size so a shrunk pad doesn't leave the view
// scrolled past its last line.
func (st *uiState) repaintFocusedPad() {
st.mu.Lock()
name := st.focusedPad
st.mu.Unlock()
if name == "" {
return
}
layout := st.layoutSnapshot()
content, _, err := st.pads.Read(name)
if err != nil {
content = fmt.Sprintf("(scratchpad %q unreadable: %v)", name, err)
}
out := st.renderPadView(name, content, layout)
if len(out) == 0 {
return
}
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(out)
}
// renderPadView builds the bytes that paint a scratchpad's content
// into the main viewport. Title row, divider, then a markdown-rendered
// body windowed by the per-pad scroll offset. Caller owns outMu and
// any prior clearViewportArea.
func (st *uiState) renderPadView(name, content string, layout terminalLayout) []byte {
mainBottom := int(layout.statusRow) - statusRows
width := int(layout.childCols())
if mainBottom < int(layout.mainTop) || width < 1 {
return nil
}
bodyCols := width - 1
if bodyCols < 1 {
bodyCols = 1
}
rendered := renderMarkdownLines(content, bodyCols)
bodyRows := mainBottom - int(layout.mainTop) + 1 - 2
if bodyRows < 1 {
bodyRows = 1
}
maxOffset := len(rendered) - bodyRows
if maxOffset < 0 {
maxOffset = 0
}
st.mu.Lock()
if st.padOffset > maxOffset {
st.padOffset = maxOffset
}
if st.padOffset < 0 {
st.padOffset = 0
}
offset := st.padOffset
st.mu.Unlock()
var b strings.Builder
fmt.Fprintf(&b, "\x1b[0m\x1b[?6l\x1b[%d;%dr\x1b[?25l\x1b[%d;%dH",
int(layout.mainTop), mainBottom,
int(layout.mainTop), int(layout.mainLeft))
row := int(layout.mainTop)
writeRow := func(prefix, body, style string) {
if row > mainBottom {
return
}
fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX", row, int(layout.mainLeft), width)
fmt.Fprintf(&b, "\x1b[%d;%dH%s", row, int(layout.mainLeft), style)
b.WriteString(prefix)
b.WriteString(body)
b.WriteString(styleReset)
row++
}
// Header tells the user which pad they're viewing and the scroll
// position so a partial view is obvious.
end := offset + bodyRows
if end > len(rendered) {
end = len(rendered)
}
title := fmt.Sprintf(" %s (%d-%d / %d · ↑/↓ PgUp/PgDn · Esc back)",
name, offset+1, end, len(rendered))
if len(rendered) == 0 {
title = fmt.Sprintf(" %s (empty · Esc back)", name)
}
writeRow("", title, styleActive+styleBold)
if width > 2 {
writeRow("", " "+strings.Repeat("─", width-2), styleBorder)
} else {
writeRow("", strings.Repeat("─", width), styleBorder)
}
for i := offset; i < end; i++ {
writeRow(" ", rendered[i], "")
}
for row <= mainBottom {
writeRow("", "", "")
}
return []byte(b.String())
}
// exitPadView leaves scratchpad focus and falls back to the first
// running top-level child, or an empty viewport if there is none. No-op
// when no pad is focused.
func (st *uiState) exitPadView() {
st.mu.Lock()
if st.focusedPad == "" {
st.mu.Unlock()
return
}
st.focusedPad = ""
st.focusedName = ""
st.mu.Unlock()
st.clearViewportArea()
if next := firstRunningTopLevel(st.sess.Children()); next != nil {
st.focusProcess(next.ID)
return
}
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// padScroll moves the focused-pad viewport by delta rows (negative =
// up, positive = down). No-op if no pad is focused. Clamping is
// performed against the rendered row count inside renderPadView, so
// callers can pass arbitrarily large step values for "jump to end".
func (st *uiState) padScroll(delta int) {
st.mu.Lock()
if st.focusedPad == "" {
st.mu.Unlock()
return
}
st.padOffset += delta
if st.padOffset < 0 {
st.padOffset = 0
}
st.mu.Unlock()
st.repaintFocusedPad()
}
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
}