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/hjbdev/patterm/internal/mcp" "github.com/hjbdev/patterm/internal/preset" "github.com/hjbdev/patterm/internal/scratchpad" "github.com/hjbdev/patterm/internal/trust" ) // 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) } // 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, 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, trust: trustStore, hostCols: cols, hostRows: rows, stdinTTY: term.IsTerminal(int(os.Stdin.Fd())), } host.attention = st host.focus = st host.prompter = 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 trust *trust.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 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 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 } st.mu.Lock() st.focusedID = c.ID st.focusedName = c.DisplayName() st.renderer = newViewportRenderer(st.layoutSnapshot()) st.mu.Unlock() st.repaintFocused() st.drawTabBar() st.drawSidebar() st.drawStatusLine() } // 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 renderer := newViewportRenderer(st.layoutSnapshot()) 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() } 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())) 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) { 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) } 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.invalidateChromeCache() st.outMu.Lock() defer st.outMu.Unlock() _, _ = os.Stdout.Write([]byte("\x1b[?25h\x1b[H\x1b[2J")) } // 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. 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 } 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 } if attention != "" && attentionAt == "" { // Sticky attention/flash from somewhere outside the focused pane. left = "[!] " + attention } if trustMsg != "" { left = "[trust] " + trustMsg } 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.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() { 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) } // 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 pendingNavID string // 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] // 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() pendingNavID = nextTabID(st.sess.Children(), st.focusedID, -1) i += adv break } if hit, adv := matchCtrlChar(chunk, i, 'd'); hit { flushForward() pendingNavID = nextTabID(st.sess.Children(), st.focusedID, +1) i += adv break } if hit, adv := matchCtrlChar(chunk, i, 'w'); hit { flushForward() pendingNavID = nextChildID(st.sess.Children(), st.focusedID, -1) i += adv break } if hit, adv := matchCtrlChar(chunk, i, 's'); hit { flushForward() pendingNavID = nextChildID(st.sess.Children(), st.focusedID, +1) i += adv break } forward = append(forward, b) i++ } flushForward() st.mu.Unlock() if pendingAction != nil { st.closePalette(*pendingAction) } if pendingNavID != "" { st.focusProcess(pendingNavID) } } 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[