Files
patterm/internal/app/launch.go
Harry Bayliss 39a042bda8 Polish chrome and rework tab-switch repaint
Module renamed github.com/harrybrwn/patterm → github.com/hjbdev/patterm
across imports.

Chrome:
- Palette redrawn with rounded box-drawing borders, accent left-bar
  for the selected item, dim hints, and a separator-aware footer.
- Tab bar grew from 1 row to 3: labels with breathing room, a dim
  argv subtitle truncated to each tab's width, and an accent thick
  underline for the focused tab with a faint divider extending across
  the rest of the host width. Layout, viewport-renderer, and screen-
  renderer tests updated for the new mainTop.
- Sidebar reuses the same palette: accent section headers, `▎`
  selection marker, `●`/`○` status glyphs, dim previews.
- Shared SGR constants moved into internal/app/style.go.

Palette input:
- Adjacent duplicate arrow events (legacy `\x1b[B` + kitty
  `\x1b[57353u` for one keypress, or two of the same form) are now
  collapsed via peekArrowEvent + chunk-level dedupe in processStdin.
- On open, push `\x1b[>0u` onto the host's kitty keyboard stack so
  palette input is in plain legacy mode regardless of what the child
  pushed (codex/ratatui pushes its own flags which had been leaking
  to the host). Popped on close.

Tab-switch repaint (repaintFocused):
- Use the emulator's SerializeVT bytes (with SGR / cursor / DECSTBM
  / tabstops) instead of plain text, fed through the per-focused
  viewport renderer so the shifter translates row positions.
- Prelude resets host SGR / DECOM / DECSTBM (pinned to viewport) /
  cursor visibility before the replay, so leftover modes from the
  previously-focused child don't distort the new snapshot.
- Re-emit the saved cursor as a child-space CUP after the
  serialized bytes so the host cursor lands at the emulator's
  actual position (overriding DECSTBM's home side-effect and the
  tabstop-setup CHA sequences) AND the renderer's vr.row/vr.col
  get re-synced via trackCSI.
- cursorShifter now carries childRows and rewrites empty
  `\x1b[r` to `\x1b[<mainTop>;<mainBottom>r` (host coords) — the
  default (1,1) shifted to (4,4) was producing a one-row scrolling
  region that scroll-exploded the replay.
- After the snapshot lands, nudge the focused child with a one-row
  PTY winsize toggle so the kernel emits SIGWINCH and ratatui-style
  TUIs throw away their diff state and emit a fresh frame.

Codex still renders incorrectly after a focus switch; see TODO.md
"Switch-back render divergence" for the deep investigation handoff.
2026-05-14 16:02:40 +01:00

243 lines
6.8 KiB
Go

