Live metrics (--profile): - New metricsTracker instruments OnPTYOut, viewport renderer, stdout writes, libghostty-vt Write/Title CGO calls, sidebar / tabbar / status draws (with cache-hit accounting), snapshot replays, and the chrome ticker (so we can see ticker fires that did nothing). - Writes metrics.jsonl (one snapshot per second) and metrics.json + summary.txt on exit, alongside the existing pprof files. - All record* methods are nil-safe so disabled paths pay only a cheap nil check; counters are atomic so the per-PTY-chunk hot path stays lock-free. Benchmark suite (go test -bench=.): - Three workload fixtures — plain ASCII, SGR-styled lines, and a ratatui-style cursor-shuffling burst — plus a containsOSC microbenchmark. Reports ns/op, MB/s, allocs/op, B/op. - Initial baseline numbers added to TODO under the perf-audit section, alongside two new findings (renderer allocs ~1 per 4 bytes on styled chunks; styled throughput tops out near 90 MB/s) those benchmarks surfaced.
2318 lines
67 KiB
Go
2318 lines
67 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
|
|
cpty "github.com/creack/pty"
|
|
"golang.org/x/term"
|
|
|
|
"github.com/hjbdev/patterm/internal/mcp"
|
|
"github.com/hjbdev/patterm/internal/persist"
|
|
"github.com/hjbdev/patterm/internal/preset"
|
|
"github.com/hjbdev/patterm/internal/scratchpad"
|
|
"github.com/hjbdev/patterm/internal/trust"
|
|
"github.com/hjbdev/patterm/internal/vt"
|
|
)
|
|
|
|
// Options configures a patterm run.
|
|
type Options struct {
|
|
ProjectDir string
|
|
ProjectKey string
|
|
// DebugDir, when non-empty, enables verbose debug logging to
|
|
// <DebugDir>/patterm.log and per-child raw PTY output capture to
|
|
// <DebugDir>/<child-id>.raw. The dir is created if missing. Events
|
|
// (spawn / exit / state change) land in <DebugDir>/events.jsonl.
|
|
DebugDir string
|
|
// ProfileDir, when non-empty, enables in-process performance
|
|
// counters. patterm writes a per-second JSONL snapshot stream to
|
|
// <ProfileDir>/metrics.jsonl, a final aggregate to metrics.json,
|
|
// and a human-readable summary.txt on shutdown. The pprof files
|
|
// written by --profile sit alongside these in the same dir.
|
|
ProfileDir string
|
|
}
|
|
|
|
const keyCtrlK byte = 0x0b
|
|
|
|
// Run is patterm's single-process entry point. SPEC §2: one Go process
|
|
// owns everything; no daemon, no detach, no socket-based reattachment.
|
|
func Run(ctx context.Context, opts Options) error {
|
|
if opts.ProjectDir == "" {
|
|
return errors.New("app: ProjectDir required")
|
|
}
|
|
|
|
presets, err := preset.Load()
|
|
if err != nil {
|
|
return fmt.Errorf("app: load presets: %w", err)
|
|
}
|
|
|
|
// Ensure the per-project scratchpad dir exists so MCP and the UI
|
|
// can read/write into it. SPEC §3.
|
|
pads, err := scratchpad.Open(opts.ProjectKey)
|
|
if err != nil {
|
|
return fmt.Errorf("app: scratchpad init: %w", err)
|
|
}
|
|
|
|
// Per-project trust store for command-preset trust gating (SPEC §7).
|
|
trustStore, err := trust.Open(opts.ProjectKey)
|
|
if err != nil {
|
|
return fmt.Errorf("app: trust init: %w", err)
|
|
}
|
|
|
|
// Per-project persisted-process store. Survives across patterm
|
|
// restarts so user-created top-level command processes come back
|
|
// after a relaunch.
|
|
persistStore, err := persist.Open(opts.ProjectKey)
|
|
if err != nil {
|
|
return fmt.Errorf("app: persist init: %w", err)
|
|
}
|
|
|
|
// In-process MCP server bound to the per-PID socket. Children that
|
|
// support MCP get pointed at `patterm mcp-stdio --socket=... --identity=...`.
|
|
// SPEC §10.
|
|
mcpSrv, err := mcp.Start()
|
|
if err != nil {
|
|
return fmt.Errorf("app: mcp start: %w", err)
|
|
}
|
|
defer mcpSrv.Close()
|
|
|
|
sess := NewSession(opts.ProjectDir, opts.ProjectKey)
|
|
defer sess.Shutdown()
|
|
|
|
// Debug capture: when --debug=<dir> is set, write a verbose log
|
|
// (patterm.log), per-child raw PTY output (<id>.raw), and a
|
|
// JSONL event stream (events.jsonl). Installed before the TUI
|
|
// listener so the very first OnChildSpawned / OnPTYOut event
|
|
// is captured.
|
|
if opts.DebugDir != "" {
|
|
dc, err := openDebugCapture(opts.DebugDir)
|
|
if err != nil {
|
|
return fmt.Errorf("app: debug capture: %w", err)
|
|
}
|
|
os.Setenv("PATTERM_DEBUG_LOG", dc.LogPath())
|
|
sess.Subscribe(dc)
|
|
defer dc.Close()
|
|
logf("debug capture enabled at %s", opts.DebugDir)
|
|
}
|
|
// Snapshot persisted processes BEFORE attaching the store: Spawn
|
|
// mints fresh ids, so the old records would otherwise linger
|
|
// alongside the new ones. Drop them up front; the restore loop
|
|
// below re-saves each entry under its new id.
|
|
savedProcesses := persistStore.List()
|
|
for _, e := range savedProcesses {
|
|
_ = persistStore.Remove(e.ID)
|
|
}
|
|
sess.SetPersistStore(persistStore)
|
|
|
|
cols, rows := hostSize()
|
|
|
|
layout := newTerminalLayout(cols, rows)
|
|
|
|
// Launcher handles preset → child translation, including MCP
|
|
// config injection for agent presets.
|
|
launcher := NewLauncher(sess, mcpSrv.Socket(), layout.childCols(), layout.childRows())
|
|
|
|
// Wire the tool host into MCP. Spawns through MCP use the host
|
|
// terminal's viewport grid for their initial PTY size; SIGWINCH paths
|
|
// resize them later.
|
|
host := newToolHost(sess, pads, launcher, presets, trustStore, layout.childCols(), layout.childRows())
|
|
mcpSrv.SetHost(host)
|
|
|
|
var restoreState *term.State
|
|
if term.IsTerminal(int(os.Stdin.Fd())) {
|
|
st, err := term.MakeRaw(int(os.Stdin.Fd()))
|
|
if err != nil {
|
|
return fmt.Errorf("app: stdin raw: %w", err)
|
|
}
|
|
restoreState = st
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
// Performance tracker — instrumented hot-path timings written to
|
|
// <ProfileDir>. nil when --profile is off, in which case every
|
|
// record*() call is a fast nil check.
|
|
metrics, err := newMetricsTracker(opts.ProfileDir)
|
|
if err != nil {
|
|
return fmt.Errorf("app: metrics tracker: %w", err)
|
|
}
|
|
if metrics != nil {
|
|
go metrics.run(ctx)
|
|
defer metrics.close()
|
|
}
|
|
|
|
// Per-session idle-detection classifier. One goroutine ticks every
|
|
// 250ms over every live child and updates IdleState. It stops when
|
|
// ctx is cancelled.
|
|
go sess.runClassifier(ctx)
|
|
|
|
st := &uiState{
|
|
sess: sess,
|
|
presets: presets,
|
|
launcher: launcher,
|
|
pads: pads,
|
|
chromeWake: make(chan struct{}, 1),
|
|
trust: trustStore,
|
|
timers: host.timers,
|
|
hostCols: cols,
|
|
hostRows: rows,
|
|
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
|
|
metrics: metrics,
|
|
}
|
|
sess.SetMetrics(metrics)
|
|
host.attention = st
|
|
host.focus = st
|
|
host.prompter = st
|
|
host.scratch = st
|
|
st.lastExit.Store(-1)
|
|
sess.Subscribe(st)
|
|
|
|
st.enterScreen()
|
|
st.renderEmptyState()
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
|
|
// Set initial PTY grid for any future child. The child gets the
|
|
// computed main viewport, excluding tab bar, sidebar, and status.
|
|
sess.ResizeAll(layout.childCols(), layout.childRows())
|
|
launcher.SetSize(layout.childCols(), layout.childRows())
|
|
host.SetSize(layout.childCols(), layout.childRows())
|
|
|
|
// Replay persisted top-level command processes. Failures are
|
|
// logged and skipped so a stale entry (preset deleted, binary
|
|
// missing) doesn't block startup.
|
|
for _, e := range savedProcesses {
|
|
c, err := launcher.RestoreCommand(e, presets)
|
|
if err != nil {
|
|
st.dbgf("restore process %s (%s): %v", e.Name, e.ID, err)
|
|
continue
|
|
}
|
|
if e.AutoRestart {
|
|
c.SetAutoRestart(true)
|
|
}
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
// SIGWINCH. The kernel emits one signal per kernel-side resize, and
|
|
// drag-resizes produce tens of them per second. The full
|
|
// resize-redraw pipeline (ResizeAll + clearScreen + repaintFocused +
|
|
// chrome) is expensive enough that running it per signal causes
|
|
// visible scroll-jumping in diff-based TUIs like codex. Coalesce:
|
|
// reset an ~80ms timer on every event, then run the pipeline once
|
|
// when the timer fires. Skip repaintFocused on this path — the
|
|
// child's own SIGWINCH-driven redraw fills the viewport; running
|
|
// our snapshot replay over a child that's mid-reflow is what
|
|
// produces the "crazy" scroll.
|
|
wg.Add(1)
|
|
winch := make(chan os.Signal, 1)
|
|
signal.Notify(winch, syscall.SIGWINCH)
|
|
go func() {
|
|
defer wg.Done()
|
|
defer signal.Stop(winch)
|
|
const debounce = 80 * time.Millisecond
|
|
var timer *time.Timer
|
|
var timerC <-chan time.Time
|
|
doResize := func() {
|
|
c, r := hostSize()
|
|
if c == 0 || r == 0 {
|
|
return
|
|
}
|
|
st.dimsMu.Lock()
|
|
st.hostCols, st.hostRows = c, r
|
|
l := st.layoutLocked()
|
|
st.dimsMu.Unlock()
|
|
st.mu.Lock()
|
|
if st.renderer != nil {
|
|
st.renderer.SetLayout(l)
|
|
}
|
|
st.mu.Unlock()
|
|
sess.ResizeAll(l.childCols(), l.childRows())
|
|
launcher.SetSize(l.childCols(), l.childRows())
|
|
host.SetSize(l.childCols(), l.childRows())
|
|
st.clearScreen()
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
}
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
if timer != nil {
|
|
timer.Stop()
|
|
}
|
|
return
|
|
case <-winch:
|
|
if timer == nil {
|
|
timer = time.NewTimer(debounce)
|
|
timerC = timer.C
|
|
} else {
|
|
if !timer.Stop() {
|
|
select {
|
|
case <-timer.C:
|
|
default:
|
|
}
|
|
}
|
|
timer.Reset(debounce)
|
|
}
|
|
case <-timerC:
|
|
timer = nil
|
|
timerC = nil
|
|
doResize()
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Chrome ticker: drain the dirty flag at ~60 Hz so per-chunk PTY
|
|
// output doesn't pay tabbar/statusline rebuild cost on every chunk.
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
ticker := time.NewTicker(16 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-st.chromeWake:
|
|
case <-ticker.C:
|
|
}
|
|
chromeChanged := st.chromeDirty.Swap(false)
|
|
sidebarChanged := st.sidebarDirty.Swap(false)
|
|
didWork := chromeChanged || sidebarChanged
|
|
st.metrics.recordTickerFire(didWork)
|
|
if !didWork {
|
|
continue
|
|
}
|
|
if chromeChanged {
|
|
st.drawTabBar()
|
|
st.drawStatusLine()
|
|
}
|
|
if sidebarChanged {
|
|
st.drawSidebar()
|
|
}
|
|
}
|
|
}()
|
|
|
|
// External termination: SPEC §2 step 4 (SIGTERM/SIGHUP → graceful exit).
|
|
wg.Add(1)
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP)
|
|
go func() {
|
|
defer wg.Done()
|
|
defer signal.Stop(sigCh)
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case sig := <-sigCh:
|
|
st.dbgf("signal %s; tearing down", sig)
|
|
cancel()
|
|
}
|
|
}()
|
|
|
|
// Stdin loop.
|
|
go func() {
|
|
if err := st.stdinLoop(); err != nil {
|
|
st.dbgf("stdinLoop: %v", err)
|
|
}
|
|
cancel()
|
|
}()
|
|
|
|
<-ctx.Done()
|
|
wg.Wait()
|
|
st.leaveScreen()
|
|
|
|
if restoreState != nil {
|
|
_ = term.Restore(int(os.Stdin.Fd()), restoreState)
|
|
}
|
|
|
|
if st.lastExit.Load() >= 0 {
|
|
fmt.Fprintf(os.Stderr, "patterm: last child exited (%d).\n", st.lastExit.Load())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// uiState is the shared state between the SIGWINCH loop, the stdin
|
|
// loop, and the session listener callbacks.
|
|
type uiState struct {
|
|
sess *Session
|
|
presets preset.Set
|
|
launcher *Launcher
|
|
pads *scratchpad.Store
|
|
trust *trust.Store
|
|
timers *timerManager
|
|
|
|
outMu sync.Mutex
|
|
|
|
mu sync.Mutex
|
|
palette *paletteState
|
|
focusedID string
|
|
focusedName string
|
|
// focusedPad names the scratchpad currently rendered in the main
|
|
// viewport. When non-empty, focusedID is "" and the host renders
|
|
// pad content instead of forwarding child PTY output. Mutually
|
|
// exclusive with focusedID. The palette also reads this to surface
|
|
// scratchpad-specific actions at the top of the command list.
|
|
focusedPad string
|
|
// padOffset is the index of the top-most rendered row in the
|
|
// markdown-formatted view of focusedPad. Reset when focus moves to
|
|
// a different pad; preserved across content changes for the same
|
|
// pad so writes from MCP don't snap the user's view back to the
|
|
// top.
|
|
padOffset int
|
|
// padOffsetName tracks which pad padOffset belongs to so a focus
|
|
// switch resets the offset cleanly.
|
|
padOffsetName string
|
|
|
|
|
|
// activeAgentID tracks which top-level agent tab "owns" the agent
|
|
// tree section of the sidebar. It only updates when focus lands on
|
|
// an agent (or one of its sub-agents), so the agent tree stays
|
|
// visible even when the user steps into the Processes pane.
|
|
activeAgentID string
|
|
// renderer confines focused-child live output to the main viewport.
|
|
// A fresh renderer is allocated per focused child so partial-escape
|
|
// state cannot bleed between panes.
|
|
renderer *viewportRenderer
|
|
repaintNextPTY string
|
|
repaintNextPTYBudget int
|
|
|
|
// attention is the latest request_human_attention surfaced via MCP;
|
|
// rendered in the status line until cleared.
|
|
attentionText string
|
|
attentionAt string
|
|
|
|
// pendingTrust is the most recent trust prompt — surfaced in the
|
|
// status line until the user resolves it with Ctrl-K. v1 keeps the
|
|
// confirmation modal minimal: the user opens the palette and picks
|
|
// "Trust preset <name>" / "Deny preset <name>". A future iteration
|
|
// can promote this to a dedicated inline modal.
|
|
pendingTrust *trustRequest
|
|
|
|
dimsMu sync.Mutex
|
|
hostCols, hostRows uint16
|
|
stdinTTY bool
|
|
|
|
// metrics is the optional performance tracker. nil when --profile
|
|
// is off. Hot paths call metrics.recordX which is a fast nil
|
|
// check on the disabled path.
|
|
metrics *metricsTracker
|
|
|
|
// chromeCacheMu guards the last-rendered byte cache for each chrome
|
|
// element. The tab bar, sidebar, and status line all repaint on
|
|
// many state changes and on every PTY chunk, but their content
|
|
// usually doesn't change between calls — caching the rendered
|
|
// output and skipping a write when it matches eliminates the
|
|
// flicker (especially in the sidebar's session tree).
|
|
chromeCacheMu sync.Mutex
|
|
tabBarCache string
|
|
sidebarCache string
|
|
statusLineCache string
|
|
|
|
// chromeDirty defers tab-bar and status-line repaints off the
|
|
// per-PTY-chunk hot path. OnPTYOut sets it; a ticker goroutine
|
|
// drains it at ~60 Hz and runs the actual draw calls. Latency-
|
|
// sensitive paths (owner flip, attention, trust, focus change)
|
|
// continue to call drawStatusLine / drawTabBar synchronously.
|
|
chromeDirty atomic.Bool
|
|
// sidebarDirty defers sidebar repaints off the per-chunk hot path
|
|
// in the same way. A long claude session resume — where every PTY
|
|
// chunk scrolls the viewport — used to call drawSidebar()
|
|
// synchronously per chunk, which dominated the resume's wall time
|
|
// (hundreds of full-sidebar rebuilds for a frame that was almost
|
|
// always cache-equal).
|
|
sidebarDirty atomic.Bool
|
|
chromeWake chan struct{}
|
|
|
|
// padsCacheMu guards the cached scratchpad listing. The sidebar
|
|
// and palette/sidebar nav helpers read it on every chunk-driven
|
|
// repaint; the cache invalidates in scratchpadsChanged() which is
|
|
// the canonical "pads mutated" signal from MCP write/append. nil
|
|
// means "never read yet" — next caller refreshes.
|
|
padsCacheMu sync.Mutex
|
|
padsCache []scratchpad.Entry
|
|
|
|
lastExit atomic.Int32
|
|
}
|
|
|
|
func (st *uiState) dbgf(format string, args ...any) {
|
|
logf(format, args...)
|
|
}
|
|
|
|
// trustRequest is one outstanding SPEC §7 trust prompt: an agent tried
|
|
// to spawn / start / restart against an untrusted command preset and
|
|
// the host wants user confirmation before the next attempt succeeds.
|
|
type trustRequest struct {
|
|
processID string
|
|
presetName string
|
|
reason string
|
|
}
|
|
|
|
// promptTrust is the SPEC §7 trust gate UI hook. Replaces any prior
|
|
// pending request — the most recent prompt wins.
|
|
func (st *uiState) promptTrust(processID, presetName, reason string) {
|
|
st.mu.Lock()
|
|
st.pendingTrust = &trustRequest{processID: processID, presetName: presetName, reason: reason}
|
|
st.mu.Unlock()
|
|
st.drawStatusLine()
|
|
}
|
|
|
|
// focusProcess is the SPEC §7 select_process hook. Routes through the
|
|
// normal focus-change path; only takes effect if the process exists.
|
|
func (st *uiState) focusProcess(processID string) {
|
|
c := st.sess.FindChild(processID)
|
|
if c == nil {
|
|
return
|
|
}
|
|
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.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
|
|
}
|
|
layout := st.layoutSnapshot()
|
|
renderer := newViewportRenderer(layout)
|
|
st.mu.Lock()
|
|
st.focusedID = c.ID
|
|
st.focusedName = c.DisplayName()
|
|
st.renderer = renderer
|
|
st.repaintNextPTY = c.ID
|
|
st.repaintNextPTYBudget = 2
|
|
st.mu.Unlock()
|
|
|
|
st.outMu.Lock()
|
|
_, _ = os.Stdout.Write(renderer.ClearViewport())
|
|
st.outMu.Unlock()
|
|
|
|
if err := st.sess.Restart(c.ID, syscall.SIGTERM, layout.childCols(), layout.childRows()); err != nil {
|
|
st.flashError(fmt.Sprintf("restart %s: %v", c.DisplayName(), err))
|
|
return
|
|
}
|
|
st.moveToViewportOrigin()
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
}
|
|
|
|
// updateActiveAgentLocked records the active agent root for the agent
|
|
// tree section whenever focus lands on an agent or one of its
|
|
// sub-agents. Focusing a top-level command process leaves the previous
|
|
// active agent intact, so the user can hop between the Processes pane
|
|
// and the agent tree without losing context. Caller holds st.mu.
|
|
func (st *uiState) updateActiveAgentLocked(c *Child) {
|
|
if c.Kind != KindAgent {
|
|
return
|
|
}
|
|
if c.ParentID == "" {
|
|
st.activeAgentID = c.ID
|
|
return
|
|
}
|
|
// Walk up to the top-level agent.
|
|
root := c
|
|
for root.ParentID != "" {
|
|
parent := st.sess.FindChild(root.ParentID)
|
|
if parent == nil {
|
|
break
|
|
}
|
|
root = parent
|
|
}
|
|
if root.Kind == KindAgent && root.ParentID == "" {
|
|
st.activeAgentID = root.ID
|
|
}
|
|
}
|
|
|
|
// notifyAttention is the request_human_attention sink (SPEC §7). We
|
|
// surface a one-line toast in the status row and remember the most
|
|
// recent ask so the status line keeps showing it. The sidebar-blink is
|
|
// deferred until the §4 chrome lands.
|
|
func (st *uiState) notifyAttention(childID, reason string) {
|
|
c := st.sess.FindChild(childID)
|
|
name := childID
|
|
if c != nil {
|
|
name = c.DisplayName()
|
|
}
|
|
st.mu.Lock()
|
|
st.attentionText = fmt.Sprintf("attention: %s — %s", name, reason)
|
|
st.attentionAt = childID
|
|
st.mu.Unlock()
|
|
st.drawStatusLine()
|
|
}
|
|
|
|
func (st *uiState) scratchpadsChanged() {
|
|
st.padsCacheMu.Lock()
|
|
st.padsCache = nil
|
|
st.padsCacheMu.Unlock()
|
|
st.chromeCacheMu.Lock()
|
|
st.sidebarCache = ""
|
|
st.chromeCacheMu.Unlock()
|
|
st.drawSidebar()
|
|
st.mu.Lock()
|
|
focusedPad := st.focusedPad
|
|
st.mu.Unlock()
|
|
if focusedPad != "" {
|
|
st.repaintFocusedPad()
|
|
}
|
|
}
|
|
|
|
// OnChildSpawned auto-focuses the new child.
|
|
func (st *uiState) OnChildSpawned(c *Child) {
|
|
layout := st.layoutSnapshot()
|
|
onAlt := childIsOnAlt(c)
|
|
st.mu.Lock()
|
|
st.focusedPad = ""
|
|
st.focusedID = c.ID
|
|
st.focusedName = c.DisplayName()
|
|
st.updateActiveAgentLocked(c)
|
|
renderer := newViewportRenderer(layout)
|
|
renderer.SetChildOnAlt(onAlt)
|
|
st.renderer = renderer
|
|
palOpen := st.palette != nil
|
|
if palOpen {
|
|
st.palette.children = st.sess.Children()
|
|
st.palette.focused = st.focusedID
|
|
st.palette.rebuild()
|
|
st.renderPaletteLocked()
|
|
}
|
|
// Prime the snapshot-replay budget for the new child. Diff-based
|
|
// vendor TUIs (claude/codex/opencode) emit incremental updates that
|
|
// assume the host display already matches their internal "last
|
|
// frame" model. On a fresh spawn the host viewport was just cleared,
|
|
// so incremental ops target cells that aren't populated yet —
|
|
// leaving the corrupted pane the user works around by toggling
|
|
// focus (which routes through repaintFocused). Setting the budget
|
|
// here makes the next ~8 PTY chunks render from the full styled
|
|
// emulator grid, so the host display tracks the emulator state
|
|
// without needing a manual focus cycle.
|
|
st.repaintNextPTY = c.ID
|
|
st.repaintNextPTYBudget = 2
|
|
st.mu.Unlock()
|
|
|
|
// Wipe the viewport area so the previous focused child's PTY
|
|
// output doesn't bleed through beneath the new pane. The palette
|
|
// branch is skipped because the palette overlay covers the whole
|
|
// screen and is about to take focus back to OnChildSpawned's
|
|
// caller path.
|
|
if !palOpen {
|
|
st.outMu.Lock()
|
|
_, _ = os.Stdout.Write(renderer.ClearViewport())
|
|
st.outMu.Unlock()
|
|
}
|
|
|
|
st.syncHostMouseForChild(onAlt)
|
|
st.moveToViewportOrigin()
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
}
|
|
|
|
// OnChildStateChanged repaints the sidebar whenever a child's
|
|
// idle-state badge flips. Cheap — the badge is the only chrome that
|
|
// reflects state today, and drawSidebar bails when the cached frame
|
|
// hasn't changed.
|
|
func (st *uiState) OnChildStateChanged(string, IdleState) {
|
|
st.drawSidebar()
|
|
}
|
|
|
|
// OnChildExited drops focus and shows the empty state if it was the
|
|
// focused child.
|
|
func (st *uiState) OnChildExited(c *Child) {
|
|
st.lastExit.Store(int32(c.ExitCode()))
|
|
layout := st.layoutSnapshot()
|
|
renderEmpty := false
|
|
st.mu.Lock()
|
|
if c.ID == st.focusedID {
|
|
next := firstRunningTopLevel(st.sess.Children())
|
|
if next == nil {
|
|
st.focusedID = ""
|
|
st.focusedName = ""
|
|
renderEmpty = true
|
|
} else {
|
|
st.focusedID = next.ID
|
|
st.focusedName = next.DisplayName()
|
|
st.updateActiveAgentLocked(next)
|
|
st.renderer = newViewportRenderer(layout)
|
|
}
|
|
}
|
|
if c.ID == st.activeAgentID {
|
|
// The active agent died; pin the agent tree to whatever agent
|
|
// root is still running, or clear it if none remain.
|
|
st.activeAgentID = firstRunningAgentID(st.sess.Children())
|
|
}
|
|
if st.palette != nil {
|
|
st.palette.children = st.sess.Children()
|
|
st.palette.focused = st.focusedID
|
|
st.palette.rebuild()
|
|
st.renderPaletteLocked()
|
|
}
|
|
repaint := st.focusedID != ""
|
|
st.mu.Unlock()
|
|
if renderEmpty {
|
|
st.renderEmptyState()
|
|
}
|
|
if repaint {
|
|
st.repaintFocused()
|
|
}
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
|
|
// Auto-restart kicks in for command entries the user marked "relaunch
|
|
// on exit". A short backoff (1s) avoids hot-spinning on processes
|
|
// that fail immediately. The user can clear the flag by killing the
|
|
// process from the palette.
|
|
if c.Kind == KindCommand && c.AutoRestart() {
|
|
go st.scheduleAutoRestart(c)
|
|
}
|
|
}
|
|
|
|
// scheduleAutoRestart re-Starts a command entry after a brief backoff.
|
|
// Bails out if the user cleared the flag, closed the process, or the
|
|
// entry came back to life through some other path while we were
|
|
// waiting. Called as a goroutine from OnChildExited.
|
|
func (st *uiState) scheduleAutoRestart(c *Child) {
|
|
time.Sleep(1 * time.Second)
|
|
if !c.AutoRestart() {
|
|
return
|
|
}
|
|
if st.sess.FindChild(c.ID) == nil {
|
|
return
|
|
}
|
|
if c.IsLive() {
|
|
return
|
|
}
|
|
l := st.layoutSnapshot()
|
|
if err := st.sess.Start(c.ID, l.childCols(), l.childRows()); err != nil {
|
|
st.dbgf("auto-restart %s: %v", c.ID, err)
|
|
return
|
|
}
|
|
// Start doesn't fire emitSpawn, so we have to nudge the chrome
|
|
// ourselves — the status flipped from exited back to running and
|
|
// the sidebar's cached frame still shows the exited glyph.
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
}
|
|
|
|
// OnPTYOut writes live output for the focused child when the palette is
|
|
// not covering the screen. The viewport renderer shifts cursor movement
|
|
// into the main pane and rewrites destructive clears. Host autowrap is
|
|
// disabled only around the replay so long styled runs cannot wrap into
|
|
// the right rail.
|
|
func (st *uiState) OnPTYOut(childID string, chunk []byte) {
|
|
var entry time.Time
|
|
if st.metrics != nil {
|
|
entry = time.Now()
|
|
}
|
|
layout := st.layoutSnapshot()
|
|
st.mu.Lock()
|
|
focus := st.focusedID
|
|
palOpen := st.palette != nil
|
|
renderer := st.renderer
|
|
forceRepaint := focus == childID && st.repaintNextPTY == childID && st.repaintNextPTYBudget > 0
|
|
if forceRepaint {
|
|
renderer = newViewportRenderer(layout)
|
|
st.renderer = renderer
|
|
st.repaintNextPTYBudget--
|
|
if st.repaintNextPTYBudget == 0 {
|
|
st.repaintNextPTY = ""
|
|
}
|
|
}
|
|
st.mu.Unlock()
|
|
if palOpen || focus != childID || renderer == nil {
|
|
st.metrics.recordPTYOutDrop()
|
|
return
|
|
}
|
|
var out []byte
|
|
if forceRepaint {
|
|
var snapStart time.Time
|
|
if st.metrics != nil {
|
|
snapStart = time.Now()
|
|
}
|
|
out = st.renderFocusedSnapshot(childID, renderer, layout)
|
|
if st.metrics != nil {
|
|
st.metrics.recordSnapshot(time.Since(snapStart))
|
|
}
|
|
if len(out) == 0 {
|
|
return
|
|
}
|
|
} else {
|
|
var rstart time.Time
|
|
if st.metrics != nil {
|
|
rstart = time.Now()
|
|
}
|
|
out = renderer.Render(chunk)
|
|
if st.metrics != nil {
|
|
st.metrics.recordRender(time.Since(rstart))
|
|
}
|
|
}
|
|
// One write covers the autowrap-disable prelude, the chunk, and the
|
|
// autowrap-restore postlude — three syscalls collapsed into one
|
|
// under outMu. The three sequences were already emitted atomically
|
|
// under the lock; coalescing just halves the syscall count.
|
|
wrapped := make([]byte, 0, len(out)+10)
|
|
wrapped = append(wrapped, "\x1b[?7l"...)
|
|
wrapped = append(wrapped, out...)
|
|
wrapped = append(wrapped, "\x1b[?7h"...)
|
|
var wstart time.Time
|
|
if st.metrics != nil {
|
|
wstart = time.Now()
|
|
}
|
|
st.outMu.Lock()
|
|
_, _ = os.Stdout.Write(wrapped)
|
|
st.outMu.Unlock()
|
|
if st.metrics != nil {
|
|
st.metrics.recordStdout(time.Since(wstart), len(wrapped))
|
|
}
|
|
// RI / IND / NEL / SU / SD / IL / DL and bottom-margin LF / VT / FF
|
|
// scroll content within the host's scroll region, which spans every
|
|
// column — so any of them drags the right-hand sidebar's session-tree
|
|
// entries along with the main pane. The viewport renderer flags any
|
|
// chunk that scrolls; when set, drop the sidebar cache so the next
|
|
// drawSidebar repaints over the clobber instead of hitting the cache
|
|
// and leaving the gap visible.
|
|
scrolled := renderer.TookScrollAction()
|
|
if scrolled {
|
|
st.chromeCacheMu.Lock()
|
|
st.sidebarCache = ""
|
|
st.chromeCacheMu.Unlock()
|
|
// Defer the sidebar repaint to the chrome ticker. On a long
|
|
// session resume every PTY chunk scrolls, and a synchronous
|
|
// drawSidebar() per chunk dominates wall time even when the
|
|
// frame ends up cache-equal — the rebuild work is unconditional.
|
|
// The chrome ticker drains the dirty flag at ~60 Hz, so the
|
|
// visible gap a scrolled chunk can leave in the sidebar columns
|
|
// is bounded by one frame.
|
|
st.markSidebarDirty()
|
|
}
|
|
// Defer the tab bar + status line repaint to the chrome ticker.
|
|
// The cached frame already short-circuits the wire write, but
|
|
// avoiding the string build, FindChild, and locking on every
|
|
// chunk pulls steady-state CPU off the hot path.
|
|
st.markChromeDirty()
|
|
if st.metrics != nil {
|
|
st.metrics.recordPTYOut(time.Since(entry), len(chunk))
|
|
}
|
|
}
|
|
|
|
func (st *uiState) enterScreen() {
|
|
st.outMu.Lock()
|
|
// SGR mouse reporting (?1000h ?1006h) stays on the entire time patterm
|
|
// is on the alt screen so we always receive wheel events. The focused
|
|
// child's wheel handling in processStdin decides whether each event
|
|
// scrolls the viewport (primary screen) or forwards to the child
|
|
// (alt screen / pad / palette).
|
|
_, _ = os.Stdout.Write([]byte("\x1b[?1049h\x1b[H\x1b[2J\x1b[?25h\x1b[?1000h\x1b[?1006h"))
|
|
st.outMu.Unlock()
|
|
st.installHostScrollRegion()
|
|
}
|
|
|
|
func (st *uiState) leaveScreen() {
|
|
st.outMu.Lock()
|
|
defer st.outMu.Unlock()
|
|
// Tear down any mouse reporting patterm enabled before leaving the
|
|
// alt screen; otherwise the calling shell can be left with a host
|
|
// that still emits SGR mouse events. Reset DECSTBM so the calling
|
|
// shell isn't stuck with a constrained scroll region.
|
|
_, _ = os.Stdout.Write([]byte("\x1b[r\x1b[?6l\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l"))
|
|
}
|
|
|
|
func (st *uiState) clearScreen() {
|
|
st.invalidateChromeCache()
|
|
st.outMu.Lock()
|
|
_, _ = os.Stdout.Write([]byte("\x1b[?25h\x1b[H\x1b[2J"))
|
|
st.outMu.Unlock()
|
|
// Re-arm the host scroll region so the post-clear paint inherits
|
|
// the viewport bounds. Without this, a SIGWINCH-driven clearScreen
|
|
// followed by a long burst of child output (no DECSTBM of its own)
|
|
// would scroll the host's full screen — chrome included — every
|
|
// time the cursor reached the bottom row.
|
|
st.installHostScrollRegion()
|
|
}
|
|
|
|
// installHostScrollRegion writes DECSTBM to bound the host's scroll
|
|
// region to mainTop..mainBottom, then disables origin mode and CUPs
|
|
// back to viewport-top. With this in place a child that emits LF / IND
|
|
// / NEL / RI / SU / SD / IL / DL at the bottom of the viewport scrolls
|
|
// only within the viewport rows — the tab bar and status row never see
|
|
// the scroll. renderFocusedSnapshot already emits the same prelude for
|
|
// snapshot replays; this method covers the windows in between (initial
|
|
// startup, post-SIGWINCH, post-clearScreen) when no snapshot fires.
|
|
func (st *uiState) installHostScrollRegion() {
|
|
layout := st.layoutSnapshot()
|
|
mainBottom := int(layout.statusRow) - statusRows
|
|
if mainBottom < int(layout.mainTop) {
|
|
return
|
|
}
|
|
st.outMu.Lock()
|
|
defer st.outMu.Unlock()
|
|
fmt.Fprintf(os.Stdout, "\x1b[?6l\x1b[%d;%dr\x1b[%d;%dH",
|
|
int(layout.mainTop), mainBottom,
|
|
int(layout.mainTop), int(layout.mainLeft))
|
|
}
|
|
|
|
// invalidateChromeCache forces the next drawTabBar / drawSidebar /
|
|
// drawStatusLine call to actually emit bytes, regardless of cached
|
|
// content. Anything that clears or repaints the screen (resize, focus
|
|
// change, full repaint) must call this — otherwise the chrome stays
|
|
// blank because the cached frame still matches the unchanged state
|
|
// even though the wire was cleared.
|
|
// padsList returns the cached scratchpad listing, refreshing from
|
|
// disk on the first call after invalidation. Callers must not mutate
|
|
// the returned slice — it is shared.
|
|
func (st *uiState) padsList() []scratchpad.Entry {
|
|
st.padsCacheMu.Lock()
|
|
if st.padsCache != nil {
|
|
out := st.padsCache
|
|
st.padsCacheMu.Unlock()
|
|
return out
|
|
}
|
|
st.padsCacheMu.Unlock()
|
|
entries, err := st.pads.List()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
st.padsCacheMu.Lock()
|
|
st.padsCache = entries
|
|
st.padsCacheMu.Unlock()
|
|
return entries
|
|
}
|
|
|
|
// markChromeDirty schedules a chrome (tab bar + status line) repaint
|
|
// on the next ticker frame. Cheap to call from the per-PTY-chunk hot
|
|
// path. Latency-sensitive sites (focus change, owner flip, attention,
|
|
// trust prompts) keep calling drawTabBar / drawStatusLine directly.
|
|
func (st *uiState) markChromeDirty() {
|
|
st.chromeDirty.Store(true)
|
|
select {
|
|
case st.chromeWake <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// markSidebarDirty schedules a sidebar repaint on the next ticker
|
|
// frame. Hot path — every scrolled PTY chunk lands here. Synchronous
|
|
// repaints from latency-sensitive sites (spawn, exit, focus, state
|
|
// change, trust) keep calling drawSidebar directly.
|
|
func (st *uiState) markSidebarDirty() {
|
|
st.sidebarDirty.Store(true)
|
|
select {
|
|
case st.chromeWake <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (st *uiState) invalidateChromeCache() {
|
|
st.chromeCacheMu.Lock()
|
|
st.tabBarCache = ""
|
|
st.sidebarCache = ""
|
|
st.statusLineCache = ""
|
|
st.chromeCacheMu.Unlock()
|
|
}
|
|
|
|
func (st *uiState) moveToViewportOrigin() {
|
|
layout := st.layoutSnapshot()
|
|
st.outMu.Lock()
|
|
defer st.outMu.Unlock()
|
|
fmt.Fprintf(os.Stdout, "\x1b[%d;%dH", int(layout.mainTop), int(layout.mainLeft))
|
|
}
|
|
|
|
func (st *uiState) renderPaletteLocked() {
|
|
if st.palette == nil {
|
|
return
|
|
}
|
|
st.outMu.Lock()
|
|
defer st.outMu.Unlock()
|
|
cols, rows := st.hostSizeSnapshot()
|
|
st.palette.render(wrapWriter(os.Stdout), int(cols), int(rows))
|
|
}
|
|
|
|
// drawStatusLine renders SPEC §4's bottom status line. Left side: input
|
|
// ownership toast ("orchestrator driving" / "you have control") and any
|
|
// attention ask. Right side: palette hint. The PTY child occupies
|
|
// host_rows-1 rows so this row is exclusively ours.
|
|
func (st *uiState) drawStatusLine() {
|
|
var entry time.Time
|
|
if st.metrics != nil {
|
|
entry = time.Now()
|
|
}
|
|
st.mu.Lock()
|
|
palOpen := st.palette != nil
|
|
focusID := st.focusedID
|
|
focusName := st.focusedName
|
|
attention := st.attentionText
|
|
attentionAt := st.attentionAt
|
|
var trustMsg string
|
|
if st.pendingTrust != nil {
|
|
trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName)
|
|
}
|
|
st.mu.Unlock()
|
|
if palOpen {
|
|
return
|
|
}
|
|
cols, rows := st.hostSizeSnapshot()
|
|
if cols == 0 || rows == 0 {
|
|
return
|
|
}
|
|
// Resolve the focused child once — drawStatusLine fires on every
|
|
// PTY chunk and ticker tick, and FindChild takes the session
|
|
// mutex.
|
|
var focusedChild *Child
|
|
if focusID != "" {
|
|
focusedChild = st.sess.FindChild(focusID)
|
|
}
|
|
owner := ""
|
|
if focusedChild != nil {
|
|
switch focusedChild.Owner() {
|
|
case OwnerOrchestrator:
|
|
owner = "orchestrator driving"
|
|
case OwnerUser:
|
|
owner = "you have control"
|
|
}
|
|
}
|
|
left := ""
|
|
if focusName != "" {
|
|
left = focusName
|
|
}
|
|
if owner != "" {
|
|
if left != "" {
|
|
left = left + " · " + owner
|
|
} else {
|
|
left = owner
|
|
}
|
|
}
|
|
if attention != "" && attentionAt == focusID {
|
|
left = "[!] " + attention
|
|
}
|
|
if attention != "" && attentionAt == "" {
|
|
// Sticky attention/flash from somewhere outside the focused pane.
|
|
left = "[!] " + attention
|
|
}
|
|
if trustMsg != "" {
|
|
left = "[trust] " + trustMsg
|
|
}
|
|
// Hints decay left-to-right when the host is narrow so the focused
|
|
// child name + ownership note on the left side never get clipped.
|
|
// Context-specific hints are appended so they survive longest.
|
|
hints := []string{
|
|
"Ctrl-A/D · tabs",
|
|
"Ctrl-W/S · tree",
|
|
"Ctrl-K · palette",
|
|
}
|
|
if focusedChild != nil {
|
|
hints = append(hints, "Ctrl-B · scroll")
|
|
if focusedChild.Kind == KindCommand {
|
|
hints = append(hints, "Ctrl-R · restart")
|
|
}
|
|
}
|
|
right := strings.Join(hints, " · ")
|
|
for len(hints) > 1 && int(cols)-len(left)-len(right) < 1 {
|
|
hints = hints[1:]
|
|
right = strings.Join(hints, " · ")
|
|
}
|
|
|
|
pad := int(cols) - len(left) - len(right)
|
|
if pad < 1 {
|
|
pad = 1
|
|
}
|
|
line := left + strings.Repeat(" ", pad) + right
|
|
if len(line) > int(cols) {
|
|
line = line[:int(cols)]
|
|
}
|
|
st.chromeCacheMu.Lock()
|
|
if line == st.statusLineCache {
|
|
st.chromeCacheMu.Unlock()
|
|
if st.metrics != nil {
|
|
st.metrics.recordStatus(time.Since(entry), true)
|
|
}
|
|
return
|
|
}
|
|
st.statusLineCache = line
|
|
st.chromeCacheMu.Unlock()
|
|
if st.metrics != nil {
|
|
defer func() { st.metrics.recordStatus(time.Since(entry), false) }()
|
|
}
|
|
|
|
st.outMu.Lock()
|
|
defer st.outMu.Unlock()
|
|
// Save cursor, move to last row col 1, write, restore.
|
|
fmt.Fprintf(os.Stdout, "\x1b7\x1b[999;1H\x1b[2m\x1b[7m%s\x1b[0m\x1b8", line)
|
|
}
|
|
|
|
// renderEmptyState is the SPEC §4 blank-canvas hint. Drawn whenever no
|
|
// child is focused.
|
|
func (st *uiState) renderEmptyState() {
|
|
layout := st.layoutSnapshot()
|
|
st.outMu.Lock()
|
|
defer st.outMu.Unlock()
|
|
line := "Press Ctrl-K to spawn an agent or process"
|
|
row := int(layout.mainTop) + (int(layout.childRows()) / 2)
|
|
col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2)
|
|
if row < int(layout.mainTop) {
|
|
row = int(layout.mainTop)
|
|
}
|
|
if col < int(layout.mainLeft) {
|
|
col = int(layout.mainLeft)
|
|
}
|
|
fmt.Fprintf(os.Stdout, "\x1b[?25l\x1b[H\x1b[2J\x1b[%d;%dH\x1b[2m%s\x1b[0m", row, col, line)
|
|
}
|
|
|
|
func (st *uiState) hostSizeSnapshot() (uint16, uint16) {
|
|
st.dimsMu.Lock()
|
|
defer st.dimsMu.Unlock()
|
|
return st.hostCols, st.hostRows
|
|
}
|
|
|
|
func (st *uiState) layoutSnapshot() terminalLayout {
|
|
st.dimsMu.Lock()
|
|
defer st.dimsMu.Unlock()
|
|
return st.layoutLocked()
|
|
}
|
|
|
|
func (st *uiState) layoutLocked() terminalLayout {
|
|
return newTerminalLayout(st.hostCols, st.hostRows)
|
|
}
|
|
|
|
// splitOnEnter walks input and returns each Enter byte (CR or LF) as
|
|
// its own slice, with the surrounding non-Enter bytes batched between.
|
|
// Empty pieces are dropped. The result preserves byte order, so
|
|
// "hello\rworld\n" yields ["hello", "\r", "world", "\n"]. Callers use
|
|
// this to keep Enter keystrokes from getting bundled into the same
|
|
// PTY write as the text that preceded them — TUI agents' paste
|
|
// detection (claude/codex/opencode) otherwise swallows the CR as
|
|
// literal content instead of treating it as a key event.
|
|
func splitOnEnter(in []byte) [][]byte {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
var out [][]byte
|
|
start := 0
|
|
for i, b := range in {
|
|
if b != '\r' && b != '\n' {
|
|
continue
|
|
}
|
|
if i > start {
|
|
out = append(out, in[start:i])
|
|
}
|
|
out = append(out, in[i:i+1])
|
|
start = i + 1
|
|
}
|
|
if start < len(in) {
|
|
out = append(out, in[start:])
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (st *uiState) stdinLoop() error {
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
n, err := os.Stdin.Read(buf)
|
|
if n > 0 {
|
|
st.processStdin(buf[:n])
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("read error %w (n=%d)", err, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// processStdin walks one read of stdin byte by byte. The palette
|
|
// intercepts everything when it's open. Otherwise Ctrl-K opens it and
|
|
// every other byte forwards to the focused PTY. The Ctrl-K Ctrl-K chord
|
|
// is SPEC §4's passthrough prefix: after the first Ctrl-K, if the very
|
|
// next byte is another Ctrl-K, both are sent to the PTY literally.
|
|
//
|
|
// NOTE on locking: a palette-close action (spawn / switch / kill /
|
|
// quit) may fire session listeners (OnChildSpawned, OnChildExited)
|
|
// synchronously. Those listeners need st.mu. We must NOT hold st.mu
|
|
// when calling closePalette — bytes after the action in the same chunk
|
|
// are dropped on the floor, which is the right behavior anyway (the
|
|
// user just decided the prior pane is gone).
|
|
func (st *uiState) processStdin(chunk []byte) {
|
|
st.mu.Lock()
|
|
|
|
// Trust modal is modal: y/Y accepts, n/N or ESC denies. Everything
|
|
// else is ignored so a typo doesn't leak into the focused PTY while
|
|
// the prompt is up. SPEC §7 trust gate.
|
|
if st.pendingTrust != nil {
|
|
req := *st.pendingTrust
|
|
consumed := 0
|
|
var resolved string
|
|
for _, b := range chunk {
|
|
consumed++
|
|
switch b {
|
|
case 'y', 'Y':
|
|
resolved = "accept"
|
|
case 'n', 'N', 0x1b: // ESC
|
|
resolved = "deny"
|
|
default:
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
if resolved != "" {
|
|
st.pendingTrust = nil
|
|
st.mu.Unlock()
|
|
if resolved == "accept" {
|
|
if err := st.trust.Grant(req.presetName); err != nil {
|
|
st.flashError(fmt.Sprintf("trust grant: %v", err))
|
|
} else {
|
|
st.flashTransient(fmt.Sprintf("trusted preset %q (retry the call)", req.presetName))
|
|
}
|
|
} else {
|
|
st.flashTransient(fmt.Sprintf("denied trust for preset %q", req.presetName))
|
|
}
|
|
st.drawStatusLine()
|
|
// Discard the rest of the chunk; we intentionally don't
|
|
// recurse into the regular handler so a stray Enter doesn't
|
|
// submit anything to the focused PTY.
|
|
_ = consumed
|
|
return
|
|
}
|
|
st.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
forward := make([]byte, 0, len(chunk))
|
|
flushForward := func() {
|
|
if len(forward) == 0 {
|
|
return
|
|
}
|
|
if st.focusedID != "" {
|
|
if c := st.sess.FindChild(st.focusedID); c != nil && c.Status() == StatusRunning {
|
|
prev := c.Owner()
|
|
// InjectAsUser splits Enter bytes onto their own
|
|
// writes so claude / codex / opencode don't treat a
|
|
// "text\r" batch as a paste.
|
|
_ = c.InjectAsUser(forward)
|
|
if prev != OwnerUser {
|
|
go st.drawStatusLine()
|
|
}
|
|
}
|
|
}
|
|
forward = forward[:0]
|
|
}
|
|
|
|
var pendingAction *paletteAction
|
|
var pendingNav navEntry
|
|
var pendingRestartID string
|
|
var pendingViewportDelta int
|
|
var pendingViewportBottom bool
|
|
var pendingPadStep int
|
|
var pendingPadExit bool
|
|
|
|
// childOnPrimary captures whether the focused child is on its primary
|
|
// screen at the start of this chunk. Wheel events on the primary
|
|
// screen scroll the emulator viewport (inline scrollback); on the
|
|
// alternate screen they fall through to the child PTY so vim / less /
|
|
// codex can consume them.
|
|
childOnPrimary := false
|
|
if st.focusedID != "" {
|
|
if c := st.sess.FindChild(st.focusedID); c != nil {
|
|
if em := c.Emulator(); em != nil {
|
|
if sc, err := em.ActiveScreen(); err == nil && sc == vt.ScreenPrimary {
|
|
childOnPrimary = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tracks the last arrow direction and the byte offset immediately
|
|
// after its CSI sequence. Some terminals emit a duplicate adjacent
|
|
// arrow event for one physical keypress (legacy `CSI B` + kitty
|
|
// `CSI 57353 u`, or two of the same form back-to-back). We collapse
|
|
// those into a single navigation step. Any non-arrow byte resets the
|
|
// tracker so genuine consecutive presses across other input still
|
|
// register normally.
|
|
var lastNav byte
|
|
var lastNavEnd int
|
|
|
|
i := 0
|
|
for i < len(chunk) {
|
|
b := chunk[i]
|
|
|
|
// Scratchpad mode: pad has no PTY destination, so input is
|
|
// repurposed for scrolling the rendered markdown view.
|
|
// Scroll-wheel events are the primary control (we enable SGR
|
|
// mouse reporting in focusScratchpad); arrow keys / PgUp/PgDn /
|
|
// Home / End work for keyboard users. App-level chords (Ctrl-K
|
|
// palette, Ctrl-WASD focus, Ctrl-B scrollback) fall through to
|
|
// the handlers below; everything else is swallowed silently so
|
|
// typing into a pad view can't leak to a child PTY.
|
|
//
|
|
// When the palette is open we skip this block entirely so the
|
|
// palette handler below receives every byte. Otherwise typing
|
|
// (and Esc) get swallowed here and the palette appears wedged.
|
|
if st.focusedPad != "" && st.palette == nil {
|
|
if b == 0x1b { // ESC or CSI
|
|
if n := csiLen(chunk, i); n > 0 {
|
|
final := chunk[i+n-1]
|
|
params := chunk[i+2 : i+n-1]
|
|
// SGR mouse: `CSI < button ; col ; row M/m`. We
|
|
// enabled 1006 reporting on focus, so the host emits
|
|
// this form. Wheel-up = 64, wheel-down = 65; +shift
|
|
// adds 4 → 68/69; +ctrl adds 16 → 80/81. We treat
|
|
// any wheel button as a 3-row step.
|
|
if final == 'M' && len(params) > 0 && params[0] == '<' {
|
|
if step, ok := parseSGRMouseWheel(params[1:]); ok {
|
|
pendingPadStep += step
|
|
i += n
|
|
continue
|
|
}
|
|
// Non-wheel mouse event (click/drag/release):
|
|
// drop silently. Pads don't have a click model
|
|
// yet, and forwarding to a child would be
|
|
// confusing while the pad view is up.
|
|
i += n
|
|
continue
|
|
}
|
|
if final == 'm' && len(params) > 0 && params[0] == '<' {
|
|
// SGR release event — always drop.
|
|
i += n
|
|
continue
|
|
}
|
|
switch final {
|
|
case 'A':
|
|
pendingPadStep -= 1
|
|
i += n
|
|
continue
|
|
case 'B':
|
|
pendingPadStep += 1
|
|
i += n
|
|
continue
|
|
case '~':
|
|
pstr := string(params)
|
|
layout := st.layoutLocked()
|
|
page := int(layout.childRows()) - 2
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
switch pstr {
|
|
case "5":
|
|
pendingPadStep -= page
|
|
i += n
|
|
continue
|
|
case "6":
|
|
pendingPadStep += page
|
|
i += n
|
|
continue
|
|
case "1", "7":
|
|
pendingPadStep -= 1 << 30
|
|
i += n
|
|
continue
|
|
case "4", "8":
|
|
pendingPadStep += 1 << 30
|
|
i += n
|
|
continue
|
|
}
|
|
case 'u':
|
|
if k, ok := decodeCSIu(string(params)); ok && k.event == 1 {
|
|
switch k.key {
|
|
case kittyKeyUp:
|
|
pendingPadStep -= 1
|
|
i += n
|
|
continue
|
|
case kittyKeyDown:
|
|
pendingPadStep += 1
|
|
i += n
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
// Unhandled CSI: drop so the pad view stays stable
|
|
// instead of letting stray escapes hit the next
|
|
// handler block.
|
|
i += n
|
|
continue
|
|
}
|
|
// Legacy X10 mouse: `CSI M Cb Cx Cy`, three raw bytes
|
|
// after the M. csiLen consumed only up to 'M'; pick up
|
|
// the three trailing bytes here. Cb is button + 32;
|
|
// wheel = 64 → byte 96, wheel-down = 65 → byte 97.
|
|
if i+5 < len(chunk) && chunk[i+1] == '[' && chunk[i+2] == 'M' {
|
|
cb := chunk[i+3]
|
|
switch cb {
|
|
case 96, 100, 112: // 64, 68, 80 — wheel up variants
|
|
pendingPadStep -= 3
|
|
i += 6
|
|
continue
|
|
case 97, 101, 113: // 65, 69, 81 — wheel down variants
|
|
pendingPadStep += 3
|
|
i += 6
|
|
continue
|
|
}
|
|
// Non-wheel legacy mouse: drop the 6-byte event.
|
|
i += 6
|
|
continue
|
|
}
|
|
// Bare ESC exits the pad view.
|
|
pendingPadExit = true
|
|
i++
|
|
break
|
|
}
|
|
// Plain bytes (letters, control chars other than ESC) drop
|
|
// silently except for the app-level chords we explicitly
|
|
// allow through below.
|
|
if hit, _ := matchCtrlK(chunk, i); hit {
|
|
// fall through to the app-level handler
|
|
} else if hit, _ := matchCtrlChar(chunk, i, 'a'); hit {
|
|
} else if hit, _ := matchCtrlChar(chunk, i, 'd'); hit {
|
|
} else if hit, _ := matchCtrlChar(chunk, i, 'w'); hit {
|
|
} else if hit, _ := matchCtrlChar(chunk, i, 's'); hit {
|
|
} else {
|
|
i++
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Palette mode swallows all bytes.
|
|
if st.palette != nil {
|
|
if nav, navLen := peekArrowEvent(chunk, i); nav != 0 {
|
|
if i == lastNavEnd && nav == lastNav {
|
|
i += navLen
|
|
continue
|
|
}
|
|
lastNav = nav
|
|
lastNavEnd = i + navLen
|
|
} else {
|
|
lastNav = 0
|
|
lastNavEnd = -1
|
|
}
|
|
|
|
action, done, adv := st.palette.handleInput(chunk, i)
|
|
if adv <= 0 {
|
|
adv = 1
|
|
}
|
|
i += adv
|
|
if done {
|
|
a := action
|
|
pendingAction = &a
|
|
break
|
|
}
|
|
st.renderPaletteLocked()
|
|
continue
|
|
}
|
|
|
|
// Ctrl-K is the reserved app-level binding. Two cases:
|
|
// - Ctrl-K then anything except Ctrl-K → open palette.
|
|
// - Ctrl-K Ctrl-K → forward both keystrokes to the child raw.
|
|
//
|
|
// Ctrl-K is recognised in legacy (0x0B), kitty CSI u, and xterm
|
|
// modifyOtherKeys encodings — see matchCtrlK. The chord forwards
|
|
// the bytes the terminal actually emitted, so a child that asked
|
|
// for kitty input gets kitty input.
|
|
if hit, adv := matchCtrlK(chunk, i); hit {
|
|
if hit2, adv2 := matchCtrlK(chunk, i+adv); hit2 {
|
|
flushForward()
|
|
forward = append(forward, chunk[i:i+adv+adv2]...)
|
|
flushForward()
|
|
i += adv + adv2
|
|
continue
|
|
}
|
|
flushForward()
|
|
st.openPaletteLocked()
|
|
i += adv
|
|
continue
|
|
}
|
|
|
|
// Ctrl+WASD: directional focus navigation, matching the four
|
|
// arrow keys you'd expect in a tiling layout. A/D step between
|
|
// top-level tabs; W/S step through the current tab's process
|
|
// list (root first, then sub-agents). Bytes after the chord
|
|
// in the same chunk are dropped — the focus change makes
|
|
// further forwarding ambiguous between old and new pane.
|
|
if hit, adv := matchCtrlChar(chunk, i, 'a'); hit {
|
|
flushForward()
|
|
if id := nextTabID(st.sess.Children(), st.focusedID, -1); id != "" {
|
|
pendingNav = navEntry{childID: id}
|
|
}
|
|
i += adv
|
|
break
|
|
}
|
|
if hit, adv := matchCtrlChar(chunk, i, 'd'); hit {
|
|
flushForward()
|
|
if id := nextTabID(st.sess.Children(), st.focusedID, +1); id != "" {
|
|
pendingNav = navEntry{childID: id}
|
|
}
|
|
i += adv
|
|
break
|
|
}
|
|
if hit, adv := matchCtrlChar(chunk, i, 'w'); hit {
|
|
flushForward()
|
|
pendingNav = nextNavEntry(st.sess.Children(), st.focusedID, st.focusedPad, st.activeAgentID, st.padsList(), -1)
|
|
i += adv
|
|
break
|
|
}
|
|
if hit, adv := matchCtrlChar(chunk, i, 's'); hit {
|
|
flushForward()
|
|
pendingNav = nextNavEntry(st.sess.Children(), st.focusedID, st.focusedPad, st.activeAgentID, st.padsList(), +1)
|
|
i += adv
|
|
break
|
|
}
|
|
if hit, adv := matchCtrlChar(chunk, i, 'r'); hit {
|
|
if c := st.sess.FindChild(st.focusedID); c != nil && c.Kind == KindCommand {
|
|
flushForward()
|
|
pendingRestartID = c.ID
|
|
i += adv
|
|
break
|
|
}
|
|
}
|
|
// Ctrl-B snaps the focused child's emulator viewport back to the
|
|
// active area. Use this as the escape hatch from a scrolled-up
|
|
// state — wheel scrolls move the viewport into the libghostty
|
|
// scrollback history; Ctrl-B brings it back. The chord is
|
|
// intercepted before forwarding so the child shell doesn't see a
|
|
// stray Ctrl-B (readline backward-char).
|
|
if hit, adv := matchCtrlChar(chunk, i, 'b'); hit {
|
|
if st.focusedID != "" {
|
|
flushForward()
|
|
pendingViewportBottom = true
|
|
i += adv
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Inline wheel scrollback for a focused child on the primary
|
|
// screen. The host always has SGR mouse reporting armed (see
|
|
// enterScreen), so wheel events arrive here even when the child
|
|
// shell never asked for mouse input. On the alternate screen we
|
|
// let the bytes fall through to forward so vim / less / codex
|
|
// receive the wheel event as input.
|
|
if childOnPrimary && b == 0x1b {
|
|
if n := csiLen(chunk, i); n > 0 {
|
|
final := chunk[i+n-1]
|
|
params := chunk[i+2 : i+n-1]
|
|
if final == 'M' && len(params) > 0 && params[0] == '<' {
|
|
if step, ok := parseSGRMouseWheel(params[1:]); ok {
|
|
pendingViewportDelta += step
|
|
i += n
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
// Legacy X10 mouse wheel: `CSI M Cb Cx Cy`.
|
|
if i+5 < len(chunk) && chunk[i+1] == '[' && chunk[i+2] == 'M' {
|
|
cb := chunk[i+3]
|
|
switch cb {
|
|
case 96, 100, 112:
|
|
pendingViewportDelta -= 3
|
|
i += 6
|
|
continue
|
|
case 97, 101, 113:
|
|
pendingViewportDelta += 3
|
|
i += 6
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
forward = append(forward, b)
|
|
i++
|
|
}
|
|
flushForward()
|
|
st.mu.Unlock()
|
|
|
|
if pendingAction != nil {
|
|
st.closePalette(*pendingAction)
|
|
}
|
|
if !pendingNav.empty() {
|
|
switch {
|
|
case pendingNav.isPad():
|
|
st.focusScratchpad(pendingNav.pad)
|
|
case pendingNav.isChild():
|
|
st.focusProcess(pendingNav.childID)
|
|
}
|
|
}
|
|
if pendingRestartID != "" {
|
|
st.restartFocusedCommand(pendingRestartID)
|
|
}
|
|
if pendingViewportDelta != 0 {
|
|
st.scrollFocusedViewport(pendingViewportDelta)
|
|
}
|
|
if pendingViewportBottom {
|
|
st.scrollFocusedViewportToBottom()
|
|
}
|
|
if pendingPadStep != 0 {
|
|
st.padScroll(pendingPadStep)
|
|
}
|
|
if pendingPadExit {
|
|
st.exitPadView()
|
|
}
|
|
}
|
|
|
|
// scrollFocusedViewport scrolls the focused child's emulator viewport by
|
|
// `delta` rows (negative is up into scrollback history, positive is down
|
|
// towards the active area) and repaints the main pane against the new
|
|
// snapshot. No-op if no child is focused or the emulator isn't live yet.
|
|
func (st *uiState) scrollFocusedViewport(delta int) {
|
|
st.mu.Lock()
|
|
id := st.focusedID
|
|
st.mu.Unlock()
|
|
if id == "" {
|
|
return
|
|
}
|
|
c := st.sess.FindChild(id)
|
|
if c == nil {
|
|
return
|
|
}
|
|
em := c.Emulator()
|
|
if em == nil {
|
|
return
|
|
}
|
|
if err := em.ScrollViewportDelta(delta); err != nil {
|
|
return
|
|
}
|
|
st.repaintFocused()
|
|
}
|
|
|
|
// scrollFocusedViewportToBottom snaps the focused child's emulator
|
|
// viewport back to the active (live) area. Bound to Ctrl-B as the escape
|
|
// hatch from a scrolled-up state.
|
|
func (st *uiState) scrollFocusedViewportToBottom() {
|
|
st.mu.Lock()
|
|
id := st.focusedID
|
|
st.mu.Unlock()
|
|
if id == "" {
|
|
return
|
|
}
|
|
c := st.sess.FindChild(id)
|
|
if c == nil {
|
|
return
|
|
}
|
|
em := c.Emulator()
|
|
if em == nil {
|
|
return
|
|
}
|
|
if err := em.ScrollViewportBottom(); err != nil {
|
|
return
|
|
}
|
|
st.repaintFocused()
|
|
}
|
|
|
|
func (st *uiState) openPaletteLocked() {
|
|
st.palette = newPalette(st.sess.Children(), st.focusedID, st.focusedPad, st.presets)
|
|
// Push a "no kitty flags" entry onto the host terminal's keyboard
|
|
// stack so palette input arrives in plain legacy form regardless of
|
|
// what the focused child pushed. Codex/ratatui enables kitty mode
|
|
// for its own PTY; that push gets forwarded to the host and leaves
|
|
// the host emitting arrow keys in multiple forms, which manifests
|
|
// as the palette double-stepping on Down/Up. Popped on close.
|
|
st.outMu.Lock()
|
|
_, _ = os.Stdout.WriteString("\x1b[>0u")
|
|
st.outMu.Unlock()
|
|
st.renderPaletteLocked()
|
|
}
|
|
|
|
// closePalette is invoked with st.mu UNLOCKED. The session-mutating
|
|
// actions below (spawn / kill) fire listeners that take st.mu, so
|
|
// holding it here would deadlock. Each helper this calls takes its own
|
|
// brief mu acquisitions as needed.
|
|
func (st *uiState) closePalette(action paletteAction) {
|
|
st.mu.Lock()
|
|
st.palette = nil
|
|
st.mu.Unlock()
|
|
// Pair with the push in openPaletteLocked: restore whatever
|
|
// keyboard flags the focused child had configured.
|
|
st.outMu.Lock()
|
|
_, _ = os.Stdout.WriteString("\x1b[<u")
|
|
st.outMu.Unlock()
|
|
st.clearScreen()
|
|
|
|
// When a scratchpad is focused, the "main viewport" is showing the
|
|
// pad's rendered body, not a child PTY. repaintFocused() would draw
|
|
// an empty state (focusedID == "") and leave the previous palette's
|
|
// top border visible in the pad area. Use the pad-aware helper for
|
|
// any branch below that wants to restore the prior view.
|
|
restoreView := func() {
|
|
st.mu.Lock()
|
|
padFocused := st.focusedPad != ""
|
|
st.mu.Unlock()
|
|
if padFocused {
|
|
st.repaintFocusedPad()
|
|
return
|
|
}
|
|
st.repaintFocused()
|
|
}
|
|
|
|
switch action.kind {
|
|
case "", "cancel":
|
|
restoreView()
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
|
|
case "spawn-agent":
|
|
if action.preset == nil {
|
|
restoreView()
|
|
return
|
|
}
|
|
l := st.layoutSnapshot()
|
|
st.launcher.SetSize(l.childCols(), l.childRows())
|
|
// LaunchAgent fires OnChildSpawned synchronously; it will draw
|
|
// chrome and set focus.
|
|
if _, err := st.launcher.LaunchAgent(action.preset, action.preset.Name, "", ""); err != nil {
|
|
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
|
|
}
|
|
|
|
case "spawn-process":
|
|
if action.preset == nil {
|
|
restoreView()
|
|
return
|
|
}
|
|
l := st.layoutSnapshot()
|
|
st.launcher.SetSize(l.childCols(), l.childRows())
|
|
if _, err := st.launcher.LaunchCommandPreset(action.preset, action.preset.Name, ""); err != nil {
|
|
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
|
|
}
|
|
|
|
case "spawn-terminal":
|
|
l := st.layoutSnapshot()
|
|
st.launcher.SetSize(l.childCols(), l.childRows())
|
|
if _, err := st.launcher.LaunchTerminal(nil, "terminal", "", "", nil); err != nil {
|
|
st.flashError(fmt.Sprintf("spawn terminal: %v", err))
|
|
}
|
|
|
|
case "spawn-process-submit":
|
|
if action.command == "" {
|
|
restoreView()
|
|
return
|
|
}
|
|
l := st.layoutSnapshot()
|
|
st.launcher.SetSize(l.childCols(), l.childRows())
|
|
display := action.command
|
|
if len(display) > 32 {
|
|
display = display[:31] + "…"
|
|
}
|
|
// shell=true so multi-word commands like "bun run dev" pass
|
|
// through `sh -lc` and the user's PATH resolves binaries the
|
|
// way they expect from an interactive shell.
|
|
c, err := st.launcher.LaunchCommandArgv([]string{action.command}, display, "", "", nil, true)
|
|
if err != nil {
|
|
st.flashError(fmt.Sprintf("spawn: %v", err))
|
|
return
|
|
}
|
|
c.SetAutoRestart(action.relaunch)
|
|
// LaunchCommandArgv fires OnChildSpawned synchronously, which
|
|
// drew the sidebar before AutoRestart was set. Invalidate so the
|
|
// ⟳ marker shows up on the next paint.
|
|
if action.relaunch {
|
|
st.chromeCacheMu.Lock()
|
|
st.sidebarCache = ""
|
|
st.chromeCacheMu.Unlock()
|
|
st.drawSidebar()
|
|
}
|
|
|
|
case "switch":
|
|
c := st.sess.FindChild(action.childID)
|
|
if c == nil || (c.Kind == KindAgent && c.Status() != StatusRunning) {
|
|
restoreView()
|
|
return
|
|
}
|
|
layout := st.layoutSnapshot()
|
|
st.mu.Lock()
|
|
leavingPad := st.focusedPad != ""
|
|
st.focusedPad = ""
|
|
st.focusedID = action.childID
|
|
st.focusedName = c.DisplayName()
|
|
st.updateActiveAgentLocked(c)
|
|
st.renderer = newViewportRenderer(layout)
|
|
st.mu.Unlock()
|
|
// Switching from a pad to a child: wipe the pad body so the
|
|
// child's snapshot paints onto a clean canvas, mirroring
|
|
// focusProcess.
|
|
if leavingPad {
|
|
st.clearViewportArea()
|
|
}
|
|
st.repaintFocused()
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
|
|
case "kill":
|
|
// User-initiated kill cancels any pending auto-restart so the
|
|
// process doesn't immediately come back.
|
|
if c := st.sess.FindChild(action.childID); c != nil {
|
|
c.SetAutoRestart(false)
|
|
}
|
|
_ = st.sess.Kill(action.childID, syscall.SIGTERM)
|
|
st.repaintFocused()
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
|
|
case "quit":
|
|
st.requestExit()
|
|
|
|
case "pad-delete":
|
|
st.handlePadDelete(action.padName)
|
|
|
|
case "pad-rename-submit":
|
|
st.handlePadRename(action.padName, action.newName)
|
|
|
|
case "pad-edit":
|
|
st.handlePadEdit(action.padName)
|
|
|
|
case "agent-rename-submit", "proc-rename-submit":
|
|
st.handleChildRename(action.childID, action.newName)
|
|
|
|
case "agent-close", "proc-delete":
|
|
st.handleChildClose(action.childID, action.kind == "proc-delete")
|
|
|
|
case "proc-stop":
|
|
st.handleProcStop(action.childID)
|
|
|
|
case "proc-restart":
|
|
st.handleProcRestart(action.childID)
|
|
}
|
|
}
|
|
|
|
func (st *uiState) handlePadDelete(name string) {
|
|
if name == "" || st.pads == nil {
|
|
st.repaintFocused()
|
|
return
|
|
}
|
|
if err := st.pads.Delete(name); err != nil {
|
|
st.flashError(fmt.Sprintf("delete %s: %v", name, err))
|
|
return
|
|
}
|
|
st.mu.Lock()
|
|
if st.focusedPad == name {
|
|
st.focusedPad = ""
|
|
}
|
|
st.mu.Unlock()
|
|
st.scratchpadsChanged()
|
|
st.repaintFocused()
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
}
|
|
|
|
func (st *uiState) handlePadRename(oldName, newName string) {
|
|
if oldName == "" || newName == "" || st.pads == nil {
|
|
st.repaintFocused()
|
|
return
|
|
}
|
|
if oldName == newName {
|
|
st.repaintFocused()
|
|
return
|
|
}
|
|
if err := st.pads.Rename(oldName, newName); err != nil {
|
|
st.flashError(fmt.Sprintf("rename %s: %v", oldName, err))
|
|
return
|
|
}
|
|
st.mu.Lock()
|
|
if st.focusedPad == oldName {
|
|
st.focusedPad = newName
|
|
}
|
|
st.mu.Unlock()
|
|
st.scratchpadsChanged()
|
|
st.repaintFocused()
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
}
|
|
|
|
// handlePadEdit launches an external editor (zed) on the focused
|
|
// scratchpad file. Fire-and-forget: we Start() the editor with
|
|
// stdin/stdout/stderr redirected to /dev/null and call Process.Release()
|
|
// so the patterm process doesn't accumulate zombies. The editor opens
|
|
// in its own window without suspending the TUI.
|
|
func (st *uiState) handlePadEdit(name string) {
|
|
if name == "" || st.pads == nil {
|
|
st.repaintFocused()
|
|
return
|
|
}
|
|
path, err := st.pads.Path(name)
|
|
if err != nil {
|
|
st.flashError(fmt.Sprintf("edit %s: %v", name, err))
|
|
return
|
|
}
|
|
null, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
|
|
if err != nil {
|
|
st.flashError(fmt.Sprintf("edit %s: open /dev/null: %v", name, err))
|
|
return
|
|
}
|
|
cmd := exec.Command("zed", path)
|
|
cmd.Stdin = null
|
|
cmd.Stdout = null
|
|
cmd.Stderr = null
|
|
if err := cmd.Start(); err != nil {
|
|
_ = null.Close()
|
|
st.flashError(fmt.Sprintf("edit %s: %v", name, err))
|
|
return
|
|
}
|
|
if cmd.Process != nil {
|
|
_ = cmd.Process.Release()
|
|
}
|
|
_ = null.Close()
|
|
st.repaintFocused()
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
}
|
|
|
|
func (st *uiState) handleChildRename(childID, newName string) {
|
|
if childID == "" || newName == "" {
|
|
st.repaintFocused()
|
|
return
|
|
}
|
|
c := st.sess.FindChild(childID)
|
|
if c == nil {
|
|
st.repaintFocused()
|
|
return
|
|
}
|
|
c.SetName(newName)
|
|
st.mu.Lock()
|
|
if st.focusedID == childID {
|
|
st.focusedName = newName
|
|
}
|
|
st.mu.Unlock()
|
|
st.chromeCacheMu.Lock()
|
|
st.tabBarCache = ""
|
|
st.sidebarCache = ""
|
|
st.chromeCacheMu.Unlock()
|
|
st.repaintFocused()
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
}
|
|
|
|
// handleChildClose removes a child entry entirely. For agents this is
|
|
// equivalent to a SIGTERM kill (the entry is ephemeral and disappears
|
|
// from the session once the PTY exits). For command processes it's
|
|
// equivalent to the MCP close_process tool: SIGKILL if alive, then
|
|
// drop the entry so it stops appearing in the switch/restart lists.
|
|
func (st *uiState) handleChildClose(childID string, kill bool) {
|
|
if childID == "" {
|
|
st.repaintFocused()
|
|
return
|
|
}
|
|
c := st.sess.FindChild(childID)
|
|
if c == nil {
|
|
st.repaintFocused()
|
|
return
|
|
}
|
|
c.SetAutoRestart(false)
|
|
if kill {
|
|
_ = st.sess.Close(childID, syscall.SIGKILL)
|
|
} else {
|
|
_ = st.sess.Kill(childID, syscall.SIGTERM)
|
|
}
|
|
st.repaintFocused()
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
}
|
|
|
|
func (st *uiState) handleProcStop(childID string) {
|
|
if childID == "" {
|
|
st.repaintFocused()
|
|
return
|
|
}
|
|
c := st.sess.FindChild(childID)
|
|
if c == nil {
|
|
st.repaintFocused()
|
|
return
|
|
}
|
|
c.SetAutoRestart(false)
|
|
_ = st.sess.Kill(childID, syscall.SIGTERM)
|
|
st.repaintFocused()
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
}
|
|
|
|
func (st *uiState) handleProcRestart(childID string) {
|
|
if childID == "" {
|
|
st.repaintFocused()
|
|
return
|
|
}
|
|
c := st.sess.FindChild(childID)
|
|
if c == nil {
|
|
st.repaintFocused()
|
|
return
|
|
}
|
|
layout := st.layoutSnapshot()
|
|
if err := st.sess.Restart(childID, syscall.SIGTERM, layout.childCols(), layout.childRows()); err != nil {
|
|
st.flashError(fmt.Sprintf("restart %s: %v", c.DisplayName(), err))
|
|
return
|
|
}
|
|
st.repaintFocused()
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
}
|
|
|
|
// flashError surfaces a spawn/etc. failure in the status line until the
|
|
// next attention update overwrites it. stderr is hidden under the alt
|
|
// screen so we can't rely on Fprintln(os.Stderr).
|
|
func (st *uiState) flashError(msg string) {
|
|
st.mu.Lock()
|
|
st.attentionText = msg
|
|
st.attentionAt = "" // shows on every focus until cleared
|
|
st.mu.Unlock()
|
|
st.renderEmptyState()
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
}
|
|
|
|
// flashTransient is the softer cousin of flashError used for
|
|
// trust-prompt resolutions. Same status-line surface; the prefix differs.
|
|
func (st *uiState) flashTransient(msg string) {
|
|
st.mu.Lock()
|
|
st.attentionText = msg
|
|
st.attentionAt = ""
|
|
st.mu.Unlock()
|
|
st.drawStatusLine()
|
|
}
|
|
|
|
// repaintFocused redraws the current focused child's screen snapshot.
|
|
// Callers must NOT hold st.mu — repaintFocused takes it
|
|
// briefly itself.
|
|
//
|
|
// We replay the emulator's padded grid snapshot rather than its VT
|
|
// serialization. SerializeVT can preserve style, but for diff-based TUIs
|
|
// we've seen it replay stale prompt layout that no longer matches the
|
|
// emulator grid; the padded snapshot is the source of truth for visible
|
|
// cells.
|
|
func (st *uiState) repaintFocused() {
|
|
layout := st.layoutSnapshot()
|
|
st.mu.Lock()
|
|
id := st.focusedID
|
|
renderer := st.renderer
|
|
st.mu.Unlock()
|
|
if id == "" {
|
|
st.renderEmptyState()
|
|
return
|
|
}
|
|
|
|
// Ratatui (codex) and other diff-based renderers can drift between
|
|
// their internal "last frame" model and the emulator state when they
|
|
// run unfocused, leaving incremental updates that target the wrong
|
|
// cells after we replay. Nudge the focused child to redraw fully so
|
|
// its next frame matches what we just put on the host.
|
|
if c := st.sess.FindChild(id); c != nil && c.Status() == StatusRunning {
|
|
cols, rows := layout.childCols(), layout.childRows()
|
|
defer c.NudgeRedraw(cols, rows)
|
|
}
|
|
|
|
out := st.renderFocusedSnapshot(id, renderer, layout)
|
|
if len(out) == 0 {
|
|
return
|
|
}
|
|
st.mu.Lock()
|
|
if st.focusedID == id {
|
|
st.repaintNextPTY = id
|
|
st.repaintNextPTYBudget = 2
|
|
}
|
|
st.mu.Unlock()
|
|
st.outMu.Lock()
|
|
defer st.outMu.Unlock()
|
|
_, _ = os.Stdout.Write(out)
|
|
}
|
|
|
|
// repaintFocusedPad paints the focused scratchpad's content into the
|
|
// main viewport, honouring the per-pad scroll offset and clamping it
|
|
// to the rendered body size so a shrunk pad doesn't leave the view
|
|
// scrolled past its last line.
|
|
func (st *uiState) repaintFocusedPad() {
|
|
st.mu.Lock()
|
|
name := st.focusedPad
|
|
st.mu.Unlock()
|
|
if name == "" {
|
|
return
|
|
}
|
|
layout := st.layoutSnapshot()
|
|
content, _, err := st.pads.Read(name)
|
|
if err != nil {
|
|
content = fmt.Sprintf("(scratchpad %q unreadable: %v)", name, err)
|
|
}
|
|
out := st.renderPadView(name, content, layout)
|
|
if len(out) == 0 {
|
|
return
|
|
}
|
|
st.outMu.Lock()
|
|
defer st.outMu.Unlock()
|
|
_, _ = os.Stdout.Write(out)
|
|
}
|
|
|
|
// renderPadView builds the bytes that paint a scratchpad's content
|
|
// into the main viewport. Title row, divider, then a markdown-rendered
|
|
// body windowed by the per-pad scroll offset. Caller owns outMu and
|
|
// any prior clearViewportArea.
|
|
func (st *uiState) renderPadView(name, content string, layout terminalLayout) []byte {
|
|
mainBottom := int(layout.statusRow) - statusRows
|
|
width := int(layout.childCols())
|
|
if mainBottom < int(layout.mainTop) || width < 1 {
|
|
return nil
|
|
}
|
|
bodyCols := width - 1
|
|
if bodyCols < 1 {
|
|
bodyCols = 1
|
|
}
|
|
rendered := renderMarkdownLines(content, bodyCols)
|
|
bodyRows := mainBottom - int(layout.mainTop) + 1 - 2
|
|
if bodyRows < 1 {
|
|
bodyRows = 1
|
|
}
|
|
maxOffset := len(rendered) - bodyRows
|
|
if maxOffset < 0 {
|
|
maxOffset = 0
|
|
}
|
|
st.mu.Lock()
|
|
if st.padOffset > maxOffset {
|
|
st.padOffset = maxOffset
|
|
}
|
|
if st.padOffset < 0 {
|
|
st.padOffset = 0
|
|
}
|
|
offset := st.padOffset
|
|
st.mu.Unlock()
|
|
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "\x1b[0m\x1b[?6l\x1b[%d;%dr\x1b[?25l\x1b[%d;%dH",
|
|
int(layout.mainTop), mainBottom,
|
|
int(layout.mainTop), int(layout.mainLeft))
|
|
|
|
row := int(layout.mainTop)
|
|
writeRow := func(prefix, body, style string) {
|
|
if row > mainBottom {
|
|
return
|
|
}
|
|
fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX", row, int(layout.mainLeft), width)
|
|
fmt.Fprintf(&b, "\x1b[%d;%dH%s", row, int(layout.mainLeft), style)
|
|
b.WriteString(prefix)
|
|
b.WriteString(body)
|
|
b.WriteString(styleReset)
|
|
row++
|
|
}
|
|
|
|
// Header tells the user which pad they're viewing and the scroll
|
|
// position so a partial view is obvious.
|
|
end := offset + bodyRows
|
|
if end > len(rendered) {
|
|
end = len(rendered)
|
|
}
|
|
title := fmt.Sprintf(" %s (%d-%d / %d · ↑/↓ PgUp/PgDn · Esc back)",
|
|
name, offset+1, end, len(rendered))
|
|
if len(rendered) == 0 {
|
|
title = fmt.Sprintf(" %s (empty · Esc back)", name)
|
|
}
|
|
writeRow("", title, styleActive+styleBold)
|
|
if width > 2 {
|
|
writeRow("", " "+strings.Repeat("─", width-2), styleBorder)
|
|
} else {
|
|
writeRow("", strings.Repeat("─", width), styleBorder)
|
|
}
|
|
|
|
for i := offset; i < end; i++ {
|
|
writeRow(" ", rendered[i], "")
|
|
}
|
|
for row <= mainBottom {
|
|
writeRow("", "", "")
|
|
}
|
|
return []byte(b.String())
|
|
}
|
|
|
|
// exitPadView leaves scratchpad focus and falls back to the first
|
|
// running top-level child, or an empty viewport if there is none. No-op
|
|
// when no pad is focused.
|
|
func (st *uiState) exitPadView() {
|
|
st.mu.Lock()
|
|
if st.focusedPad == "" {
|
|
st.mu.Unlock()
|
|
return
|
|
}
|
|
st.focusedPad = ""
|
|
st.focusedName = ""
|
|
st.mu.Unlock()
|
|
st.clearViewportArea()
|
|
if next := firstRunningTopLevel(st.sess.Children()); next != nil {
|
|
st.focusProcess(next.ID)
|
|
return
|
|
}
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
}
|
|
|
|
// padScroll moves the focused-pad viewport by delta rows (negative =
|
|
// up, positive = down). No-op if no pad is focused. Clamping is
|
|
// performed against the rendered row count inside renderPadView, so
|
|
// callers can pass arbitrarily large step values for "jump to end".
|
|
func (st *uiState) padScroll(delta int) {
|
|
st.mu.Lock()
|
|
if st.focusedPad == "" {
|
|
st.mu.Unlock()
|
|
return
|
|
}
|
|
st.padOffset += delta
|
|
if st.padOffset < 0 {
|
|
st.padOffset = 0
|
|
}
|
|
st.mu.Unlock()
|
|
st.repaintFocusedPad()
|
|
}
|
|
|
|
func (st *uiState) renderFocusedSnapshot(id string, renderer *viewportRenderer, layout terminalLayout) []byte {
|
|
text, cursor, err := st.sess.SnapshotChild(id)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if renderer != nil {
|
|
if styled, err := st.sess.StyledSnapshotChild(id); err == nil && len(styled) > 0 {
|
|
mainBottom := int(layout.statusRow) - statusRows
|
|
prelude := fmt.Sprintf(
|
|
"\x1b[0m\x1b[?6l\x1b[%d;%dr\x1b[?25h\x1b[%d;%dH",
|
|
int(layout.mainTop), mainBottom,
|
|
int(layout.mainTop), int(layout.mainLeft),
|
|
)
|
|
out := []byte(prelude)
|
|
out = append(out, renderer.ClearViewport()...)
|
|
out = append(out, renderer.Render(styled)...)
|
|
cup := fmt.Sprintf("\x1b[%d;%dH", int(cursor.Row)+1, int(cursor.Col)+1)
|
|
out = append(out, renderer.Render([]byte(cup))...)
|
|
return out
|
|
}
|
|
}
|
|
out := renderScreenSnapshot(text, cursor, layout)
|
|
if renderer != nil {
|
|
cup := fmt.Sprintf("\x1b[%d;%dH", int(cursor.Row)+1, int(cursor.Col)+1)
|
|
out = append(out, renderer.Render([]byte(cup))...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (st *uiState) requestExit() {
|
|
// Reuse SIGTERM-to-self as the cleanest way to unwind: the signal
|
|
// handler in Run() calls cancel() which exits the loop and runs
|
|
// Shutdown.
|
|
_ = syscall.Kill(os.Getpid(), syscall.SIGTERM)
|
|
}
|
|
|
|
func hostSize() (cols, rows uint16) {
|
|
ws, err := cpty.GetsizeFull(os.Stdin)
|
|
if err != nil || ws.Cols == 0 || ws.Rows == 0 {
|
|
return 120, 40
|
|
}
|
|
return ws.Cols, ws.Rows
|
|
}
|