package app import ( "context" "errors" "fmt" "io" "os" "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 } 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() // 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() st := &uiState{ sess: sess, presets: presets, launcher: launcher, pads: pads, chromeWake: make(chan struct{}, 1), trust: trustStore, hostCols: cols, hostRows: rows, stdinTTY: term.IsTerminal(int(os.Stdin.Fd())), } host.attention = st host.focus = st host.prompter = st host.scratch = st st.lastExit.Store(-1) sess.Subscribe(st) st.enterScreen() st.renderEmptyState() st.drawTabBar() st.drawSidebar() st.drawStatusLine() // Set initial PTY grid for any future child. The child gets the // computed main viewport, excluding tab bar, sidebar, and status. sess.ResizeAll(layout.childCols(), layout.childRows()) launcher.SetSize(layout.childCols(), layout.childRows()) host.SetSize(layout.childCols(), layout.childRows()) // 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: } if !st.chromeDirty.Swap(false) { continue } st.drawTabBar() st.drawStatusLine() } }() // External termination: SPEC §2 step 4 (SIGTERM/SIGHUP → graceful exit). wg.Add(1) sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGHUP) go func() { defer wg.Done() defer signal.Stop(sigCh) select { case <-ctx.Done(): return case sig := <-sigCh: st.dbgf("signal %s; tearing down", sig) cancel() } }() // Stdin loop. go func() { if err := st.stdinLoop(); err != nil { st.dbgf("stdinLoop: %v", err) } cancel() }() <-ctx.Done() wg.Wait() st.leaveScreen() if restoreState != nil { _ = term.Restore(int(os.Stdin.Fd()), restoreState) } if st.lastExit.Load() >= 0 { fmt.Fprintf(os.Stderr, "patterm: last child exited (%d).\n", st.lastExit.Load()) } return nil } // uiState is the shared state between the SIGWINCH loop, the stdin // loop, and the session listener callbacks. type uiState struct { sess *Session presets preset.Set launcher *Launcher pads *scratchpad.Store trust *trust.Store outMu sync.Mutex mu sync.Mutex palette *paletteState focusedID string focusedName string // 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. 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 " / "Deny preset ". A future iteration // can promote this to a dedicated inline modal. pendingTrust *trustRequest dimsMu sync.Mutex hostCols, hostRows uint16 stdinTTY bool // chromeCacheMu guards the last-rendered byte cache for each chrome // element. The tab bar, sidebar, and status line all repaint on // many state changes and on every PTY chunk, but their content // usually doesn't change between calls — caching the rendered // output and skipping a write when it matches eliminates the // flicker (especially in the sidebar's session tree). chromeCacheMu sync.Mutex tabBarCache string sidebarCache string statusLineCache string // 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 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() st.mu.Lock() leavingPad := st.focusedPad != "" st.focusedPad = "" st.focusedID = c.ID st.focusedName = c.DisplayName() st.updateActiveAgentLocked(c) st.renderer = newViewportRenderer(layout) st.mu.Unlock() // 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() } // 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() st.mu.Lock() st.focusedPad = "" st.focusedID = c.ID st.focusedName = c.DisplayName() st.updateActiveAgentLocked(c) renderer := newViewportRenderer(layout) st.renderer = renderer palOpen := st.palette != nil if palOpen { st.palette.children = st.sess.Children() st.palette.focused = st.focusedID st.palette.rebuild() st.renderPaletteLocked() } // Prime the snapshot-replay budget for the new child. Diff-based // vendor TUIs (claude/codex/opencode) emit incremental updates that // assume the host display already matches their internal "last // frame" model. On a fresh spawn the host viewport was just cleared, // so incremental ops target cells that aren't populated yet — // leaving the corrupted pane the user works around by toggling // focus (which routes through repaintFocused). Setting the budget // here makes the next ~8 PTY chunks render from the full styled // emulator grid, so the host display tracks the emulator state // without needing a manual focus cycle. st.repaintNextPTY = c.ID st.repaintNextPTYBudget = 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.moveToViewportOrigin() st.drawTabBar() st.drawSidebar() st.drawStatusLine() } // OnChildExited drops focus and shows the empty state if it was the // focused child. func (st *uiState) OnChildExited(c *Child) { st.lastExit.Store(int32(c.ExitCode())) layout := st.layoutSnapshot() renderEmpty := false st.mu.Lock() if c.ID == st.focusedID { next := firstRunningTopLevel(st.sess.Children()) if next == nil { st.focusedID = "" st.focusedName = "" renderEmpty = true } else { st.focusedID = next.ID st.focusedName = next.DisplayName() st.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) { layout := st.layoutSnapshot() st.mu.Lock() focus := st.focusedID palOpen := st.palette != nil renderer := st.renderer forceRepaint := focus == childID && st.repaintNextPTY == childID && st.repaintNextPTYBudget > 0 if forceRepaint { renderer = newViewportRenderer(layout) st.renderer = renderer st.repaintNextPTYBudget-- if st.repaintNextPTYBudget == 0 { st.repaintNextPTY = "" } } st.mu.Unlock() if palOpen || focus != childID || renderer == nil { return } var out []byte if forceRepaint { out = st.renderFocusedSnapshot(childID, renderer, layout) if len(out) == 0 { return } } else { out = renderer.Render(chunk) } // 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"...) st.outMu.Lock() _, _ = os.Stdout.Write(wrapped) st.outMu.Unlock() // 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() // Scrolled chunks can clobber the sidebar columns; repaint // synchronously so the gap fills before the next chunk lands. st.drawSidebar() } // 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() } 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: } } func (st *uiState) invalidateChromeCache() { st.chromeCacheMu.Lock() st.tabBarCache = "" st.sidebarCache = "" st.statusLineCache = "" st.chromeCacheMu.Unlock() } func (st *uiState) moveToViewportOrigin() { layout := st.layoutSnapshot() st.outMu.Lock() defer st.outMu.Unlock() fmt.Fprintf(os.Stdout, "\x1b[%d;%dH", int(layout.mainTop), int(layout.mainLeft)) } func (st *uiState) renderPaletteLocked() { if st.palette == nil { return } st.outMu.Lock() defer st.outMu.Unlock() cols, rows := st.hostSizeSnapshot() st.palette.render(wrapWriter(os.Stdout), int(cols), int(rows)) } // drawStatusLine renders SPEC §4's bottom status line. Left side: input // ownership toast ("orchestrator driving" / "you have control") and any // attention ask. Right side: palette hint. The PTY child occupies // host_rows-1 rows so this row is exclusively ours. func (st *uiState) drawStatusLine() { st.mu.Lock() palOpen := st.palette != nil focusID := st.focusedID focusName := st.focusedName attention := st.attentionText attentionAt := st.attentionAt var trustMsg string if st.pendingTrust != nil { trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName) } st.mu.Unlock() if palOpen { return } cols, rows := st.hostSizeSnapshot() if cols == 0 || rows == 0 { return } // 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() return } st.statusLineCache = line st.chromeCacheMu.Unlock() st.outMu.Lock() defer st.outMu.Unlock() // Save cursor, move to last row col 1, write, restore. fmt.Fprintf(os.Stdout, "\x1b7\x1b[999;1H\x1b[2m\x1b[7m%s\x1b[0m\x1b8", line) } // renderEmptyState is the SPEC §4 blank-canvas hint. Drawn whenever no // child is focused. func (st *uiState) renderEmptyState() { layout := st.layoutSnapshot() st.outMu.Lock() defer st.outMu.Unlock() line := "Press Ctrl-K to spawn an agent or process" row := int(layout.mainTop) + (int(layout.childRows()) / 2) col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2) if row < int(layout.mainTop) { row = int(layout.mainTop) } if col < int(layout.mainLeft) { col = int(layout.mainLeft) } fmt.Fprintf(os.Stdout, "\x1b[?25l\x1b[H\x1b[2J\x1b[%d;%dH\x1b[2m%s\x1b[0m", row, col, line) } func (st *uiState) hostSizeSnapshot() (uint16, uint16) { st.dimsMu.Lock() defer st.dimsMu.Unlock() return st.hostCols, st.hostRows } func (st *uiState) layoutSnapshot() terminalLayout { st.dimsMu.Lock() defer st.dimsMu.Unlock() return st.layoutLocked() } func (st *uiState) layoutLocked() terminalLayout { return newTerminalLayout(st.hostCols, st.hostRows) } // splitOnEnter walks input and returns each Enter byte (CR or LF) as // its own slice, with the surrounding non-Enter bytes batched between. // Empty pieces are dropped. The result preserves byte order, so // "hello\rworld\n" yields ["hello", "\r", "world", "\n"]. Callers use // this to keep Enter keystrokes from getting bundled into the same // PTY write as the text that preceded them — TUI agents' paste // detection (claude/codex/opencode) otherwise swallows the CR as // literal content instead of treating it as a key event. func splitOnEnter(in []byte) [][]byte { if len(in) == 0 { return nil } var out [][]byte start := 0 for i, b := range in { if b != '\r' && b != '\n' { continue } if i > start { out = append(out, in[start:i]) } out = append(out, in[i:i+1]) start = i + 1 } if start < len(in) { out = append(out, in[start:]) } return out } func (st *uiState) stdinLoop() error { buf := make([]byte, 4096) for { n, err := os.Stdin.Read(buf) if n > 0 { st.processStdin(buf[:n]) } if err != nil { if errors.Is(err, io.EOF) { return nil } return fmt.Errorf("read error %w (n=%d)", err, n) } } } // processStdin walks one read of stdin byte by byte. The palette // intercepts everything when it's open. Otherwise Ctrl-K opens it and // every other byte forwards to the focused PTY. The Ctrl-K Ctrl-K chord // is SPEC §4's passthrough prefix: after the first Ctrl-K, if the very // next byte is another Ctrl-K, both are sent to the PTY literally. // // NOTE on locking: a palette-close action (spawn / switch / kill / // quit) may fire session listeners (OnChildSpawned, OnChildExited) // synchronously. Those listeners need st.mu. We must NOT hold st.mu // when calling closePalette — bytes after the action in the same chunk // are dropped on the floor, which is the right behavior anyway (the // user just decided the prior pane is gone). func (st *uiState) processStdin(chunk []byte) { st.mu.Lock() // Trust modal is modal: y/Y accepts, n/N or ESC denies. Everything // else is ignored so a typo doesn't leak into the focused PTY while // the prompt is up. SPEC §7 trust gate. if st.pendingTrust != nil { req := *st.pendingTrust consumed := 0 var resolved string for _, b := range chunk { consumed++ switch b { case 'y', 'Y': resolved = "accept" case 'n', 'N', 0x1b: // ESC resolved = "deny" default: continue } break } if resolved != "" { st.pendingTrust = nil st.mu.Unlock() if resolved == "accept" { if err := st.trust.Grant(req.presetName); err != nil { st.flashError(fmt.Sprintf("trust grant: %v", err)) } else { st.flashTransient(fmt.Sprintf("trusted preset %q (retry the call)", req.presetName)) } } else { st.flashTransient(fmt.Sprintf("denied trust for preset %q", req.presetName)) } st.drawStatusLine() // Discard the rest of the chunk; we intentionally don't // recurse into the regular handler so a stray Enter doesn't // submit anything to the focused PTY. _ = consumed return } st.mu.Unlock() return } forward := make([]byte, 0, len(chunk)) flushForward := func() { if len(forward) == 0 { return } if st.focusedID != "" { if c := st.sess.FindChild(st.focusedID); c != nil && c.Status() == StatusRunning { prev := c.Owner() // InjectAsUser splits Enter bytes onto their own // writes so claude / codex / opencode don't treat a // "text\r" batch as a paste. _ = c.InjectAsUser(forward) if prev != OwnerUser { go st.drawStatusLine() } } } forward = forward[:0] } var pendingAction *paletteAction var 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. if st.focusedPad != "" { 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.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[ 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) { st.repaintFocused() return } layout := st.layoutSnapshot() st.mu.Lock() st.focusedID = action.childID st.focusedName = c.DisplayName() st.updateActiveAgentLocked(c) st.renderer = newViewportRenderer(layout) st.mu.Unlock() 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() } } // 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 }