Files
patterm/internal/app/app.go
Harry Bayliss 34b41be1df Cancel pending timers when a child is closed
Stale timer bodies were re-delivered to the orchestrator pane after
the parent had already processed the sub-agent's reply and called
close_process. The timer registry held no link to the child
lifecycle, so timers owned by or watching the closed child lingered
until something triggered a fire — e.g. a trailing classifier tick
for the now-removed child.

Add an OnChildClosed hook to ChildEventListener, emit it from
Session.Close (and the terminal-corpse path in reapChild), and have
the timer manager prune the registry: cancel timers owned by the
closed child; remove the closed child from each timer's watched
list (cancel the timer outright when watched empties).

Natural exit deliberately does not route through this hook — the
classifier already emits an idle transition on exit which delivers
any legitimate "fire when sub-agent finishes" semantics exactly
once; cancelling on exit would swallow that.
2026-05-18 12:37:32 +01:00

2553 lines
73 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)
}
appSettings, settingsPath, err := loadSettings()
if err != nil {
logf("settings load: %v", 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,
settings: appSettings,
settingsPath: settingsPath,
ctx: ctx,
}
st.summaries = newSummaryManager(sess, opts.ProjectDir, presets, func() autoSummarySettings {
st.settingsMu.Lock()
defer st.settingsMu.Unlock()
return st.settings.AutoSummary.clone()
}, func() {
st.markChromeDirty()
st.markSidebarDirty()
}, func(_ string, result summaryState) {
if result.Error != "" {
st.flashError(fmt.Sprintf("summary: %v", result.Error))
}
})
sess.SetMetrics(metrics)
host.attention = st
host.focus = st
host.prompter = st
host.scratch = st
st.lastExit.Store(-1)
sess.Subscribe(st)
go st.summaries.run(ctx)
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
// toasts is the stackable notification surface. flashError,
// flashTransient, and notifyAttention all push onto it; the user
// dismisses entries with Ctrl-N or the "Clear notifications"
// palette command.
toasts toastStack
// 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
settingsMu sync.Mutex
settings settings
settingsPath string
ctx context.Context
summaries *summaryManager
// 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...)
}
func (st *uiState) activeSummaryText(width int) string {
text := st.activeSummaryRaw()
if text == "" || width <= 0 {
return ""
}
if visibleLen(text) > width {
text = clipRunes(text, width-1) + "…"
}
return text
}
func (st *uiState) activeSummaryRaw() string {
if st.summaries == nil {
return ""
}
st.settingsMu.Lock()
enabled := st.settings.AutoSummary.Enabled
st.settingsMu.Unlock()
if !enabled {
return ""
}
st.mu.Lock()
active := st.activeAgentID
st.mu.Unlock()
if active == "" {
return ""
}
sum := st.summaries.Summary(active)
text := strings.TrimSpace(sum.Text)
if text == "" {
return ""
}
return text
}
// 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
// push a toast onto the stack; the focused-pane render path picks it
// up. 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.notifyToast(toastAttention, fmt.Sprintf("%s — %s", name, reason))
}
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 when the spawn came from
// the user (palette, persistence restore, or an external MCP client with
// no resolved identity). When ParentID is set — meaning a patterm-managed
// agent spawned this child via spawn_agent/spawn_process — focus stays
// on whatever the user was watching; the new child is still surfaced in
// the sidebar/tab bar so it's reachable via the palette or select_process.
func (st *uiState) OnChildSpawned(c *Child) {
if st.summaries != nil {
st.summaries.RegisterChild(c)
}
if c.ParentID != "" {
st.mu.Lock()
if st.palette != nil {
st.palette.children = st.sess.Children()
st.palette.focused = st.focusedID
st.palette.rebuild()
st.renderPaletteLocked()
}
st.mu.Unlock()
st.drawTabBar()
st.drawSidebar()
return
}
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()
}
// OnChildClosed is the explicit-removal hook (close_process or the
// terminal-corpse cleanup in reapChild). The UI already reflects
// removals via the OnChildExited path and the children-map view, so
// this is a no-op here — the timerManager is the consumer that
// cares.
func (st *uiState) OnChildClosed(string) {}
// OnChildExited drops focus and shows the empty state if it was the
// focused child.
func (st *uiState) OnChildExited(c *Child) {
if st.summaries != nil {
st.summaries.UnregisterChild(c.ID)
}
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()
}
if st.summaries != nil {
st.summaries.ObserveOutput(childID)
}
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, the
// autowrap-restore postlude, and (when a toast is up) the toast
// overlay — four syscalls collapsed into one under outMu. The
// sequences were already emitted atomically under the lock;
// coalescing just halves the syscall count and makes claude's
// continuous redraws + our toast layer land in the same frame so
// the box doesn't flicker as the child paints over its cells.
overlay := st.toastOverlayBytes()
wrapped := make([]byte, 0, len(out)+len(overlay)+10)
wrapped = append(wrapped, "\x1b[?7l"...)
wrapped = append(wrapped, out...)
wrapped = append(wrapped, "\x1b[?7h"...)
wrapped = append(wrapped, overlay...)
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
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 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")
}
}
// Surface the toast-dismiss chord only while a notification is on
// screen — the hint is noise otherwise, and Ctrl-N falls through
// to the focused PTY when the stack is empty.
if st.toasts.length() > 0 {
hints = append(hints, "Ctrl-N · dismiss")
}
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()
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)
}
st.outMu.Lock()
fmt.Fprintf(os.Stdout, "\x1b[?25l\x1b[H\x1b[2J\x1b[%d;%dH\x1b[2m%s\x1b[0m", row, col, line)
st.outMu.Unlock()
st.renderToasts()
}
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))
var pendingAction *paletteAction
var pendingNav navEntry
var pendingRestartID string
var pendingViewportDelta int
var pendingViewportBottom bool
var pendingPadStep int
var pendingPadExit bool
var pendingDismissToast bool
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 st.summaries != nil {
st.summaries.ObserveHumanInput(c.ID, forward)
}
if prev != OwnerUser {
go st.drawStatusLine()
}
// Auto-snap the emulator viewport to the live area
// on any forwarded keystroke. Without this, typing
// while scrolled into history leaves the cursor /
// echoed bytes off-screen below the visible region.
pendingViewportBottom = true
}
}
forward = forward[:0]
}
// 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 if hit, _ := matchCtrlChar(chunk, i, 'n'); hit {
// Ctrl-N is the toast dismiss key. In pad view we
// allow it through the chord block so the handler
// below can fire even though pads otherwise swallow
// bytes.
} 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 action.kind == "settings-save" {
st.applySettingsAction(action)
st.renderPaletteLocked()
continue
}
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-N dismisses the most recent toast. We only consume the
// chord when there's actually a toast to dismiss; otherwise the
// bytes fall through to the focused PTY so readline /
// nano / emacs / opencode keep working in shells and editors.
if hit, adv := matchCtrlChar(chunk, i, 'n'); hit {
if st.toasts.length() > 0 {
flushForward()
pendingDismissToast = true
i += adv
continue
}
forward = append(forward, chunk[i:i+adv]...)
i += adv
continue
}
// 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()
}
if pendingDismissToast {
if st.toasts.dismissTop() {
st.refreshToastSurface()
}
}
}
// 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.settingsMu.Lock()
appSettings := st.settings.clone()
st.settingsMu.Unlock()
st.palette = newPalette(st.sess.Children(), st.focusedID, st.focusedPad, st.presets, appSettings)
// 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 "toasts-clear":
if st.toasts.clear() {
st.refreshToastSurface()
}
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)
case "settings-test":
st.applySettingsAction(action)
restoreView()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
go st.testSummarizer()
case "settings-run-now":
st.applySettingsAction(action)
restoreView()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
st.runSummaryNow()
}
}
func (st *uiState) applySettingsAction(action paletteAction) {
if action.settings == nil {
return
}
next := action.settings.clone()
st.settingsMu.Lock()
path := st.settingsPath
st.settingsMu.Unlock()
if err := saveSettings(path, next); err != nil {
st.flashError(fmt.Sprintf("save settings: %v", err))
return
}
st.settingsMu.Lock()
st.settings = next
st.settingsMu.Unlock()
}
func (st *uiState) testSummarizer() {
if st.summaries == nil {
return
}
base := st.ctx
if base == nil {
base = context.Background()
}
ctx, cancel := context.WithTimeout(base, summaryTimeout)
defer cancel()
if err := st.summaries.Test(ctx); err != nil {
st.flashError(fmt.Sprintf("summarizer test: %v", err))
return
}
st.flashTransient("summarizer test passed")
}
func (st *uiState) runSummaryNow() {
if st.summaries == nil {
return
}
st.mu.Lock()
active := st.activeAgentID
st.mu.Unlock()
if active == "" {
st.flashError("no active top-level agent to summarize")
return
}
ctx := st.ctx
if ctx == nil {
ctx = context.Background()
}
st.summaries.RunNow(ctx, active)
st.flashTransient("summary requested")
}
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 as an error toast over the
// focused pane. stderr is hidden under the alt screen so we can't rely
// on Fprintln(os.Stderr).
func (st *uiState) flashError(msg string) {
st.notifyToast(toastError, msg)
}
// flashTransient is the softer cousin of flashError used for
// trust-prompt resolutions and other ack-style notices. Same
// stackable surface, info styling.
func (st *uiState) flashTransient(msg string) {
st.notifyToast(toastInfo, msg)
}
// 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()
_, _ = os.Stdout.Write(out)
st.outMu.Unlock()
st.renderToasts()
}
// 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()
_, _ = os.Stdout.Write(out)
st.outMu.Unlock()
st.renderToasts()
}
// 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
}