diff --git a/AGENTS.md b/AGENTS.md index ef42892..15a584d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,29 @@ PATTERM_BIN=/absolute/path/to/patterm go test ./internal/harness/... Without `PATTERM_BIN`, harness tests build the current checkout once into a temp location and test that binary. +## Changelog + +User-visible changes go in `CHANGELOG.md` (Keep-a-Changelog format). +When finishing work that affects users — new MCP tools, palette +behavior, preset shapes, host chrome, anything observable — add a +bullet under `[Unreleased]` in the appropriate `Added` / `Changed` / +`Fixed` / `Removed` section. The TODO file is scratch space, not +history; the changelog is the record. + +When a `TODO.md` item is actioned (bug fixed, behavior changed, +feature shipped), the resolution belongs in `CHANGELOG.md` — not as +a "done" entry left in `TODO.md`. Workflow: + +1. Land the code change. +2. Add a `[Unreleased]` bullet in `CHANGELOG.md` describing what the + user will now experience differently. +3. Remove the corresponding item from `TODO.md` (don't tick it off + and leave it behind — `TODO.md` only lists outstanding work). + +If a TODO item turns out to be a non-issue or gets dropped without a +code change, just delete it from `TODO.md`; no changelog entry is +needed. + ## Development Notes - Prefer existing package boundaries. MCP protocol shapes live in `internal/mcp`; runtime behavior usually belongs in `internal/app`. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..89bd8b4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,95 @@ +# Changelog + +All notable changes to patterm are tracked in this file. Format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project +loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Ctrl+A / Ctrl+D step focus between top-level tabs; Ctrl+W / Ctrl+S + step through processes (root + sub-agents) inside the current tab. + Recognised in legacy, kitty CSI u, and xterm modifyOtherKeys + encodings. The chord shadows the corresponding raw byte for the + focused pane — pressing Ctrl+D no longer sends EOF to the + underlying shell, for instance. +- MCP protocol layer (`internal/mcp/protocol.go`) implementing + `initialize`, `tools/list`, `tools/call`, `ping`, and MCP + notifications. Tool catalog with input schemas is advertised via + `tools/list`. Real MCP clients (claude, etc.) can now complete the + handshake against patterm's per-PID socket. Legacy direct-tool + dispatch is preserved so the harness keeps working unchanged. +- `mcp_injection.kind = "cli_override"` for agents that accept inline + `key=value` config overrides on the command line. The default codex + preset uses it to emit `-c mcp_servers.patterm.command=…` and + `-c mcp_servers.patterm.args=[…]` — zero files written, no + `CODEX_HOME` override. +- `mcp_injection.kind = "config_env"` for agents that read their + config from an env var. The default opencode preset uses it to pass + a merged `opencode.json` inline through `OPENCODE_CONFIG_CONTENT`, + so auth/agents/tui.json/skills resolve from the user's real `$HOME` + with no XDG override. +- Palette macros: typing `sw `, `k `, or `sp ` filters the list to + switch / kill / spawn entries respectively. Footer shows the + available macros. + +### Changed +- Palette ordering: open agents/processes (`Switch to …`) now appear + above the option to spawn new ones, with kill entries pushed down + toward the end of the list. +- Tab bar trimmed from three rows (label / subtitle / underline) to + two (label / underline). Tabs flex to fill the available host width + evenly with leftover columns distributed to the leftmost tabs; the + `+ new` hint sits in a reserved slot on the right. Layout's + `mainTop` consequently drops from 4 to 3, giving each pane one + extra row of viewport. + +### Fixed +- Killed agents no longer linger in the command palette. Agent + entries that aren't running are filtered out of the switch list; + session-persistent commands (which can be restarted) stay visible. +- `tools/list` now emits a concrete `properties` object (`{}`) for + parameterless tools instead of `null`. Claude rejected the + `null`-properties form with "tools fetch failed" even though the + initialize handshake had succeeded. +- Sidebar no longer flickers on every PTY chunk. The tab bar, + sidebar, and status line now cache their last rendered byte string + and skip the write when the new frame matches; full repaint paths + (resize / focus change / palette close / screen clear) invalidate + the cache so the next draw fires unconditionally. +- Spawning a child agent now clears the viewport area before it + paints, so the previous focused child's PTY output no longer bleeds + through underneath the new pane. +- Orchestrator-injected input (initial agent prompts, MCP + `send_input` with `submit: true`, `send_message`, `timer_wait` + callbacks) now ends with CR (`\r`) instead of LF (`\n`). Claude + treated `\n` as "newline in textarea"; with CR the prompt actually + submits, matching what the host terminal sends when a user presses + Enter directly. +- Enter is now written to a child PTY as its own `write()` call, + separated from the preceding text by a short delay. Both + `InjectAsUser` (user typing forwarded through patterm) and + `InjectAsOrchestrator` (MCP / send_message / initial-prompt paths) + share the split. Without it, claude — and other paste-detecting + TUIs — coalesced `"hello\r"` into one read and inserted the CR as + literal text instead of treating it as the Enter keystroke. +- Sidebar (and tab bar) no longer get wiped when the focused child + issues `CSI 0 J` / `CSI 1 J` (clear-to-cursor). The viewport renderer + already clamped `CSI 2 J` and `CSI K` to viewport columns, but the + partial-screen variants were forwarded verbatim, so any tool-call + expansion in claude (Ctrl+O) would erase every cell to the right of + the cursor — including the right rail. Both forms are now translated + into per-row ECH sequences that stop at the viewport's right edge. +- Sidebar left border no longer vanishes when the viewport repaints. + The border column was the same column as the viewport's rightmost + cell, so any child write to that column (or `clearViewport`'s ECH) + would erase it. The viewport is now one column narrower so the + border has a dedicated column. + +## Conventions + +- This file is the single record of user-visible changes; the TODO is + scratch space, not history. +- One bullet per change, written in the past tense from the user's + point of view. Reference the package or preset name when it helps a + reader find the code. diff --git a/CLAUDE.md b/CLAUDE.md index ef42892..15a584d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,29 @@ PATTERM_BIN=/absolute/path/to/patterm go test ./internal/harness/... Without `PATTERM_BIN`, harness tests build the current checkout once into a temp location and test that binary. +## Changelog + +User-visible changes go in `CHANGELOG.md` (Keep-a-Changelog format). +When finishing work that affects users — new MCP tools, palette +behavior, preset shapes, host chrome, anything observable — add a +bullet under `[Unreleased]` in the appropriate `Added` / `Changed` / +`Fixed` / `Removed` section. The TODO file is scratch space, not +history; the changelog is the record. + +When a `TODO.md` item is actioned (bug fixed, behavior changed, +feature shipped), the resolution belongs in `CHANGELOG.md` — not as +a "done" entry left in `TODO.md`. Workflow: + +1. Land the code change. +2. Add a `[Unreleased]` bullet in `CHANGELOG.md` describing what the + user will now experience differently. +3. Remove the corresponding item from `TODO.md` (don't tick it off + and leave it behind — `TODO.md` only lists outstanding work). + +If a TODO item turns out to be a non-issue or gets dropped without a +code change, just delete it from `TODO.md`; no changelog entry is +needed. + ## Development Notes - Prefer existing package boundaries. MCP protocol shapes live in `internal/mcp`; runtime behavior usually belongs in `internal/app`. diff --git a/TODO.md b/TODO.md index cbe6a9c..e1f93f9 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,2 @@ -- [ ] Killed agents are visible in the command palette. They shouldn't be. -- [ ] claude failed to connect to patterm mcp -32601 -- [ ] codex doesn't show the patterm mcp at all -- [ ] opencode doesn't show the patterm mcp at all -- [ ] Open agents/processes should appear above the option to open a new one in the palette -- [ ] Some sort of macros in the command pallete would be nice, like if i type `sw ` it would only show the switch entries. Maybe we should have info text greyed out to show these macros. - - [ ] sw = switch - - [ ] k = kill - - [ ] sp = spawn +- [ ] When the parent agent/orchestrator is killed, all child processes spawned by it should also be killed. + - [ ] This should apply to when we detect the process dies, like if the user Ctrl + C's out. diff --git a/internal/app/app.go b/internal/app/app.go index e5daa64..c586785 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -235,6 +235,17 @@ type uiState struct { 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 } @@ -300,14 +311,28 @@ 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 { + 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() @@ -402,11 +427,26 @@ func (st *uiState) leaveScreen() { } 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() @@ -489,6 +529,14 @@ func (st *uiState) drawStatusLine() { 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. @@ -535,6 +583,36 @@ 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 { @@ -616,6 +694,9 @@ func (st *uiState) processStdin(chunk []byte) { 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() @@ -626,6 +707,7 @@ func (st *uiState) processStdin(chunk []byte) { } 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 @@ -691,6 +773,37 @@ func (st *uiState) processStdin(chunk []byte) { 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++ } @@ -700,6 +813,9 @@ func (st *uiState) processStdin(chunk []byte) { if pendingAction != nil { st.closePalette(*pendingAction) } + if pendingNavID != "" { + st.focusProcess(pendingNavID) + } } func (st *uiState) openPaletteLocked() { diff --git a/internal/app/child.go b/internal/app/child.go index 7c814d3..8689c5e 100644 --- a/internal/app/child.go +++ b/internal/app/child.go @@ -429,24 +429,42 @@ func (c *Child) teardownPTY() { // pane. SPEC §6: the user's first keystroke flips ownership. func (c *Child) InjectAsUser(b []byte) error { c.SetOwner(OwnerUser) - pty := c.PTY() - if pty == nil { - return errors.New("child has no pty") - } - _, err := pty.Write(b) - return err + return c.writeInput(b) } // InjectAsOrchestrator is the path send_message / initial_prompt / // timer_wait writes take. Ownership flips back to orchestrator. SPEC §6. func (c *Child) InjectAsOrchestrator(b []byte) error { c.SetOwner(OwnerOrchestrator) + return c.writeInput(b) +} + +// writeInput is the shared PTY write path used by both injection +// flavours. Each Enter byte (CR or LF) is split onto its own write +// with a brief delay so TUI agents with paste-detection (claude, +// codex, opencode) don't coalesce a trailing CR into the text that +// preceded it. Without the split, `pty.Write([]byte("hello\r"))` +// arrives at the agent as one read() and gets treated as multi-line +// pasted content rather than "key Enter". +func (c *Child) writeInput(b []byte) error { pty := c.PTY() if pty == nil { return errors.New("child has no pty") } - _, err := pty.Write(b) - return err + pieces := splitOnEnter(b) + if len(pieces) <= 1 { + _, err := pty.Write(b) + return err + } + for i, piece := range pieces { + if i > 0 { + time.Sleep(15 * time.Millisecond) + } + if _, err := pty.Write(piece); err != nil { + return err + } + } + return nil } func mintIdentity() string { diff --git a/internal/app/host.go b/internal/app/host.go index 3272922..653bb99 100644 --- a/internal/app/host.go +++ b/internal/app/host.go @@ -523,7 +523,12 @@ func encodeInput(args mcp.SendInputArgs) ([]byte, error) { } out := []byte(args.Text) if submit { - out = append(out, '\n') + // CR (`\r`) is what every terminal emits for Enter in raw + // mode, and what TUI agents (claude/codex/…) bind to + // "submit". Sending `\n` here used to land as a literal + // newline inside their textareas, leaving the message + // composed but not sent. + out = append(out, '\r') } return out, nil case "paste": @@ -635,13 +640,13 @@ func classifySendMessage(caller, target *Child, callerID, message string) (strin return "", mcp.Errorf("not_related", "send_message: cannot send to self") } if caller != nil && target.ParentID == caller.ID { - return "[orchestrator] " + message + "\n", nil + return "[orchestrator] " + message + "\r", nil } if caller != nil && caller.ParentID == target.ID { - return fmt.Sprintf("[sub-agent:%s] %s\n", caller.DisplayName(), message), nil + return fmt.Sprintf("[sub-agent:%s] %s\r", caller.DisplayName(), message), nil } if caller == nil && target.ParentID == "" { - return "[orchestrator] " + message + "\n", nil + return "[orchestrator] " + message + "\r", nil } return "", mcp.Errorf("not_related", "send_message: %q is neither parent nor child of caller (siblings must route through the parent in v1)", target.ID) } @@ -670,7 +675,7 @@ func (h *toolHost) TimerWait(callerID string, seconds float64, label string) (st if !caller.IsLive() { return } - line := fmt.Sprintf("[system] Your timer [%s] has completed.\n", label) + line := fmt.Sprintf("[system] Your timer [%s] has completed.\r", label) _ = caller.InjectAsOrchestrator([]byte(line)) }() return id, nil diff --git a/internal/app/keymatch.go b/internal/app/keymatch.go index 803f10b..a0b5f5b 100644 --- a/internal/app/keymatch.go +++ b/internal/app/keymatch.go @@ -142,3 +142,37 @@ func isModifyOtherKeysCtrlK(params string) bool { } return parts[0] == "27" && parts[1] == "5" && parts[2] == "107" } + +// matchCtrlChar reports whether chunk[i:] starts with Ctrl+ where +// ch is a lowercase ASCII letter. Recognises the same three encodings +// as matchCtrlK: legacy single byte (Ctrl-A = 0x01 .. Ctrl-Z = 0x1A), +// kitty CSI u with mods=5, and xterm modifyOtherKeys CSI 27;5;~. +// Only unmodified Ctrl (no Shift/Alt/Meta) and a press event match. +func matchCtrlChar(chunk []byte, i int, ch byte) (matched bool, advance int) { + if i >= len(chunk) || ch < 'a' || ch > 'z' { + return false, 0 + } + legacy := ch - 'a' + 1 + if chunk[i] == legacy { + return true, 1 + } + n := csiLen(chunk, i) + if n == 0 { + return false, 0 + } + final := chunk[i+n-1] + params := string(chunk[i+2 : i+n-1]) + switch final { + case 'u': + k, ok := decodeCSIu(params) + if ok && k.key == int(ch) && k.mods == 5 && k.event == 1 { + return true, n + } + case '~': + parts := strings.Split(params, ";") + if len(parts) == 3 && parts[0] == "27" && parts[1] == "5" && parts[2] == strconv.Itoa(int(ch)) { + return true, n + } + } + return false, 0 +} diff --git a/internal/app/launch.go b/internal/app/launch.go index 2d3a708..a5ab239 100644 --- a/internal/app/launch.go +++ b/internal/app/launch.go @@ -79,10 +79,39 @@ func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, par } 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. + // Merge patterm's MCP entry into a vendored copy of the + // user's existing config file, then point the child at the + // vendored copy via the preset's home_var. The real config + // file is never modified. + envAssign, _, mErr := mcpConfigMerge(p, p.MCPInjection, identity, l.bin, l.mcpSocket) + if mErr != nil { + _ = os.Remove(mcpConfigPath) + return nil, mErr + } + env = append(env, envAssign) env = append(env, "PATTERM_MCP_CONFIG="+mcpConfigPath) + case "cli_override": + // Inline -c key=value overrides for agents that accept + // them (codex's `-c mcp_servers.patterm.command=...`). No + // filesystem footprint, so the user's real config and auth + // are untouched. + extra, err := mcpCLIOverrideArgs(p, p.MCPInjection, identity, l.bin, l.mcpSocket) + if err != nil { + _ = os.Remove(mcpConfigPath) + return nil, err + } + argv = append(argv, extra...) + case "config_env": + // Read the user's config, merge patterm in, and pass the + // merged document inline via an env var (opencode's + // OPENCODE_CONFIG_CONTENT). Nothing is written to disk and + // XDG_CONFIG_HOME stays as the user set it. + assignment, err := mcpConfigEnv(p, p.MCPInjection, identity, l.bin, l.mcpSocket) + if err != nil { + _ = os.Remove(mcpConfigPath) + return nil, err + } + env = append(env, assignment) default: return nil, fmt.Errorf("preset %s: unknown mcp_injection.kind %q", p.Name, p.MCPInjection.Kind) } @@ -114,7 +143,10 @@ func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, par if initialPrompt == "" { return } - _ = c.InjectAsOrchestrator([]byte(initialPrompt + "\n")) + // InjectAsOrchestrator splits Enter onto its own PTY write so + // claude / codex / opencode treat the CR as a key event + // rather than the tail end of a multi-byte paste. + _ = c.InjectAsOrchestrator([]byte(initialPrompt + "\r")) }() return c, nil } diff --git a/internal/app/layout.go b/internal/app/layout.go index 1b5800a..3c59687 100644 --- a/internal/app/layout.go +++ b/internal/app/layout.go @@ -40,7 +40,11 @@ func newTerminalLayout(cols, rows uint16) terminalLayout { l.sidebarVisible = true l.sidebarWidth = sidebarCols l.sidebarLeft = cols - sidebarCols + 1 - l.mainCols = cols - sidebarCols + // The sidebar's left border lives one column to the left of + // sidebarLeft. The viewport must stop one column short of that + // border or child output (and clearViewport ECH) would erase + // it whenever the cursor reached the right margin. + l.mainCols = cols - sidebarCols - 1 } reservedRows := tabBarRows + statusRows diff --git a/internal/app/layout_test.go b/internal/app/layout_test.go index 5466432..dc796d5 100644 --- a/internal/app/layout_test.go +++ b/internal/app/layout_test.go @@ -11,13 +11,13 @@ func TestTerminalLayoutWideUsesMainViewport(t *testing.T) { if !l.sidebarVisible { t.Fatal("wide layout should show sidebar") } - if l.childCols() != 92 { - t.Fatalf("child cols: got %d want 92", l.childCols()) + if l.childCols() != 91 { + t.Fatalf("child cols: got %d want 91", l.childCols()) } - if l.childRows() != 36 { - t.Fatalf("child rows: got %d want 36", l.childRows()) + if l.childRows() != 37 { + t.Fatalf("child rows: got %d want 37", l.childRows()) } - if l.mainTop != 4 || l.statusRow != 40 { + if l.mainTop != 3 || l.statusRow != 40 { t.Fatalf("unexpected vertical chrome: mainTop=%d statusRow=%d", l.mainTop, l.statusRow) } } @@ -30,8 +30,8 @@ func TestTerminalLayoutNarrowHidesSidebar(t *testing.T) { if l.childCols() != 38 { t.Fatalf("child cols: got %d want 38", l.childCols()) } - if l.childRows() != 8 { - t.Fatalf("child rows: got %d want 8", l.childRows()) + if l.childRows() != 9 { + t.Fatalf("child rows: got %d want 9", l.childRows()) } } @@ -46,13 +46,13 @@ func TestSpawnSizingUsesViewportDimensions(t *testing.T) { l := newTerminalLayout(120, 40) launcher := NewLauncher(nil, "", l.childCols(), l.childRows()) cols, rows := launcher.size() - if cols != 92 || rows != 36 { - t.Fatalf("launcher size: got %dx%d want 92x36", cols, rows) + if cols != 91 || rows != 37 { + t.Fatalf("launcher size: got %dx%d want 91x37", cols, rows) } host := newToolHost(nil, nil, nil, preset.Set{}, nil, l.childCols(), l.childRows()) cols, rows = host.size() - if cols != 92 || rows != 36 { - t.Fatalf("tool host size: got %dx%d want 92x36", cols, rows) + if cols != 91 || rows != 37 { + t.Fatalf("tool host size: got %dx%d want 91x37", cols, rows) } } diff --git a/internal/app/mcp_inject.go b/internal/app/mcp_inject.go new file mode 100644 index 0000000..94d8673 --- /dev/null +++ b/internal/app/mcp_inject.go @@ -0,0 +1,377 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/hjbdev/patterm/internal/preset" +) + +// patternMcpEntryName is the canonical name patterm uses when slotting +// itself into an external MCP config block (codex's mcp_servers, +// opencode's mcp, etc.). Stable on purpose: a single name means +// repeated spawns replace the previous entry instead of accumulating. +const patternMcpEntryName = "patterm" + +// mcpConfigMerge prepares a vendored copy of the user's config file +// with patterm's MCP entry merged in, lays it out under a per-spawn +// home directory, and returns the env var assignment the child needs +// (e.g. "CODEX_HOME=/tmp/patterm-mcp-xxx"). +// +// patterm never modifies the user's real config file in place. The +// merged copy lives under $XDG_RUNTIME_DIR/patterm/agents// +// and is removed when the agent process exits. +func mcpConfigMerge(p *preset.Preset, inj *preset.MCPInjection, identity, bin, socket string) (envAssign, homeDir string, err error) { + // Allow older preset files that pre-date the home_var / home_path / + // format fields by falling back to known defaults for the well-known + // agent config paths. + homeVar, homePath, format := inj.HomeVar, inj.HomePath, strings.ToLower(inj.Format) + if homeVar == "" || homePath == "" || format == "" { + hv, hp, f := inferHomeFromPath(inj.Path) + if homeVar == "" { + homeVar = hv + } + if homePath == "" { + homePath = hp + } + if format == "" { + format = f + } + } + if format == "" { + switch strings.ToLower(filepath.Ext(inj.Path)) { + case ".toml": + format = "toml" + case ".json": + format = "json" + } + } + if homeVar == "" || homePath == "" { + return "", "", fmt.Errorf("preset %s: mcp_injection.config_file requires home_var and home_path (path %q not recognised; add the fields to the preset)", p.Name, inj.Path) + } + if inj.MergeKey == "" { + return "", "", fmt.Errorf("preset %s: mcp_injection.config_file requires merge_key", p.Name) + } + if format == "" { + return "", "", fmt.Errorf("preset %s: cannot infer mcp_injection.format from path %q", p.Name, inj.Path) + } + + homeDir, err = mcpRuntimeDir(identity) + if err != nil { + return "", "", err + } + dest := filepath.Join(homeDir, homePath) + if err := os.MkdirAll(filepath.Dir(dest), 0o700); err != nil { + return "", "", err + } + + src := expandUser(inj.Path) + // Mirror the user's real agent-home directory (auth, sessions, + // history, etc.) into the temp home via symlinks so codex / opencode + // still see their credentials and prior state. Only the config file + // itself is replaced with our merged copy. + if err := mirrorAgentHome(filepath.Dir(src), filepath.Dir(dest), filepath.Base(dest)); err != nil { + return "", "", err + } + + srcBody, err := os.ReadFile(src) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", "", fmt.Errorf("read %s: %w", src, err) + } + // srcBody stays nil if the user has no existing config — we'll + // write a fresh minimal one with just the patterm entry. + + args := []string{"mcp-stdio", "--socket", socket, "--identity", identity} + var merged []byte + switch format { + case "toml": + merged, err = mergeTOMLMCP(srcBody, inj.MergeKey, bin, args) + case "json": + merged, err = mergeJSONMCP(srcBody, inj.MergeKey, bin, args) + default: + err = fmt.Errorf("preset %s: unsupported mcp_injection.format %q", p.Name, format) + } + if err != nil { + return "", "", err + } + if err := os.WriteFile(dest, merged, 0o600); err != nil { + return "", "", err + } + + return homeVar + "=" + homeDir, homeDir, nil +} + +// mcpConfigEnv reads the user's existing config file, merges patterm's +// MCP entry into it, and returns an env-var assignment (e.g. +// `OPENCODE_CONFIG_CONTENT={...}`) the child can read directly. No +// file is written and XDG_CONFIG_HOME is not touched — the agent's +// auth/state/skill dirs continue to resolve from the user's real +// $HOME exactly as they do without patterm. +func mcpConfigEnv(p *preset.Preset, inj *preset.MCPInjection, identity, bin, socket string) (string, error) { + if inj.Var == "" { + return "", fmt.Errorf("preset %s: mcp_injection.config_env requires var", p.Name) + } + if inj.MergeKey == "" { + return "", fmt.Errorf("preset %s: mcp_injection.config_env requires merge_key", p.Name) + } + format := strings.ToLower(inj.Format) + if format == "" { + switch strings.ToLower(filepath.Ext(inj.Path)) { + case ".toml": + format = "toml" + case ".json": + format = "json" + } + } + if format == "" { + return "", fmt.Errorf("preset %s: cannot infer mcp_injection.format from path %q", p.Name, inj.Path) + } + + var srcBody []byte + if inj.Path != "" { + body, err := os.ReadFile(expandUser(inj.Path)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("read %s: %w", inj.Path, err) + } + srcBody = body + } + + args := []string{"mcp-stdio", "--socket", socket, "--identity", identity} + var merged []byte + var err error + switch format { + case "toml": + merged, err = mergeTOMLMCP(srcBody, inj.MergeKey, bin, args) + case "json": + merged, err = mergeJSONMCP(srcBody, inj.MergeKey, bin, args) + default: + err = fmt.Errorf("preset %s: unsupported mcp_injection.format %q", p.Name, format) + } + if err != nil { + return "", err + } + return inj.Var + "=" + string(merged), nil +} + +// mcpCLIOverrideArgs builds the `-c key=value` argv tail for the +// `cli_override` injection kind. The agent merges these into its +// in-memory config at startup, so there's no filesystem footprint at +// all — codex picks up patterm's MCP server without us touching +// ~/.codex/config.toml or hijacking CODEX_HOME (which would mask +// auth.json and saved sessions). +func mcpCLIOverrideArgs(p *preset.Preset, inj *preset.MCPInjection, identity, bin, socket string) ([]string, error) { + flag := inj.Flag + if flag == "" { + flag = "-c" + } + prefix := inj.KeyPrefix + if prefix == "" { + return nil, fmt.Errorf("preset %s: mcp_injection.cli_override requires key_prefix", p.Name) + } + args := []string{"mcp-stdio", "--socket", socket, "--identity", identity} + + // We hard-code TOML scalar encoding because every consumer in the + // wild (codex today; future cli_override targets are expected to + // be the same) parses overrides as TOML expressions. Quoting the + // command preserves spaces in paths; quoting each args element + // keeps the array shape intact. + cmdVal := tomlString(bin) + var argsVal strings.Builder + argsVal.WriteString("[") + for i, a := range args { + if i > 0 { + argsVal.WriteString(", ") + } + argsVal.WriteString(tomlString(a)) + } + argsVal.WriteString("]") + + return []string{ + flag, prefix + ".command=" + cmdVal, + flag, prefix + ".args=" + argsVal.String(), + }, nil +} + +// tomlString renders a Go string as a TOML basic string literal. TOML +// uses the same escape conventions as JSON for backslash and quote, +// which keeps this implementation small. +func tomlString(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + +// inferHomeFromPath maps the well-known agent config paths to the env +// var + relative path patterm should use when merging. Lets older +// preset files (without home_var/home_path/format) keep working. +func inferHomeFromPath(path string) (homeVar, homePath, format string) { + switch { + case strings.HasSuffix(path, "/.codex/config.toml"): + return "CODEX_HOME", "config.toml", "toml" + case strings.HasSuffix(path, "/opencode/opencode.json"): + return "XDG_CONFIG_HOME", "opencode/opencode.json", "json" + } + return "", "", "" +} + +// mirrorAgentHome populates mirroredDir with symlinks pointing at each +// entry of srcDir, except for skipBase (which the caller is replacing +// with a freshly-written file). This lets agents that root every piece +// of their per-user state at one dir — codex via CODEX_HOME, opencode +// via XDG_CONFIG_HOME/opencode — keep reading their real auth.json, +// sessions, history, etc. even when patterm overrides the home root. +func mirrorAgentHome(srcDir, mirroredDir, skipBase string) error { + if err := os.MkdirAll(mirroredDir, 0o700); err != nil { + return err + } + entries, err := os.ReadDir(srcDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + for _, e := range entries { + if e.Name() == skipBase { + continue + } + src := filepath.Join(srcDir, e.Name()) + dst := filepath.Join(mirroredDir, e.Name()) + // Replace any stale symlink/file at dst — the runtime dir is + // per-identity so this should be a no-op on first spawn, but + // being defensive keeps re-spawn semantics sane if the dir is + // reused. + _ = os.Remove(dst) + if err := os.Symlink(src, dst); err != nil { + return fmt.Errorf("symlink %s -> %s: %w", src, dst, err) + } + } + return nil +} + +func mcpRuntimeDir(identity string) (string, error) { + if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" { + dir := filepath.Join(runtime, "patterm", "agents", identity) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + return dir, nil + } + dir := filepath.Join(os.TempDir(), "patterm-agents-"+identity) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + return dir, nil +} + +func expandUser(p string) string { + if strings.HasPrefix(p, "~/") { + home, err := os.UserHomeDir() + if err == nil { + return filepath.Join(home, p[2:]) + } + } + return p +} + +// mergeJSONMCP parses src as JSON, slots patterm's MCP entry under the +// merge key, and reserializes. If src is empty/whitespace, we start +// from an empty object. opencode uses a `command` array shape with +// `type: "local"`; codex JSON variants (uncommon) reuse the codex +// command/args shape. We emit the opencode shape because it's the +// only JSON consumer in the default preset set. +func mergeJSONMCP(src []byte, mergeKey, bin string, args []string) ([]byte, error) { + var root map[string]any + trimmed := strings.TrimSpace(string(src)) + if trimmed == "" { + root = map[string]any{} + } else { + if err := json.Unmarshal([]byte(trimmed), &root); err != nil { + return nil, fmt.Errorf("parse json config: %w", err) + } + } + + mcp, _ := root[mergeKey].(map[string]any) + if mcp == nil { + mcp = map[string]any{} + } + + entry := map[string]any{ + "type": "local", + "command": append([]string{bin}, args...), + "enabled": true, + } + mcp[patternMcpEntryName] = entry + root[mergeKey] = mcp + + out, err := json.MarshalIndent(root, "", " ") + if err != nil { + return nil, err + } + return append(out, '\n'), nil +} + +// mergeTOMLMCP merges a `[.patterm]` block into a TOML +// document. We deliberately avoid pulling in a full TOML parser: +// codex's config.toml is human-edited but the patterm entry is +// well-bounded, so a string-level "strip the old patterm section, +// append a fresh one" suffices for the merge use case. +func mergeTOMLMCP(src []byte, mergeKey, bin string, args []string) ([]byte, error) { + stripped := stripTOMLSection(string(src), mergeKey+"."+patternMcpEntryName) + + if stripped != "" && !strings.HasSuffix(stripped, "\n") { + stripped += "\n" + } + if stripped != "" { + stripped += "\n" + } + + var b strings.Builder + b.WriteString(stripped) + b.WriteString("# managed by patterm — re-written on each spawn\n") + fmt.Fprintf(&b, "[%s.%s]\n", mergeKey, patternMcpEntryName) + fmt.Fprintf(&b, "command = %q\n", bin) + b.WriteString("args = [") + for i, a := range args { + if i > 0 { + b.WriteString(", ") + } + fmt.Fprintf(&b, "%q", a) + } + b.WriteString("]\n") + return []byte(b.String()), nil +} + +// stripTOMLSection returns src with the `[header]` table (and the +// lines until the next top-level `[...]` header or EOF) removed. +// Lines that begin with `header.` as a subsection of the target are +// also dropped so we don't leave stale per-key dotted assignments. +func stripTOMLSection(src, header string) string { + if src == "" { + return "" + } + wantTable := "[" + header + "]" + wantSubPrefix := "[" + header + "." + lines := strings.Split(src, "\n") + out := make([]string, 0, len(lines)) + inTarget := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + if trimmed == wantTable || strings.HasPrefix(trimmed, wantSubPrefix) { + inTarget = true + continue + } + inTarget = false + } + if inTarget { + continue + } + out = append(out, line) + } + joined := strings.Join(out, "\n") + return strings.TrimRight(joined, "\n") +} diff --git a/internal/app/mcp_inject_test.go b/internal/app/mcp_inject_test.go new file mode 100644 index 0000000..ea3157d --- /dev/null +++ b/internal/app/mcp_inject_test.go @@ -0,0 +1,140 @@ +package app + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestMergeTOMLMCPFreshFile(t *testing.T) { + out, err := mergeTOMLMCP(nil, "mcp_servers", "/usr/local/bin/patterm", + []string{"mcp-stdio", "--socket", "/run/patterm/1.sock", "--identity", "abc123"}) + if err != nil { + t.Fatal(err) + } + s := string(out) + if !strings.Contains(s, "[mcp_servers.patterm]") { + t.Fatalf("missing patterm table:\n%s", s) + } + if !strings.Contains(s, `command = "/usr/local/bin/patterm"`) { + t.Fatalf("missing command line:\n%s", s) + } + if !strings.Contains(s, `args = ["mcp-stdio", "--socket", "/run/patterm/1.sock", "--identity", "abc123"]`) { + t.Fatalf("missing args line:\n%s", s) + } +} + +func TestMergeTOMLMCPPreservesOtherSections(t *testing.T) { + existing := `model = "gpt-5" + +[mcp_servers.something_else] +command = "x" +args = ["y"] +` + out, err := mergeTOMLMCP([]byte(existing), "mcp_servers", "/bin/patterm", + []string{"mcp-stdio", "--socket", "/s", "--identity", "id"}) + if err != nil { + t.Fatal(err) + } + s := string(out) + if !strings.Contains(s, `model = "gpt-5"`) { + t.Fatalf("lost top-level model setting:\n%s", s) + } + if !strings.Contains(s, "[mcp_servers.something_else]") { + t.Fatalf("lost neighbouring mcp_servers entry:\n%s", s) + } + if !strings.Contains(s, "[mcp_servers.patterm]") { + t.Fatalf("missing patterm entry:\n%s", s) + } +} + +func TestMergeTOMLMCPReplacesStalePatternEntry(t *testing.T) { + existing := `[mcp_servers.patterm] +command = "/old/path" +args = ["stale"] + +[mcp_servers.keep] +command = "k" +` + out, err := mergeTOMLMCP([]byte(existing), "mcp_servers", "/new/bin", + []string{"mcp-stdio", "--socket", "/s2", "--identity", "id2"}) + if err != nil { + t.Fatal(err) + } + s := string(out) + if strings.Contains(s, "/old/path") { + t.Fatalf("stale command remained:\n%s", s) + } + if strings.Contains(s, "stale") { + t.Fatalf("stale args remained:\n%s", s) + } + if !strings.Contains(s, "[mcp_servers.keep]") { + t.Fatalf("dropped sibling section:\n%s", s) + } + // New patterm block appears exactly once. + if c := strings.Count(s, "[mcp_servers.patterm]"); c != 1 { + t.Fatalf("expected single patterm block, got %d:\n%s", c, s) + } +} + +func TestMergeJSONMCPFreshFile(t *testing.T) { + out, err := mergeJSONMCP(nil, "mcp", "/bin/patterm", + []string{"mcp-stdio", "--socket", "/s", "--identity", "id"}) + if err != nil { + t.Fatal(err) + } + var root map[string]any + if err := json.Unmarshal(out, &root); err != nil { + t.Fatalf("output not valid json: %v\n%s", err, out) + } + mcp, ok := root["mcp"].(map[string]any) + if !ok { + t.Fatalf("mcp key missing or wrong type: %v", root) + } + entry, ok := mcp["patterm"].(map[string]any) + if !ok { + t.Fatalf("patterm entry missing: %v", mcp) + } + if entry["type"] != "local" { + t.Fatalf("expected type=local, got %v", entry["type"]) + } + cmd, ok := entry["command"].([]any) + if !ok || len(cmd) != 6 || cmd[0] != "/bin/patterm" { + t.Fatalf("unexpected command: %#v", entry["command"]) + } +} + +func TestMergeJSONMCPPreservesExistingKeysAndReplacesPatterm(t *testing.T) { + existing := `{ + "$schema": "https://opencode.ai/config.json", + "model": "claude-sonnet-4", + "mcp": { + "patterm": {"type": "local", "command": ["old"]}, + "other": {"type": "local", "command": ["k"]} + } +}` + out, err := mergeJSONMCP([]byte(existing), "mcp", "/new/bin", + []string{"mcp-stdio", "--socket", "/s", "--identity", "id"}) + if err != nil { + t.Fatal(err) + } + var root map[string]any + if err := json.Unmarshal(out, &root); err != nil { + t.Fatalf("output not valid json: %v\n%s", err, out) + } + if root["$schema"] != "https://opencode.ai/config.json" { + t.Fatalf("lost $schema: %v", root["$schema"]) + } + if root["model"] != "claude-sonnet-4" { + t.Fatalf("lost model: %v", root["model"]) + } + mcp := root["mcp"].(map[string]any) + if _, ok := mcp["other"]; !ok { + t.Fatalf("dropped sibling mcp entry") + } + entry := mcp["patterm"].(map[string]any) + cmd := entry["command"].([]any) + if cmd[0] != "/new/bin" { + t.Fatalf("patterm entry not refreshed: %v", cmd) + } +} diff --git a/internal/app/palette.go b/internal/app/palette.go index 90f5695..62c8afd 100644 --- a/internal/app/palette.go +++ b/internal/app/palette.go @@ -38,6 +38,29 @@ type paletteState struct { items []paletteItem } +// macroPrefixes maps the palette macro prefix (without trailing space) +// to the paletteAction.kind values that should be retained when that +// macro is active. Typing `sw ` filters to switch entries only, +// `k ` to kills, `sp ` to spawn entries (agents + +// processes). +var macroPrefixes = map[string][]string{ + "sw": {"switch"}, + "k": {"kill"}, + "sp": {"spawn-agent", "spawn-process"}, +} + +// detectMacro returns the macro keyword and the remaining query, or +// ("", original) if no macro is active. A macro is active when the +// query starts with one of the known prefixes followed by a space. +func detectMacro(q string) (macro, rest string) { + for k := range macroPrefixes { + if len(q) > len(k) && q[:len(k)] == k && q[len(k)] == ' ' { + return k, q[len(k)+1:] + } + } + return "", q +} + func newPalette(children []*Child, focused string, presets preset.Set) *paletteState { p := &paletteState{children: children, focused: focused, presets: presets} p.rebuild() @@ -47,6 +70,21 @@ func newPalette(children []*Child, focused string, presets preset.Set) *paletteS func (p *paletteState) rebuild() { all := p.allItems() q := strings.ToLower(string(p.query)) + macro, rest := detectMacro(q) + if macro != "" { + kinds := macroPrefixes[macro] + filtered := all[:0:0] + for _, it := range all { + for _, k := range kinds { + if it.action.kind == k { + filtered = append(filtered, it) + break + } + } + } + all = filtered + q = rest + } if q == "" { p.items = all } else { @@ -68,8 +106,32 @@ func (p *paletteState) rebuild() { func (p *paletteState) allItems() []paletteItem { var out []paletteItem - // Preset commands first — SPEC §4 calls these out as the primary - // way to spawn anything. One entry per file under presets/. + // Switch entries first — existing open agents/processes should + // surface above options to spawn new ones. Hide non-running agents + // (e.g. killed ones) so the list doesn't accumulate corpses. Command + // processes are session-persistent, so they remain visible after + // exit to keep restart_process in reach. + for _, c := range p.children { + if c.Kind == KindAgent && c.Status() != StatusRunning { + continue + } + label := "Switch to " + c.Name + hint := strings.Join(c.Argv, " ") + if c.ID == p.focused { + label = "• " + label + " (current)" + } + if c.Status() != StatusRunning { + label = label + " [" + string(c.Status()) + "]" + } + out = append(out, paletteItem{ + label: label, + hint: hint, + action: paletteAction{kind: "switch", childID: c.ID}, + }) + } + + // Preset commands — SPEC §4 calls these out as the primary way to + // spawn anything. One entry per file under presets/. for _, pr := range p.presets.Agents { out = append(out, paletteItem{ label: "Spawn agent: " + pr.Name, @@ -85,22 +147,7 @@ func (p *paletteState) allItems() []paletteItem { }) } - // Switch / Kill entries — one per existing child. - for _, c := range p.children { - label := "Switch to " + c.Name - hint := strings.Join(c.Argv, " ") - if c.ID == p.focused { - label = "• " + label + " (current)" - } - if c.Status() != StatusRunning { - label = label + " [" + string(c.Status()) + "]" - } - out = append(out, paletteItem{ - label: label, - hint: hint, - action: paletteAction{kind: "switch", childID: c.ID}, - }) - } + // Kill entries last among the action rows, before Quit. for _, c := range p.children { if c.Status() != StatusRunning { continue @@ -447,7 +494,7 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) { b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset) row++ - footer := "↵ run · esc close · ↑↓ navigate" + footer := "↵ run · esc close · ↑↓ navigate · sw/k/sp filter" fLen := utf8.RuneCountInString(footer) fPad := content - fLen if fPad < 0 { diff --git a/internal/app/sidebar.go b/internal/app/sidebar.go index fe32ebd..a976ad1 100644 --- a/internal/app/sidebar.go +++ b/internal/app/sidebar.go @@ -152,8 +152,17 @@ func (st *uiState) drawSidebar() { write("") } + frame := b.String() + st.chromeCacheMu.Lock() + if frame == st.sidebarCache { + st.chromeCacheMu.Unlock() + return + } + st.sidebarCache = frame + st.chromeCacheMu.Unlock() + st.outMu.Lock() // Save cursor; emit the sidebar; restore. - fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", b.String()) + fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame) st.outMu.Unlock() } diff --git a/internal/app/tabbar.go b/internal/app/tabbar.go index fb87e0c..80c0149 100644 --- a/internal/app/tabbar.go +++ b/internal/app/tabbar.go @@ -7,15 +7,15 @@ import ( "unicode/utf8" ) -// Three-row tab bar: labels row, subtitle row, underline row. The PTY -// viewport's top row is therefore mainTop == tabBarRows + 1. -const tabBarRows = 3 +// Two-row tab bar: labels row, underline row. The PTY viewport's top +// row is therefore mainTop == tabBarRows + 1. +const tabBarRows = 2 -// drawTabBar renders the top tab strip across the full host width. The -// strip has three rows: labels (with horizontal padding), a dim -// subtitle showing each child's argv, and an underline that's thick + -// accent for the focused tab and faint for the rest. Subtitles are -// truncated with `…` to the tab's width. +// drawTabBar renders the top tab strip across the full host width. +// Tabs share the available width with a flex layout — each visible +// session gets roughly width/N cells, with the remainder distributed +// to the leftmost tabs so the strip fills the screen edge-to-edge. +// A trailing "+ new" hint sits in the rightmost reserved slot. func (st *uiState) drawTabBar() { st.mu.Lock() palOpen := st.palette != nil @@ -37,94 +37,123 @@ func (st *uiState) drawTabBar() { } } + const ( + newHint = "+ new" + minTabWidth = 6 // enough for two pad cells + "x…" or similar + ) + newHintW := utf8.RuneCountInString(newHint) + 2 // " + new " framing + type tabRect struct { startCol int width int label string - subtitle string active bool } - const ( - leadingPad = 2 // host columns before the first tab - tabPad = 2 // spaces on each side of the label inside the tab - tabGap = 1 // gap columns between adjacent tabs - tailReserve = 8 // reserve room for the trailing "+ new" hint - ) + // Reserve space at the right edge for "+ new". If there are too + // many tabs to fit even at minTabWidth, drop tabs from the right + // until they do. The current focus stays visible. + tabBudget := width - newHintW + if tabBudget < minTabWidth { + tabBudget = width + newHintW = 0 + } - tabs := make([]tabRect, 0, len(sessions)) - cur := leadingPad + 1 - for _, c := range sessions { - label := c.Name - labelW := utf8.RuneCountInString(label) - tabW := labelW + tabPad*2 - - // If the tab won't fit, try truncating the label down to whatever - // space is left (label still has to leave room for "…"). - if cur+tabW+tabGap+tailReserve > width+1 { - avail := width + 1 - cur - tabGap - tailReserve - tabPad*2 - if avail < 3 { - break - } - label = clipRunes(label, avail-1) + "…" - labelW = utf8.RuneCountInString(label) - tabW = labelW + tabPad*2 - tabs = append(tabs, tabRect{ - startCol: cur, width: tabW, - label: label, subtitle: strings.Join(c.Argv, " "), - active: c.ID == focus, - }) - cur += tabW + tabGap - break + visible := sessions + if len(visible) > 0 { + maxTabs := tabBudget / minTabWidth + if maxTabs < 1 { + maxTabs = 1 } + if len(visible) > maxTabs { + // Keep the focused tab plus as many leftward tabs as fit. + focusIdx := -1 + for i, c := range visible { + if c.ID == focus { + focusIdx = i + break + } + } + if focusIdx < 0 { + focusIdx = 0 + } + start := focusIdx - maxTabs + 1 + if start < 0 { + start = 0 + } + end := start + maxTabs + if end > len(visible) { + end = len(visible) + } + visible = visible[start:end] + } + } - tabs = append(tabs, tabRect{ - startCol: cur, width: tabW, - label: label, subtitle: strings.Join(c.Argv, " "), - active: c.ID == focus, - }) - cur += tabW + tabGap + tabs := make([]tabRect, 0, len(visible)) + if n := len(visible); n > 0 { + base := tabBudget / n + extra := tabBudget - base*n + col := 1 + for i, c := range visible { + w := base + if i < extra { + w++ + } + label := c.Name + labelW := utf8.RuneCountInString(label) + maxLabelW := w - 2 // one pad on each side + if maxLabelW < 1 { + maxLabelW = 1 + } + if labelW > maxLabelW { + if maxLabelW > 1 { + label = clipRunes(label, maxLabelW-1) + "…" + } else { + label = clipRunes(label, maxLabelW) + } + labelW = utf8.RuneCountInString(label) + } + tabs = append(tabs, tabRect{ + startCol: col, + width: w, + label: label, + active: c.ID == focus, + }) + col += w + } } var b strings.Builder - // Clear all three rows up front so a stale label from the previous - // frame can't bleed through. + // Clear both rows so a stale label from the previous frame can't + // bleed through. b.WriteString("\x1b[1;1H\x1b[2K") b.WriteString("\x1b[2;1H\x1b[2K") - b.WriteString("\x1b[3;1H\x1b[2K") for _, t := range tabs { - // Row 1: label + // Row 1: centre-ish label inside the tab cell. + labelW := utf8.RuneCountInString(t.label) + leftPad := (t.width - labelW) / 2 + if leftPad < 1 { + leftPad = 1 + } + rightPad := t.width - labelW - leftPad + if rightPad < 0 { + rightPad = 0 + } fmt.Fprintf(&b, "\x1b[1;%dH", t.startCol) if t.active { b.WriteString(styleActive) } else { b.WriteString(styleHint) } - b.WriteString(strings.Repeat(" ", tabPad)) + b.WriteString(strings.Repeat(" ", leftPad)) b.WriteString(t.label) - b.WriteString(strings.Repeat(" ", tabPad)) + b.WriteString(strings.Repeat(" ", rightPad)) b.WriteString(styleReset) - // Row 2: subtitle, truncated to tab width and dimmed. - sub := t.subtitle - if utf8.RuneCountInString(sub) > t.width { - if t.width > 1 { - sub = clipRunes(sub, t.width-1) + "…" - } else { - sub = "" - } - } - padR := t.width - utf8.RuneCountInString(sub) - if padR < 0 { - padR = 0 - } - fmt.Fprintf(&b, "\x1b[2;%dH%s%s%s%s", - t.startCol, styleDim, sub, strings.Repeat(" ", padR), styleReset) - - // Row 3: underline. Thick accent for the active tab, faint + // Row 2: underline. Thick accent for the active tab, faint // border for the rest. - fmt.Fprintf(&b, "\x1b[3;%dH", t.startCol) + fmt.Fprintf(&b, "\x1b[2;%dH", t.startCol) if t.active { b.WriteString(styleAccent) b.WriteString(strings.Repeat("━", t.width)) @@ -135,26 +164,26 @@ func (st *uiState) drawTabBar() { b.WriteString(styleReset) } - // "+ new" hint at the end of the labels row, in dim. - if cur+3 <= width { - fmt.Fprintf(&b, "\x1b[1;%dH%s+ new%s", cur+1, styleDim, styleReset) + // "+ new" hint right-aligned in the reserved slot. + if newHintW > 0 { + hintCol := width - newHintW + 1 + fmt.Fprintf(&b, "\x1b[1;%dH %s%s%s ", hintCol, styleDim, newHint, styleReset) + // Underline continues faintly under the hint so the strip + // reads as one bar. + fmt.Fprintf(&b, "\x1b[2;%dH%s%s%s", + hintCol, styleBorder, strings.Repeat("─", newHintW), styleReset) } - // Extend the faint underline across the rest of the host width so - // the tab strip reads as one continuous divider. - if cur <= width { - remain := width - cur + 1 - if remain > 0 { - fmt.Fprintf(&b, "\x1b[3;%dH%s%s%s", - cur, styleBorder, strings.Repeat("─", remain), styleReset) - } - } - if leadingPad > 0 { - fmt.Fprintf(&b, "\x1b[3;1H%s%s%s", - styleBorder, strings.Repeat("─", leadingPad), styleReset) + frame := b.String() + st.chromeCacheMu.Lock() + if frame == st.tabBarCache { + st.chromeCacheMu.Unlock() + return } + st.tabBarCache = frame + st.chromeCacheMu.Unlock() st.outMu.Lock() defer st.outMu.Unlock() - fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", b.String()) + fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame) } diff --git a/internal/app/tree.go b/internal/app/tree.go index 21bc7c3..1db5d63 100644 --- a/internal/app/tree.go +++ b/internal/app/tree.go @@ -57,3 +57,96 @@ func firstRunningTopLevel(children []*Child) *Child { } return nil } + +// runningTopLevels lists every running top-level session in the order +// they appear in the snapshot — the same order the tab bar uses, so +// Ctrl+A/D navigation matches what the user sees on screen. +func runningTopLevels(children []*Child) []*Child { + out := make([]*Child, 0, len(children)) + for _, c := range children { + if c.ParentID == "" && c.Status() == StatusRunning { + out = append(out, c) + } + } + return out +} + +// nextTabID returns the id of the top-level session `step` positions +// away from the current focus in the runningTopLevels list, wrapping +// at both ends. Returns "" when there's nothing to switch to. +func nextTabID(children []*Child, focusID string, step int) string { + roots := runningTopLevels(children) + if len(roots) == 0 { + return "" + } + rootID := activeRootID(children, focusID) + idx := -1 + for i, r := range roots { + if r.ID == rootID { + idx = i + break + } + } + if idx < 0 { + idx = 0 + } + idx = (idx + step) % len(roots) + if idx < 0 { + idx += len(roots) + } + if roots[idx].ID == focusID { + return "" + } + return roots[idx].ID +} + +// currentTabFlat returns the focused tab's processes (root first, then +// its running children) in display order. Used to step focus with +// Ctrl+W/S. +func currentTabFlat(children []*Child, focusID string) []*Child { + rootID := activeRootID(children, focusID) + if rootID == "" { + return nil + } + out := make([]*Child, 0, 4) + for _, c := range children { + if c.ID == rootID && c.Status() == StatusRunning { + out = append(out, c) + break + } + } + for _, c := range children { + if c.ParentID == rootID && c.Status() == StatusRunning { + out = append(out, c) + } + } + return out +} + +// nextChildID returns the process id `step` positions away from the +// current focus inside its tab, wrapping at both ends. Empty when +// there's only one process in the tab. +func nextChildID(children []*Child, focusID string, step int) string { + flat := currentTabFlat(children, focusID) + if len(flat) < 2 { + return "" + } + idx := -1 + for i, c := range flat { + if c.ID == focusID { + idx = i + break + } + } + if idx < 0 { + idx = 0 + } + idx = (idx + step) % len(flat) + if idx < 0 { + idx += len(flat) + } + if flat[idx].ID == focusID { + return "" + } + return flat[idx].ID +} diff --git a/internal/app/tree_test.go b/internal/app/tree_test.go index d709501..f4141ca 100644 --- a/internal/app/tree_test.go +++ b/internal/app/tree_test.go @@ -39,3 +39,61 @@ func childIDs(cs []*Child) []string { } return ids } + +func TestNextTabIDWrapsAndSkipsCurrent(t *testing.T) { + r1 := testChild("c1", "root1", "", StatusRunning) + r2 := testChild("c2", "root2", "", StatusRunning) + r3 := testChild("c3", "root3", "", StatusRunning) + children := []*Child{r1, r2, r3} + + if got := nextTabID(children, "c1", +1); got != "c2" { + t.Fatalf("next from c1: %q", got) + } + if got := nextTabID(children, "c1", -1); got != "c3" { + t.Fatalf("prev from c1: %q", got) + } + if got := nextTabID(children, "c3", +1); got != "c1" { + t.Fatalf("wrap forward from c3: %q", got) + } +} + +func TestNextTabIDFromSubAgentJumpsByRoot(t *testing.T) { + r1 := testChild("c1", "root1", "", StatusRunning) + r1Child := testChild("c2", "child1", "c1", StatusRunning) + r2 := testChild("c3", "root2", "", StatusRunning) + children := []*Child{r1, r1Child, r2} + + // Focus is on a sub-agent of root1; Ctrl+D should jump to root2, + // not stay inside root1's sub-tree. + if got := nextTabID(children, "c2", +1); got != "c3" { + t.Fatalf("next from sub-agent: %q want c3", got) + } +} + +func TestNextChildIDCyclesWithinTab(t *testing.T) { + r1 := testChild("c1", "root1", "", StatusRunning) + a := testChild("c2", "a", "c1", StatusRunning) + b := testChild("c3", "b", "c1", StatusRunning) + other := testChild("c4", "other-root", "", StatusRunning) + children := []*Child{r1, a, b, other} + + if got := nextChildID(children, "c1", +1); got != "c2" { + t.Fatalf("root->first child: %q", got) + } + if got := nextChildID(children, "c2", +1); got != "c3" { + t.Fatalf("a->b: %q", got) + } + if got := nextChildID(children, "c3", +1); got != "c1" { + t.Fatalf("wrap b->root: %q", got) + } + if got := nextChildID(children, "c1", -1); got != "c3" { + t.Fatalf("wrap backward root->b: %q", got) + } +} + +func TestNextChildIDNoopWhenOnlyOneProcess(t *testing.T) { + r := testChild("c1", "solo", "", StatusRunning) + if got := nextChildID([]*Child{r}, "c1", +1); got != "" { + t.Fatalf("expected empty when only one process in tab, got %q", got) + } +} diff --git a/internal/app/viewport_renderer.go b/internal/app/viewport_renderer.go index 349f0b6..54ca666 100644 --- a/internal/app/viewport_renderer.go +++ b/internal/app/viewport_renderer.go @@ -161,6 +161,10 @@ func (vr *viewportRenderer) emitCSI() { return } switch n { + case 0: + vr.pending.WriteString(vr.clearViewportFromCursor()) + case 1: + vr.pending.WriteString(vr.clearViewportToCursor()) case 2, 3: vr.pending.WriteString(vr.clearViewport()) default: @@ -203,6 +207,54 @@ func (vr *viewportRenderer) clearViewport() string { return b.String() } +// clearViewportFromCursor implements `CSI 0 J` clamped to the viewport. +// Without clamping, the child's "clear to end of screen" would reach the +// rightmost columns and erase the sidebar. +func (vr *viewportRenderer) clearViewportFromCursor() string { + row, col := vr.row, vr.col + cols := int(vr.layout.childCols()) + rows := int(vr.layout.childRows()) + if row < 1 { + row = 1 + } + if col < 1 { + col = 1 + } + var b strings.Builder + b.WriteString("\x1b7") + if remaining := cols - col + 1; remaining > 0 { + fmt.Fprintf(&b, "\x1b[%dX", remaining) + } + for r := row + 1; r <= rows; r++ { + fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX", + int(vr.layout.mainTop)+r-1, int(vr.layout.mainLeft), cols) + } + b.WriteString("\x1b8") + return b.String() +} + +// clearViewportToCursor implements `CSI 1 J` clamped to the viewport. +func (vr *viewportRenderer) clearViewportToCursor() string { + row, col := vr.row, vr.col + cols := int(vr.layout.childCols()) + if row < 1 { + row = 1 + } + if col < 1 { + col = 1 + } + var b strings.Builder + b.WriteString("\x1b7") + for r := 1; r < row; r++ { + fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX", + int(vr.layout.mainTop)+r-1, int(vr.layout.mainLeft), cols) + } + fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX", + int(vr.layout.mainTop)+row-1, int(vr.layout.mainLeft), col) + b.WriteString("\x1b8") + return b.String() +} + func (vr *viewportRenderer) clearLine(n int) string { right := int(vr.layout.childCols()) if vr.col < 1 { diff --git a/internal/app/viewport_renderer_test.go b/internal/app/viewport_renderer_test.go index 4cfbc2f..3729b2f 100644 --- a/internal/app/viewport_renderer_test.go +++ b/internal/app/viewport_renderer_test.go @@ -8,7 +8,7 @@ import ( func TestViewportRendererShiftsCursor(t *testing.T) { vr := newViewportRenderer(newTerminalLayout(120, 40)) got := string(vr.Render([]byte("\x1b[H"))) - if got != "\x1b[4;1H" { + if got != "\x1b[3;1H" { t.Fatalf("CUP home: got %q", got) } } @@ -22,17 +22,17 @@ func TestViewportRendererSwallowsAltScreenToggles(t *testing.T) { } func TestViewportRendererClearScreenIsViewportOnly(t *testing.T) { - // hostRows=7 leaves three viewport rows after the 3-row tab bar and + // hostRows=7 leaves four viewport rows after the 2-row tab bar and // 1-row status reservation. vr := newViewportRenderer(newTerminalLayout(20, 7)) got := string(vr.Render([]byte("\x1b[2J"))) if strings.Contains(got, "\x1b[2J") { t.Fatalf("host clear-screen leaked through: %q", got) } - if strings.Count(got, "\x1b[20X") != 3 { + if strings.Count(got, "\x1b[20X") != 4 { t.Fatalf("clear rows: got %q", got) } - if !strings.Contains(got, "\x1b[4;1H") || !strings.Contains(got, "\x1b[6;1H") { + if !strings.Contains(got, "\x1b[3;1H") || !strings.Contains(got, "\x1b[6;1H") { t.Fatalf("clear did not target viewport rows: %q", got) } } @@ -56,6 +56,45 @@ func TestViewportRendererClearLineStopsAtViewportRight(t *testing.T) { } } +func TestViewportRendererClearToEndIsViewportOnly(t *testing.T) { + // Reproduces the sidebar-wipe bug: claude's Ctrl+O expansion emits + // `CSI 0 J` (clear from cursor to end of screen). Forwarded verbatim, + // it would erase every host column to the right of the cursor — + // including the sidebar — because the cursor is at host coordinates + // but the J sequence isn't constrained to the viewport. + vr := newViewportRenderer(newTerminalLayout(40, 7)) + got := string(vr.Render([]byte("\x1b[H\x1b[0J"))) + if strings.Contains(got, "\x1b[0J") || strings.Contains(got, "\x1b[J") { + t.Fatalf("host clear-to-end leaked through: %q", got) + } + // childCols == 19 (40 cols - 28 sidebar - 1 gap - 0-index fudge). + // Each of the 4 viewport rows should get a 19-cell erase. + // childCols == 11 with hostCols=40 (28 sidebar + 1 gap reserved). + // 4 viewport rows, but the cursor row uses ECH at cursor (col 1), + // so we expect 4 erases of 11 cells each. + count := strings.Count(got, "\x1b[11X") + if count != 4 { + t.Fatalf("expected 4 ECH-11 sequences, got %d in %q", count, got) + } +} + +func TestViewportRendererClearToStartIsViewportOnly(t *testing.T) { + vr := newViewportRenderer(newTerminalLayout(40, 7)) + // Park the cursor mid-viewport, then issue `CSI 1 J`. + got := string(vr.Render([]byte("\x1b[3;5H\x1b[1J"))) + if strings.Contains(got, "\x1b[1J") { + t.Fatalf("host clear-to-start leaked through: %q", got) + } + // Two full rows above (childCols-wide erase, 11 cells each) plus a + // 5-cell erase on the cursor row. + if !strings.Contains(got, "\x1b[11X") { + t.Fatalf("expected viewport-wide ECH for rows above cursor: %q", got) + } + if !strings.Contains(got, "\x1b[5X") { + t.Fatalf("expected 5-cell ECH on cursor row: %q", got) + } +} + func TestViewportRendererTracksPrintableCursor(t *testing.T) { vr := newViewportRenderer(newTerminalLayout(20, 5)) got := string(vr.Render([]byte("hello\x1b[K"))) diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 93768a2..c548ec3 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -115,9 +115,11 @@ func (s *Server) handleConn(conn net.Conn) { } else { // Treat as a real request from an unknown caller. resp := s.dispatch("", greeting) - resp = append(resp, '\n') - if _, werr := conn.Write(resp); werr != nil { - return + if resp != nil { + resp = append(resp, '\n') + if _, werr := conn.Write(resp); werr != nil { + return + } } } @@ -125,9 +127,11 @@ func (s *Server) handleConn(conn net.Conn) { line, err := r.ReadBytes('\n') if len(line) > 0 { resp := s.dispatch(callerID, line) - resp = append(resp, '\n') - if _, werr := conn.Write(resp); werr != nil { - return + if resp != nil { + resp = append(resp, '\n') + if _, werr := conn.Write(resp); werr != nil { + return + } } } if err != nil { diff --git a/internal/mcp/protocol.go b/internal/mcp/protocol.go new file mode 100644 index 0000000..bad525a --- /dev/null +++ b/internal/mcp/protocol.go @@ -0,0 +1,415 @@ +package mcp + +import ( + "encoding/json" + "fmt" +) + +// MCP protocol surface. The patterm server originally exposed each +// tool as its own JSON-RPC method (and the harness still drives it +// that way). Real MCP clients (claude, codex, opencode) speak the +// model-context-protocol RPC dialect: they send `initialize` first, +// then `tools/list`, then `tools/call` with `{name, arguments}`. This +// file wraps those four entry points around the existing tool dispatch +// without changing the underlying tool implementations. + +// supportedProtocolVersion is the MCP protocol revision we advertise +// when a client doesn't pin a specific version. Claude Code accepts +// the dated-string scheme used by the MCP spec. +const supportedProtocolVersion = "2025-06-18" + +// serverInfo identifies the server back to the client during the +// initialize handshake. The version is intentionally kept generic so +// it doesn't need bumping per release; clients only key behavior off +// name + protocol version. +var serverInfo = map[string]any{ + "name": "patterm", + "version": "0.1.0", +} + +// toolDescriptor is the shape returned by `tools/list`. inputSchema is +// a JSON Schema object — we provide a minimal `{type: "object"}` schema +// for each tool, which lets MCP clients accept arbitrary arguments and +// rely on patterm's own server-side validation for typing. +type toolDescriptor struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]any `json:"inputSchema"` +} + +// objectSchema builds an inputSchema for a tool that takes an object +// with the listed properties. required lists property names that must +// be present; passing nil makes them all optional. We always emit a +// concrete `properties` object (never null) because some MCP clients +// reject schemas where `properties` is not an object. +func objectSchema(properties map[string]any, required []string) map[string]any { + if properties == nil { + properties = map[string]any{} + } + s := map[string]any{ + "type": "object", + "properties": properties, + "additionalProperties": true, + } + if len(required) > 0 { + s["required"] = required + } + return s +} + +func stringProp(desc string) map[string]any { + return map[string]any{"type": "string", "description": desc} +} + +func numberProp(desc string) map[string]any { + return map[string]any{"type": "number", "description": desc} +} + +func integerProp(desc string) map[string]any { + return map[string]any{"type": "integer", "description": desc} +} + +func booleanProp(desc string) map[string]any { + return map[string]any{"type": "boolean", "description": desc} +} + +// toolCatalog is the full list advertised via tools/list. Descriptions +// are intentionally short — clients are expected to fetch help() for +// detail. Schemas mirror the param structs in tools.go. +func toolCatalog() []toolDescriptor { + return []toolDescriptor{ + { + Name: "spawn_agent", + Description: "Spawn a sub-agent from an agent preset and optionally seed it with initial instructions.", + InputSchema: objectSchema(map[string]any{ + "agent": stringProp("Preset name (e.g. \"claude\", \"codex\")."), + "agent_instructions": stringProp("Initial prompt typed into the agent after it's ready."), + "name": stringProp("Display name for the new pane."), + }, []string{"agent"}), + }, + { + Name: "spawn_process", + Description: "Spawn a process: a terminal, a process preset, or a freeform argv command.", + InputSchema: objectSchema(map[string]any{ + "kind": stringProp("\"terminal\" or \"command\"."), + "preset": stringProp("Process preset name (mutually exclusive with argv)."), + "argv": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Argv vector for freeform commands."}, + "name": stringProp("Display name for the pane."), + "working_dir": stringProp("Working directory for the spawned process."), + "env": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}, "description": "Extra environment variables."}, + "shell": booleanProp("Run argv through sh -lc."), + }, nil), + }, + { + Name: "start_process", + Description: "(Re)attach a PTY to a session-persistent command process that has exited.", + InputSchema: objectSchema(map[string]any{ + "process_id": stringProp("Target process id."), + }, []string{"process_id"}), + }, + { + Name: "restart_process", + Description: "Signal the target process and restart it under a fresh PTY.", + InputSchema: objectSchema(map[string]any{ + "process_id": stringProp("Target process id."), + "signal": integerProp("Signal to send before relaunch (default SIGTERM)."), + }, []string{"process_id"}), + }, + { + Name: "stop_process", + Description: "Send a signal to a running process without removing its entry.", + InputSchema: objectSchema(map[string]any{ + "process_id": stringProp("Target process id."), + "signal": integerProp("Signal to send (default SIGTERM)."), + }, []string{"process_id"}), + }, + { + Name: "close_process", + Description: "Remove the process entry entirely; live children are SIGKILL'd first.", + InputSchema: objectSchema(map[string]any{ + "process_id": stringProp("Target process id."), + }, []string{"process_id"}), + }, + { + Name: "rename_process", + Description: "Rename the pane label for a process.", + InputSchema: objectSchema(map[string]any{ + "process_id": stringProp("Target process id."), + "name": stringProp("New display name."), + }, []string{"process_id", "name"}), + }, + { + Name: "select_process", + Description: "Focus the named process in the host TUI.", + InputSchema: objectSchema(map[string]any{ + "process_id": stringProp("Target process id."), + }, []string{"process_id"}), + }, + { + Name: "list_processes", + Description: "List visible processes, optionally filtered by kind (\"agent\", \"command\", \"terminal\").", + InputSchema: objectSchema(map[string]any{ + "kind": stringProp("Optional kind filter."), + }, nil), + }, + { + Name: "get_process_status", + Description: "Return rich status (status, geometry, cursor, screen version) for one process.", + InputSchema: objectSchema(map[string]any{ + "process_id": stringProp("Target process id."), + }, []string{"process_id"}), + }, + { + Name: "get_project_status", + Description: "One-shot orientation: project, caller, processes, scratchpads.", + InputSchema: objectSchema(nil, nil), + }, + { + Name: "get_process_output", + Description: "Read rendered grid (\"grid\") or scrollback (\"scrollback\") output, with screen-version watermark.", + InputSchema: objectSchema(map[string]any{ + "process_id": stringProp("Target process id."), + "mode": stringProp("\"grid\" (default) or \"scrollback\"."), + "since_offset": integerProp("Watermark offset from a previous call."), + }, []string{"process_id"}), + }, + { + Name: "get_process_raw_output", + Description: "Read the raw ANSI byte stream since since_offset.", + InputSchema: objectSchema(map[string]any{ + "process_id": stringProp("Target process id."), + "since_offset": integerProp("Byte offset from a previous call."), + }, []string{"process_id"}), + }, + { + Name: "search_output", + Description: "Search a process's rendered or raw output and return matching lines.", + InputSchema: objectSchema(map[string]any{ + "process_id": stringProp("Target process id."), + "pattern": stringProp("Regex pattern."), + "kind": stringProp("\"rendered\" (default) or \"raw\"."), + "limit": integerProp("Max matches (default 20)."), + }, []string{"process_id", "pattern"}), + }, + { + Name: "wait_for_pattern", + Description: "Block until pattern appears in process output or timeout elapses.", + InputSchema: objectSchema(map[string]any{ + "process_id": stringProp("Target process id."), + "pattern": stringProp("Regex pattern."), + "timeout_seconds": numberProp("Max time to wait (seconds)."), + "scope": stringProp("\"new\" (default) or \"all\"."), + }, []string{"process_id", "pattern"}), + }, + { + Name: "get_process_ports", + Description: "Return URL-form port sightings observed in a process's output.", + InputSchema: objectSchema(map[string]any{ + "process_id": stringProp("Target process id."), + }, []string{"process_id"}), + }, + { + Name: "send_input", + Description: "Type text, paste a block, or fire a named key into a process. Optional tail-after-send.", + InputSchema: objectSchema(map[string]any{ + "process_id": stringProp("Target process id."), + "kind": stringProp("\"text\", \"paste\", or \"key\"."), + "text": stringProp("Text payload for kind=text/paste."), + "key": stringProp("Named key for kind=key (e.g. \"enter\", \"esc\")."), + "submit": booleanProp("Whether to append a submit keystroke."), + "wait_ms": integerProp("After sending, wait this many ms before tailing."), + "tail_mode": stringProp("\"none\" (default), \"stream\", or \"grid\"."), + }, []string{"process_id", "kind"}), + }, + { + Name: "send_message", + Description: "Deliver a text message to another process as orchestrator-owned input.", + InputSchema: objectSchema(map[string]any{ + "target_process_id": stringProp("Recipient process id."), + "message": stringProp("Message body."), + }, []string{"target_process_id", "message"}), + }, + { + Name: "request_human_attention", + Description: "Flag a process pane as needing human review.", + InputSchema: objectSchema(map[string]any{ + "process_id": stringProp("Target process id."), + "reason": stringProp("Short description shown to the human."), + }, []string{"process_id", "reason"}), + }, + { + Name: "timer_wait", + Description: "Sleep server-side for `seconds` and return a timer id (use to pace polling).", + InputSchema: objectSchema(map[string]any{ + "seconds": numberProp("Sleep duration."), + "label": stringProp("Optional label for diagnostics."), + }, []string{"seconds"}), + }, + { + Name: "scratchpad_list", + Description: "List shared per-project scratchpad entries.", + InputSchema: objectSchema(nil, nil), + }, + { + Name: "scratchpad_read", + Description: "Read a scratchpad entry, returning content and revision.", + InputSchema: objectSchema(map[string]any{ + "name": stringProp("Scratchpad name."), + }, []string{"name"}), + }, + { + Name: "scratchpad_write", + Description: "Write a scratchpad entry with optimistic concurrency on expected_revision.", + InputSchema: objectSchema(map[string]any{ + "name": stringProp("Scratchpad name."), + "content": stringProp("New content."), + "expected_revision": stringProp("Last-seen revision token."), + }, []string{"name", "content"}), + }, + { + Name: "scratchpad_append", + Description: "Append to a scratchpad entry without revision checking.", + InputSchema: objectSchema(map[string]any{ + "name": stringProp("Scratchpad name."), + "content": stringProp("Text to append."), + }, []string{"name", "content"}), + }, + { + Name: "whoami", + Description: "Return the caller's identity, role, parent, project metadata, and available tools.", + InputSchema: objectSchema(nil, nil), + }, + { + Name: "help", + Description: "Return human-readable help for a topic (e.g. tool name).", + InputSchema: objectSchema(map[string]any{ + "topic": stringProp("Topic or tool name (empty for index)."), + }, nil), + }, + } +} + +// handleProtocolMethod handles MCP protocol-level methods. Returns +// (result, handled). When handled is false, the caller falls back to +// the legacy direct-tool dispatch. For notifications, result is nil +// and handled is true. +func (s *Server) handleProtocolMethod(callerID, method string, params json.RawMessage, isNotification bool) (any, bool, int, string, any) { + switch method { + case "initialize": + var p struct { + ProtocolVersion string `json:"protocolVersion"` + Capabilities map[string]any `json:"capabilities"` + ClientInfo map[string]any `json:"clientInfo"` + } + _ = unmarshalParamsOptional(params, &p) + protoVersion := p.ProtocolVersion + if protoVersion == "" { + protoVersion = supportedProtocolVersion + } + result := map[string]any{ + "protocolVersion": protoVersion, + "capabilities": map[string]any{ + "tools": map[string]any{"listChanged": false}, + }, + "serverInfo": serverInfo, + } + return result, true, 0, "", nil + + case "notifications/initialized", "notifications/cancelled", "notifications/roots/list_changed": + // Notifications get no response — handled is true so the caller + // doesn't fall through to legacy dispatch, but result is nil. + return nil, true, 0, "", nil + + case "ping": + return map[string]any{}, true, 0, "", nil + + case "tools/list": + return map[string]any{"tools": toolCatalog()}, true, 0, "", nil + + case "tools/call": + var p struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + } + if err := unmarshalParams(params, &p); err != nil { + return nil, true, codeInvalidParams, err.Error(), nil + } + if p.Name == "" { + return nil, true, codeInvalidParams, "tools/call: name required", nil + } + s.mu.Lock() + host := s.host + s.mu.Unlock() + if host == nil { + return nil, true, codeInternal, "patterm: tool host not initialized", nil + } + result, code, errMsg, data := callTool(host, callerID, p.Name, p.Arguments) + if errMsg != "" { + // MCP convention: errors during tool execution come back as + // successful tools/call results with isError=true, so the + // model sees the failure as content rather than a transport + // error. Genuine transport errors (parse, etc.) stay as + // JSON-RPC errors and are handled outside this branch. + content := errMsg + if data != nil { + if kindMap, ok := data.(map[string]string); ok { + if k, present := kindMap["kind"]; present && k != "" { + content = fmt.Sprintf("%s (%s)", errMsg, k) + } + } + } + _ = code // code stays useful for legacy callers; tools/call surfaces text. + return map[string]any{ + "content": []map[string]any{{"type": "text", "text": content}}, + "isError": true, + }, true, 0, "", nil + } + return wrapToolResult(result), true, 0, "", nil + + case "resources/list": + // We don't expose resources; respond with an empty list rather + // than a method-not-found to keep clients happy. + return map[string]any{"resources": []any{}}, true, 0, "", nil + + case "prompts/list": + return map[string]any{"prompts": []any{}}, true, 0, "", nil + + case "logging/setLevel": + return map[string]any{}, true, 0, "", nil + } + return nil, false, 0, "", nil +} + +// wrapToolResult turns a structured tool result into an MCP tools/call +// response. Plain strings (e.g. "ok") become text content; structured +// values are JSON-encoded into a single text block and also exposed +// under structuredContent so capable clients can read the shape. +func wrapToolResult(result any) map[string]any { + var text string + switch v := result.(type) { + case nil: + text = "ok" + case string: + text = v + default: + b, err := json.Marshal(v) + if err != nil { + text = fmt.Sprintf("%v", v) + } else { + text = string(b) + } + } + out := map[string]any{ + "content": []map[string]any{{"type": "text", "text": text}}, + "isError": false, + } + if result != nil { + switch result.(type) { + case string: + // Skip — plain string already lives in content. + default: + out["structuredContent"] = result + } + } + return out +} diff --git a/internal/mcp/protocol_test.go b/internal/mcp/protocol_test.go new file mode 100644 index 0000000..e9276ab --- /dev/null +++ b/internal/mcp/protocol_test.go @@ -0,0 +1,128 @@ +package mcp + +import ( + "encoding/json" + "testing" +) + +func TestInitializeReturnsCapabilities(t *testing.T) { + s := &Server{} + req := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"claude","version":"1.0"}}}`) + resp := s.dispatch("", req) + if resp == nil { + t.Fatal("expected response for initialize") + } + var parsed struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result map[string]interface{} `json:"result"` + Error *struct { + Code int `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(resp, &parsed); err != nil { + t.Fatalf("parse: %v\n%s", err, resp) + } + if parsed.Error != nil { + t.Fatalf("initialize returned error: %+v", parsed.Error) + } + if parsed.Result["protocolVersion"] == nil { + t.Fatalf("missing protocolVersion: %+v", parsed.Result) + } + caps, ok := parsed.Result["capabilities"].(map[string]interface{}) + if !ok { + t.Fatalf("capabilities not object: %+v", parsed.Result) + } + if caps["tools"] == nil { + t.Fatalf("tools capability missing: %+v", caps) + } +} + +func TestInitializedNotificationSuppressesResponse(t *testing.T) { + s := &Server{} + req := []byte(`{"jsonrpc":"2.0","method":"notifications/initialized"}`) + resp := s.dispatch("", req) + if resp != nil { + t.Fatalf("notification produced a response: %s", resp) + } +} + +func TestToolsListReturnsConcreteSchemas(t *testing.T) { + s := &Server{} + req := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/list"}`) + resp := s.dispatch("", req) + if resp == nil { + t.Fatal("expected response for tools/list") + } + var parsed struct { + Result map[string]interface{} `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(resp, &parsed); err != nil { + t.Fatalf("parse: %v\n%s", err, resp) + } + if parsed.Error != nil { + t.Fatalf("tools/list returned error: %+v", parsed.Error) + } + tools, ok := parsed.Result["tools"].([]interface{}) + if !ok { + t.Fatalf("tools not array: %+v", parsed.Result) + } + if len(tools) == 0 { + t.Fatalf("expected at least one tool, got 0") + } + // Every tool must have name, description, and inputSchema with + // `type=object` and a concrete `properties` object — `properties: + // null` trips up strict MCP clients (claude in particular). + for i, tool := range tools { + entry, ok := tool.(map[string]interface{}) + if !ok { + t.Fatalf("tool %d not object: %#v", i, tool) + } + if entry["name"] == "" || entry["name"] == nil { + t.Fatalf("tool %d missing name: %#v", i, entry) + } + if entry["description"] == "" || entry["description"] == nil { + t.Fatalf("tool %d missing description: %#v", i, entry) + } + schema, ok := entry["inputSchema"].(map[string]interface{}) + if !ok { + t.Fatalf("tool %d inputSchema not object: %#v", i, entry) + } + if schema["type"] != "object" { + t.Fatalf("tool %d schema type != object: %#v", i, schema) + } + props, ok := schema["properties"] + if !ok { + t.Fatalf("tool %s missing properties", entry["name"]) + } + if _, ok := props.(map[string]interface{}); !ok { + t.Fatalf("tool %s properties not object (got %T): %#v", entry["name"], props, props) + } + } +} + +func TestPingReturnsEmptyObject(t *testing.T) { + s := &Server{} + req := []byte(`{"jsonrpc":"2.0","id":3,"method":"ping"}`) + resp := s.dispatch("", req) + if resp == nil { + t.Fatal("expected response for ping") + } + var parsed struct { + Result map[string]interface{} `json:"result"` + Error *struct{ Code int } `json:"error"` + } + if err := json.Unmarshal(resp, &parsed); err != nil { + t.Fatalf("parse: %v\n%s", err, resp) + } + if parsed.Error != nil { + t.Fatalf("ping returned error: %+v", parsed.Error) + } + if parsed.Result == nil { + t.Fatal("ping result missing") + } +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 91b1a4b..d0bc073 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -237,6 +237,9 @@ func (s *Server) SetHost(h ToolHost) { // dispatch routes a single JSON-RPC request. callerID is the ID of the // process that owns this connection (resolved at greeting time). +// Returns nil for notifications (no id present), which tells the caller +// to skip writing a response. Otherwise returns a complete JSON-RPC +// reply ready to send. func (s *Server) dispatch(callerID string, req []byte) []byte { var msg struct { JSONRPC string `json:"jsonrpc"` @@ -247,14 +250,37 @@ func (s *Server) dispatch(callerID string, req []byte) []byte { if err := json.Unmarshal(req, &msg); err != nil { return jsonRPCError(nil, codeParseError, "parse error: "+err.Error(), nil) } + + isNotification := len(msg.ID) == 0 || string(msg.ID) == "null" + + // MCP protocol-level methods (initialize, tools/list, tools/call, + // ping, notifications) run before legacy direct-tool dispatch so + // real MCP clients can hand-shake even when host isn't ready yet + // (initialize doesn't touch the host). + if result, handled, code, errMsg, data := s.handleProtocolMethod(callerID, msg.Method, msg.Params, isNotification); handled { + if isNotification { + return nil + } + if errMsg != "" { + return jsonRPCError(msg.ID, code, errMsg, data) + } + return jsonRPCResult(msg.ID, result) + } + s.mu.Lock() host := s.host s.mu.Unlock() if host == nil { + if isNotification { + return nil + } return jsonRPCError(msg.ID, codeInternal, "patterm: tool host not initialized", nil) } result, code, errMsg, data := callTool(host, callerID, msg.Method, msg.Params) + if isNotification { + return nil + } if errMsg != "" { return jsonRPCError(msg.ID, code, errMsg, data) } diff --git a/internal/preset/preset.go b/internal/preset/preset.go index dc6ae27..39039c1 100644 --- a/internal/preset/preset.go +++ b/internal/preset/preset.go @@ -45,16 +45,33 @@ type Preset struct { ChromeTrimHints []string `json:"chrome_trim_hints,omitempty"` } -// MCPInjection covers the three strategies SPEC §10 enumerates: a CLI -// flag (claude --mcp-config ...), an external config file we merge into -// (codex ~/.codex/config.toml), or an env var. +// MCPInjection covers the strategies SPEC §10 enumerates plus +// `cli_override` for agents (like codex) that accept inline config +// overrides via repeated CLI flags, and `config_env` for agents (like +// opencode) that read their config from an env var. The fields used +// depend on Kind. type MCPInjection struct { - Kind string `json:"kind"` // "flag" | "config_file" | "env_var" + Kind string `json:"kind"` // "flag" | "config_file" | "env_var" | "cli_override" | "config_env" Flag string `json:"flag,omitempty"` ConfigPath string `json:"config_path,omitempty"` - Path string `json:"path,omitempty"` - MergeKey string `json:"merge_key,omitempty"` Var string `json:"var,omitempty"` + + // config_file fields. patterm reads the file at Path, merges in a + // `patterm` entry under MergeKey, writes the result inside a temp + // directory laid out so HomeVar + HomePath points at the merged + // file, and exports HomeVar to the child. Format is inferred from + // Path's extension (toml or json) when blank. + Path string `json:"path,omitempty"` + MergeKey string `json:"merge_key,omitempty"` + Format string `json:"format,omitempty"` + HomeVar string `json:"home_var,omitempty"` + HomePath string `json:"home_path,omitempty"` + + // cli_override fields. patterm emits one ` .=` + // pair per MCP setting (command, args) so the agent merges them + // into its in-memory config without touching any file on disk. Used + // for codex's `-c key=value`. + KeyPrefix string `json:"key_prefix,omitempty"` } // ReadySignal lets a preset override the default 1s-idle heuristic. @@ -196,7 +213,12 @@ func ensureDefaults(base string) error { `{ "name": "codex", "argv": ["codex"], - "mcp_injection": { "kind": "config_file", "path": "~/.codex/config.toml", "merge_key": "mcp_servers" }, + "mcp_injection": { + "kind": "cli_override", + "flag": "-c", + "key_prefix": "mcp_servers.patterm", + "format": "toml" + }, "ready_signal": { "idle_ms": 1000 }, "chrome_trim_hints": [ "^OpenAI Codex", @@ -213,7 +235,13 @@ func ensureDefaults(base string) error { `{ "name": "opencode", "argv": ["opencode"], - "mcp_injection": { "kind": "config_file", "path": "~/.config/opencode/opencode.json", "merge_key": "mcp" }, + "mcp_injection": { + "kind": "config_env", + "path": "~/.config/opencode/opencode.json", + "merge_key": "mcp", + "format": "json", + "var": "OPENCODE_CONFIG_CONTENT" + }, "ready_signal": { "idle_ms": 1000 }, "chrome_trim_hints": [ "^\\s*█",