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 // /patterm.log and per-child raw PTY output capture to // /.raw. The dir is created if missing. Events // (spawn / exit / state change) land in /events.jsonl. DebugDir string // ProfileDir, when non-empty, enables in-process performance // counters. patterm writes a per-second JSONL snapshot stream to // /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= is set, write a verbose log // (patterm.log), per-child raw PTY output (.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 // . 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 " / "Deny preset ". 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 and tab bar whenever a // child's idle-state badge flips. Cheap — both draws bail when the // cached frame hasn't changed. func (st *uiState) OnChildStateChanged(string, IdleState) { st.drawTabBar() 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[ 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 }