725 lines
18 KiB
Go
725 lines
18 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
|
|
cpty "github.com/creack/pty"
|
|
"golang.org/x/term"
|
|
|
|
"github.com/harrybrwn/patterm/internal/mcp"
|
|
"github.com/harrybrwn/patterm/internal/policy"
|
|
"github.com/harrybrwn/patterm/internal/preset"
|
|
"github.com/harrybrwn/patterm/internal/scratchpad"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
pol, err := policy.Load()
|
|
if err != nil {
|
|
return fmt.Errorf("app: load policy: %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)
|
|
}
|
|
|
|
// 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()
|
|
|
|
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, pol, 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,
|
|
hostCols: cols,
|
|
hostRows: rows,
|
|
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
|
|
}
|
|
host.attention = 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())
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
// SIGWINCH.
|
|
wg.Add(1)
|
|
winch := make(chan os.Signal, 1)
|
|
signal.Notify(winch, syscall.SIGWINCH)
|
|
go func() {
|
|
defer wg.Done()
|
|
defer signal.Stop(winch)
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-winch:
|
|
c, r := hostSize()
|
|
if c == 0 || r == 0 {
|
|
continue
|
|
}
|
|
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.repaintFocused()
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
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
|
|
|
|
outMu sync.Mutex
|
|
|
|
mu sync.Mutex
|
|
palette *paletteState
|
|
focusedID string
|
|
focusedName 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
|
|
// passthrough: when true, the next keystroke is forwarded to the
|
|
// focused PTY untouched (SPEC §4 Ctrl-K Ctrl-K).
|
|
passthroughArmed bool
|
|
|
|
// attention is the latest request_human_attention surfaced via MCP;
|
|
// rendered in the status line until cleared.
|
|
attentionText string
|
|
attentionAt string
|
|
|
|
dimsMu sync.Mutex
|
|
hostCols, hostRows uint16
|
|
stdinTTY bool
|
|
|
|
lastExit atomic.Int32
|
|
}
|
|
|
|
func (st *uiState) dbgf(format string, args ...any) {
|
|
logf(format, args...)
|
|
}
|
|
|
|
// 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.Name
|
|
}
|
|
st.mu.Lock()
|
|
st.attentionText = fmt.Sprintf("attention: %s — %s", name, reason)
|
|
st.attentionAt = childID
|
|
st.mu.Unlock()
|
|
st.drawStatusLine()
|
|
}
|
|
|
|
// OnChildSpawned auto-focuses the new child.
|
|
func (st *uiState) OnChildSpawned(c *Child) {
|
|
st.mu.Lock()
|
|
st.focusedID = c.ID
|
|
st.focusedName = c.Name
|
|
st.renderer = newViewportRenderer(st.layoutSnapshot())
|
|
if st.palette != nil {
|
|
st.palette.children = st.sess.Children()
|
|
st.palette.focused = st.focusedID
|
|
st.palette.rebuild()
|
|
st.renderPaletteLocked()
|
|
}
|
|
st.mu.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()))
|
|
st.mu.Lock()
|
|
if c.ID == st.focusedID {
|
|
next := firstRunningTopLevel(st.sess.Children())
|
|
if next == nil {
|
|
st.focusedID = ""
|
|
st.focusedName = ""
|
|
st.renderEmptyStateLocked()
|
|
} else {
|
|
st.focusedID = next.ID
|
|
st.focusedName = next.Name
|
|
st.renderer = newViewportRenderer(st.layoutSnapshot())
|
|
}
|
|
}
|
|
if st.palette != nil {
|
|
st.palette.children = st.sess.Children()
|
|
st.palette.focused = st.focusedID
|
|
st.palette.rebuild()
|
|
st.renderPaletteLocked()
|
|
}
|
|
st.mu.Unlock()
|
|
if st.focusedID != "" {
|
|
st.repaintFocused()
|
|
}
|
|
st.drawTabBar()
|
|
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) {
|
|
st.mu.Lock()
|
|
focus := st.focusedID
|
|
palOpen := st.palette != nil
|
|
renderer := st.renderer
|
|
st.mu.Unlock()
|
|
if palOpen || focus != childID || renderer == nil {
|
|
return
|
|
}
|
|
out := renderer.Render(chunk)
|
|
st.outMu.Lock()
|
|
_, _ = os.Stdout.Write([]byte("\x1b[?7l"))
|
|
_, _ = os.Stdout.Write(out)
|
|
_, _ = os.Stdout.Write([]byte("\x1b[?7h"))
|
|
st.outMu.Unlock()
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
}
|
|
|
|
func (st *uiState) enterScreen() {
|
|
st.outMu.Lock()
|
|
defer st.outMu.Unlock()
|
|
_, _ = os.Stdout.Write([]byte("\x1b[?1049h\x1b[H\x1b[2J\x1b[?25h"))
|
|
}
|
|
|
|
func (st *uiState) leaveScreen() {
|
|
st.outMu.Lock()
|
|
defer st.outMu.Unlock()
|
|
_, _ = os.Stdout.Write([]byte("\x1b[?25h\x1b[?1049l"))
|
|
}
|
|
|
|
func (st *uiState) clearScreen() {
|
|
st.outMu.Lock()
|
|
defer st.outMu.Unlock()
|
|
_, _ = os.Stdout.Write([]byte("\x1b[?25h\x1b[H\x1b[2J"))
|
|
}
|
|
|
|
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
|
|
st.mu.Unlock()
|
|
if palOpen {
|
|
return
|
|
}
|
|
cols, rows := st.hostSizeSnapshot()
|
|
if cols == 0 || rows == 0 {
|
|
return
|
|
}
|
|
owner := ""
|
|
if focusID != "" {
|
|
if c := st.sess.FindChild(focusID); c != nil {
|
|
switch c.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
|
|
}
|
|
right := "Ctrl-K · palette"
|
|
|
|
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.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() {
|
|
st.mu.Lock()
|
|
defer st.mu.Unlock()
|
|
st.renderEmptyStateLocked()
|
|
}
|
|
|
|
func (st *uiState) renderEmptyStateLocked() {
|
|
st.outMu.Lock()
|
|
defer st.outMu.Unlock()
|
|
layout := st.layoutSnapshot()
|
|
line := "Press Ctrl-K to spawn an agent or process"
|
|
row := int(layout.mainTop) + (int(layout.childRows()) / 2)
|
|
col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2)
|
|
if row < int(layout.mainTop) {
|
|
row = int(layout.mainTop)
|
|
}
|
|
if col < int(layout.mainLeft) {
|
|
col = int(layout.mainLeft)
|
|
}
|
|
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)
|
|
}
|
|
|
|
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()
|
|
|
|
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()
|
|
_ = c.InjectAsUser(forward)
|
|
if prev != OwnerUser {
|
|
go st.drawStatusLine()
|
|
}
|
|
}
|
|
}
|
|
forward = forward[:0]
|
|
}
|
|
|
|
var pendingAction *paletteAction
|
|
|
|
i := 0
|
|
for i < len(chunk) {
|
|
b := chunk[i]
|
|
|
|
// Passthrough armed: forward this byte literally regardless of
|
|
// what it is, then disarm.
|
|
if st.passthroughArmed {
|
|
forward = append(forward, b)
|
|
st.passthroughArmed = false
|
|
i++
|
|
continue
|
|
}
|
|
|
|
// Palette mode swallows all bytes.
|
|
if st.palette != nil {
|
|
var peek []byte
|
|
if i+1 < len(chunk) {
|
|
peek = chunk[i+1:]
|
|
}
|
|
action, done := st.palette.handleKey(b, peek)
|
|
if b == 0x1b && len(peek) >= 2 && peek[0] == '[' {
|
|
if peek[1] == 'A' || peek[1] == 'B' {
|
|
i += 3
|
|
} else {
|
|
i++
|
|
}
|
|
} else {
|
|
i++
|
|
}
|
|
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 → arm passthrough; the next byte goes raw.
|
|
if b == keyCtrlK {
|
|
// Peek at the next byte if we have it.
|
|
next := byte(0)
|
|
haveNext := i+1 < len(chunk)
|
|
if haveNext {
|
|
next = chunk[i+1]
|
|
}
|
|
if haveNext && next == keyCtrlK {
|
|
// Chord: forward both Ctrl-K bytes literally. (Some
|
|
// nested TUIs expect Ctrl-K itself.)
|
|
flushForward()
|
|
forward = append(forward, keyCtrlK, keyCtrlK)
|
|
flushForward()
|
|
i += 2
|
|
continue
|
|
}
|
|
if !haveNext {
|
|
// Could be the first byte of a chord — arm and wait.
|
|
st.passthroughArmed = true
|
|
// But we also want palette-open on a lone Ctrl-K. Resolve
|
|
// by treating "Ctrl-K at end of read" as palette open;
|
|
// any subsequent Ctrl-K in the next read still has the
|
|
// chord semantics because passthroughArmed got set first.
|
|
// To match the spec's reading, simpler model: lone Ctrl-K
|
|
// in this read opens the palette.
|
|
st.passthroughArmed = false
|
|
flushForward()
|
|
st.openPaletteLocked()
|
|
i++
|
|
continue
|
|
}
|
|
// Ctrl-K followed by something that's not Ctrl-K → palette open.
|
|
flushForward()
|
|
st.openPaletteLocked()
|
|
i++
|
|
continue
|
|
}
|
|
|
|
forward = append(forward, b)
|
|
i++
|
|
}
|
|
flushForward()
|
|
st.mu.Unlock()
|
|
|
|
if pendingAction != nil {
|
|
st.closePalette(*pendingAction)
|
|
}
|
|
}
|
|
|
|
func (st *uiState) openPaletteLocked() {
|
|
st.palette = newPalette(st.sess.Children(), st.focusedID, st.presets)
|
|
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()
|
|
st.clearScreen()
|
|
|
|
switch action.kind {
|
|
case "", "cancel":
|
|
st.repaintFocused()
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
|
|
case "spawn-agent":
|
|
if action.preset == nil {
|
|
st.repaintFocused()
|
|
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 {
|
|
st.repaintFocused()
|
|
return
|
|
}
|
|
l := st.layoutSnapshot()
|
|
st.launcher.SetSize(l.childCols(), l.childRows())
|
|
if _, err := st.launcher.LaunchProcess(action.preset, action.preset.Name); err != nil {
|
|
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
|
|
}
|
|
|
|
case "switch":
|
|
c := st.sess.FindChild(action.childID)
|
|
if c == nil || c.Status() != StatusRunning {
|
|
st.repaintFocused()
|
|
return
|
|
}
|
|
st.mu.Lock()
|
|
st.focusedID = action.childID
|
|
st.focusedName = c.Name
|
|
st.renderer = newViewportRenderer(st.layoutSnapshot())
|
|
st.mu.Unlock()
|
|
st.repaintFocused()
|
|
st.drawTabBar()
|
|
st.drawSidebar()
|
|
st.drawStatusLine()
|
|
|
|
case "kill":
|
|
_ = 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()
|
|
}
|
|
|
|
// repaintFocused redraws the current focused child's screen snapshot.
|
|
// Callers must NOT hold st.mu — repaintFocused takes it
|
|
// briefly itself.
|
|
func (st *uiState) repaintFocused() {
|
|
st.mu.Lock()
|
|
id := st.focusedID
|
|
st.mu.Unlock()
|
|
if id == "" {
|
|
st.renderEmptyState()
|
|
return
|
|
}
|
|
text, cursor, err := st.sess.SnapshotChild(id)
|
|
if err != nil {
|
|
return
|
|
}
|
|
out := renderScreenSnapshot(text, cursor, st.layoutSnapshot())
|
|
st.outMu.Lock()
|
|
defer st.outMu.Unlock()
|
|
_, _ = os.Stdout.Write(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
|
|
}
|