package app
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/hjbdev/patterm/internal/preset"
)
// Launcher knows how to turn a preset into a running child. Both the
// palette and the MCP spawn_agent tool route through here so MCP
// injection happens consistently. SPEC §10.
type Launcher struct {
sess *Session
mcpSocket string
bin string // path to this binary (for the mcp-stdio subcommand)
sizeMu sync.Mutex
cols, rows uint16
}
func NewLauncher(sess *Session, mcpSocket string, cols, rows uint16) *Launcher {
bin, err := os.Executable()
if err != nil {
bin = "patterm"
}
return &Launcher{sess: sess, mcpSocket: mcpSocket, bin: bin, cols: cols, rows: rows}
}
func (l *Launcher) SetSize(cols, rows uint16) {
l.sizeMu.Lock()
defer l.sizeMu.Unlock()
l.cols, l.rows = cols, rows
}
func (l *Launcher) size() (uint16, uint16) {
l.sizeMu.Lock()
defer l.sizeMu.Unlock()
return l.cols, l.rows
}
// LaunchAgent spawns the agent preset, applies the preset's MCP
// injection, waits for the ready signal, and types initial_prompt into
// the PTY. SPEC §7 spawn_agent, §8 conversation protocol.
func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, parentID string) (*Child, error) {
if p.Kind != preset.KindAgent {
return nil, fmt.Errorf("launch: %q is not an agent preset", p.Name)
}
argv := append([]string(nil), p.Argv...)
env := l.sess.ChildEnv()
for k, v := range p.Env {
env = append(env, k+"="+v)
}
// Mint a per-spawn MCP config file pointing at the mcp-stdio proxy
// with the new child's identity. We don't know the identity until
// we've created the child, but the child needs the env/argv at
// creation time — so we reserve the identity by pre-creating the
// MCP config with a placeholder, then patching it post-spawn.
identity, mcpConfigPath, err := l.writeMCPConfig()
if err != nil {
return nil, err
}
if p.MCPInjection != nil {
switch p.MCPInjection.Kind {
case "flag":
if p.MCPInjection.Flag == "" {
return nil, fmt.Errorf("preset %s: mcp_injection.flag required for kind=flag", p.Name)
}
argv = append(argv, p.MCPInjection.Flag, mcpConfigPath)
case "env_var":
if p.MCPInjection.Var == "" {
return nil, fmt.Errorf("preset %s: mcp_injection.var required for kind=env_var", p.Name)
}
env = append(env, p.MCPInjection.Var+"="+mcpConfigPath)
case "config_file":
// SPEC §10 mentions merging into an external config file. We
// expose the config_path via an env var the user can read
// at preset-creation time; full merge is deferred.
env = append(env, "PATTERM_MCP_CONFIG="+mcpConfigPath)
default:
return nil, fmt.Errorf("preset %s: unknown mcp_injection.kind %q", p.Name, p.MCPInjection.Kind)
}
}
// Spawn with the chosen identity.
cols, rows := l.size()
c, err := l.sess.Spawn(SpawnSpec{
Kind: KindAgent,
Argv: argv,
Env: env,
Name: displayName,
ParentID: parentID,
PresetRef: p.Name,
Identity: identity,
}, cols, rows)
if err != nil {
_ = os.Remove(mcpConfigPath)
return nil, err
}
// Wait for the preset's ready signal, then type the initial prompt.
idle := time.Duration(1000) * time.Millisecond
if p.ReadySignal != nil && p.ReadySignal.IdleMS > 0 {
idle = time.Duration(p.ReadySignal.IdleMS) * time.Millisecond
}
go func() {
waitForIdle(c, idle, 30*time.Second)
if initialPrompt == "" {
return
}
_ = c.InjectAsOrchestrator([]byte(initialPrompt + "\n"))
}()
return c, nil
}
// LaunchCommandPreset spawns a process preset as a SPEC §7 command
// entry. No MCP injection; just argv. The entry is session-persistent
// (survives PTY exit so it can be Restart'd).
func (l *Launcher) LaunchCommandPreset(p *preset.Preset, displayName, parentID string) (*Child, error) {
if p.Kind != preset.KindCommand {
return nil, fmt.Errorf("launch: %q is not a command preset", p.Name)
}
env := l.sess.ChildEnv()
for k, v := range p.Env {
env = append(env, k+"="+v)
}
cols, rows := l.size()
return l.sess.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: p.ResolvedArgv(),
Env: env,
Name: displayName,
ParentID: parentID,
WorkDir: p.WorkingDir,
PresetRef: p.Name,
}, cols, rows)
}
// LaunchCommandArgv spawns a freeform-argv command entry. Trust gating
// (SPEC §7) lives one level up in toolHost — by the time we get here
// trust is settled (freeform argv is implicitly trusted).
func (l *Launcher) LaunchCommandArgv(argv []string, displayName, parentID, workDir string, env []string, shell bool) (*Child, error) {
if shell && len(argv) > 0 {
argv = []string{"sh", "-lc", strings.Join(argv, " ")}
}
if env == nil {
env = l.sess.ChildEnv()
}
cols, rows := l.size()
return l.sess.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: argv,
Env: env,
Name: displayName,
ParentID: parentID,
WorkDir: workDir,
}, cols, rows)
}
// LaunchTerminal spawns a bare interactive shell. SPEC §7 kind=terminal.
// argv defaults to $SHELL -i when empty.
func (l *Launcher) LaunchTerminal(argv []string, displayName, parentID, workDir string, env []string) (*Child, error) {
if len(argv) == 0 {
sh := os.Getenv("SHELL")
if sh == "" {
sh = "/bin/sh"
}
argv = []string{sh, "-i"}
}
if env == nil {
env = l.sess.ChildEnv()
}
cols, rows := l.size()
return l.sess.Spawn(SpawnSpec{
Kind: KindTerminal,
Argv: argv,
Env: env,
Name: displayName,
ParentID: parentID,
WorkDir: workDir,
}, cols, rows)
}
func (l *Launcher) writeMCPConfig() (identity, path string, err error) {
identity = mintIdentity()
dir, err := preset.ConfigDir()
if err != nil {
return "", "", err
}
dir = filepath.Join(dir, "mcp")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", "", err
}
path = filepath.Join(dir, identity+".json")
cfg := map[string]any{
"mcpServers": map[string]any{
"patterm": map[string]any{
"command": l.bin,
"args": []string{"mcp-stdio", "--socket", l.mcpSocket, "--identity", identity},
},
},
}
body, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return "", "", err
}
body = append(body, '\n')
if err := os.WriteFile(path, body, 0o600); err != nil {
return "", "", err
}
return identity, path, nil
}
// waitForIdle polls the child's IdleMS until it exceeds idle, or until
// max elapses.
func waitForIdle(c *Child, idle, max time.Duration) {
deadline := time.Now().Add(max)
tick := time.NewTicker(100 * time.Millisecond)
defer tick.Stop()
for {
<-tick.C
if c.Status() != StatusRunning {
return
}
if c.IdleMS() >= idle.Milliseconds() && c.lastWriteNS.Load() != 0 {
return
}
if time.Now().After(deadline) {
return
}
}
}
// joinArgs flattens an argv slice into a single line (used for display
// hints).
func joinArgs(argv []string) string { return strings.Join(argv, " ") }