Land staged session/MCP/chrome work + sidebar clear-J fix

This batches the in-flight [Unreleased] block from CHANGELOG.md into a
single commit. Highlights:

- Real MCP protocol layer (initialize / tools/list / tools/call) so
  vendor MCP clients can complete the handshake against the per-PID
  socket. Legacy direct-dispatch preserved for the harness.
- New mcp_injection kinds — cli_override for codex, config_env for
  opencode — joining the existing env-var and config_file paths so
  patterm can slot into more agents without touching their real
  config or auth.
- Ctrl+A/D and Ctrl+W/S focus navigation across tabs and intra-tab
  process lists, recognised in legacy / kitty CSI u / xterm
  modifyOtherKeys encodings.
- Palette macros (sw / k / sp ) and reordering so open sessions
  surface above spawn-new entries.
- Two-row tab bar, sidebar/tabbar/status chrome cache, viewport-wipe
  on agent spawn, CR-terminated orchestrator injections, and split-
  Enter PTY writes so paste-detecting TUIs see Enter as a key event.

Also fixes the bug logged in TODO: claude's Ctrl+O tool-call expansion
emits CSI 0 J, which the viewport renderer was forwarding verbatim —
wiping the sidebar to the right of the cursor and leaving the chrome
cache convinced nothing had changed. CSI 0 J and CSI 1 J are now
translated into per-row ECH sequences clamped to the viewport, same
as CSI 2 J and CSI K already were.

Agent guides (CLAUDE.md / AGENTS.md) now spell out the
TODO->CHANGELOG workflow so completed items land in the changelog
rather than as ticked entries left behind in TODO.
This commit is contained in:
2026-05-14 19:09:35 +01:00
parent 7649587f9a
commit 3622c41fd0
25 changed files with 1951 additions and 163 deletions

View File

@@ -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. 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 ## Development Notes
- Prefer existing package boundaries. MCP protocol shapes live in `internal/mcp`; runtime behavior usually belongs in `internal/app`. - Prefer existing package boundaries. MCP protocol shapes live in `internal/mcp`; runtime behavior usually belongs in `internal/app`.

95
CHANGELOG.md Normal file
View File

@@ -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.

View File

@@ -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. 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 ## Development Notes
- Prefer existing package boundaries. MCP protocol shapes live in `internal/mcp`; runtime behavior usually belongs in `internal/app`. - Prefer existing package boundaries. MCP protocol shapes live in `internal/mcp`; runtime behavior usually belongs in `internal/app`.

11
TODO.md
View File

@@ -1,9 +1,2 @@
- [ ] Killed agents are visible in the command palette. They shouldn't be. - [ ] When the parent agent/orchestrator is killed, all child processes spawned by it should also be killed.
- [ ] claude failed to connect to patterm mcp -32601 - [ ] This should apply to when we detect the process dies, like if the user Ctrl + C's out.
- [ ] 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 <query>` it would only show the switch entries. Maybe we should have info text greyed out to show these macros.
- [ ] sw <query> = switch
- [ ] k <query> = kill
- [ ] sp <query> = spawn

View File

@@ -235,6 +235,17 @@ type uiState struct {
hostCols, hostRows uint16 hostCols, hostRows uint16
stdinTTY bool 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 lastExit atomic.Int32
} }
@@ -300,14 +311,28 @@ func (st *uiState) OnChildSpawned(c *Child) {
st.mu.Lock() st.mu.Lock()
st.focusedID = c.ID st.focusedID = c.ID
st.focusedName = c.Name st.focusedName = c.Name
st.renderer = newViewportRenderer(st.layoutSnapshot()) renderer := newViewportRenderer(st.layoutSnapshot())
if st.palette != nil { st.renderer = renderer
palOpen := st.palette != nil
if palOpen {
st.palette.children = st.sess.Children() st.palette.children = st.sess.Children()
st.palette.focused = st.focusedID st.palette.focused = st.focusedID
st.palette.rebuild() st.palette.rebuild()
st.renderPaletteLocked() st.renderPaletteLocked()
} }
st.mu.Unlock() 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.moveToViewportOrigin()
st.drawTabBar() st.drawTabBar()
st.drawSidebar() st.drawSidebar()
@@ -402,11 +427,26 @@ func (st *uiState) leaveScreen() {
} }
func (st *uiState) clearScreen() { func (st *uiState) clearScreen() {
st.invalidateChromeCache()
st.outMu.Lock() st.outMu.Lock()
defer st.outMu.Unlock() defer st.outMu.Unlock()
_, _ = os.Stdout.Write([]byte("\x1b[?25h\x1b[H\x1b[2J")) _, _ = 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() { func (st *uiState) moveToViewportOrigin() {
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
st.outMu.Lock() st.outMu.Lock()
@@ -489,6 +529,14 @@ func (st *uiState) drawStatusLine() {
if len(line) > int(cols) { if len(line) > int(cols) {
line = 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() st.outMu.Lock()
defer st.outMu.Unlock() defer st.outMu.Unlock()
// Save cursor, move to last row col 1, write, restore. // 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) 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 { func (st *uiState) stdinLoop() error {
buf := make([]byte, 4096) buf := make([]byte, 4096)
for { for {
@@ -616,6 +694,9 @@ func (st *uiState) processStdin(chunk []byte) {
if st.focusedID != "" { if st.focusedID != "" {
if c := st.sess.FindChild(st.focusedID); c != nil && c.Status() == StatusRunning { if c := st.sess.FindChild(st.focusedID); c != nil && c.Status() == StatusRunning {
prev := c.Owner() 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) _ = c.InjectAsUser(forward)
if prev != OwnerUser { if prev != OwnerUser {
go st.drawStatusLine() go st.drawStatusLine()
@@ -626,6 +707,7 @@ func (st *uiState) processStdin(chunk []byte) {
} }
var pendingAction *paletteAction var pendingAction *paletteAction
var pendingNavID string
// Tracks the last arrow direction and the byte offset immediately // Tracks the last arrow direction and the byte offset immediately
// after its CSI sequence. Some terminals emit a duplicate adjacent // after its CSI sequence. Some terminals emit a duplicate adjacent
@@ -691,6 +773,37 @@ func (st *uiState) processStdin(chunk []byte) {
continue 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) forward = append(forward, b)
i++ i++
} }
@@ -700,6 +813,9 @@ func (st *uiState) processStdin(chunk []byte) {
if pendingAction != nil { if pendingAction != nil {
st.closePalette(*pendingAction) st.closePalette(*pendingAction)
} }
if pendingNavID != "" {
st.focusProcess(pendingNavID)
}
} }
func (st *uiState) openPaletteLocked() { func (st *uiState) openPaletteLocked() {

View File

@@ -429,25 +429,43 @@ func (c *Child) teardownPTY() {
// pane. SPEC §6: the user's first keystroke flips ownership. // pane. SPEC §6: the user's first keystroke flips ownership.
func (c *Child) InjectAsUser(b []byte) error { func (c *Child) InjectAsUser(b []byte) error {
c.SetOwner(OwnerUser) c.SetOwner(OwnerUser)
pty := c.PTY() return c.writeInput(b)
if pty == nil {
return errors.New("child has no pty")
}
_, err := pty.Write(b)
return err
} }
// InjectAsOrchestrator is the path send_message / initial_prompt / // InjectAsOrchestrator is the path send_message / initial_prompt /
// timer_wait writes take. Ownership flips back to orchestrator. SPEC §6. // timer_wait writes take. Ownership flips back to orchestrator. SPEC §6.
func (c *Child) InjectAsOrchestrator(b []byte) error { func (c *Child) InjectAsOrchestrator(b []byte) error {
c.SetOwner(OwnerOrchestrator) 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() pty := c.PTY()
if pty == nil { if pty == nil {
return errors.New("child has no pty") return errors.New("child has no pty")
} }
pieces := splitOnEnter(b)
if len(pieces) <= 1 {
_, err := pty.Write(b) _, err := pty.Write(b)
return err 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 { func mintIdentity() string {
var buf [12]byte var buf [12]byte

View File

@@ -523,7 +523,12 @@ func encodeInput(args mcp.SendInputArgs) ([]byte, error) {
} }
out := []byte(args.Text) out := []byte(args.Text)
if submit { 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 return out, nil
case "paste": 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") return "", mcp.Errorf("not_related", "send_message: cannot send to self")
} }
if caller != nil && target.ParentID == caller.ID { if caller != nil && target.ParentID == caller.ID {
return "[orchestrator] " + message + "\n", nil return "[orchestrator] " + message + "\r", nil
} }
if caller != nil && caller.ParentID == target.ID { 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 == "" { 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) 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() { if !caller.IsLive() {
return 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)) _ = caller.InjectAsOrchestrator([]byte(line))
}() }()
return id, nil return id, nil

View File

@@ -142,3 +142,37 @@ func isModifyOtherKeysCtrlK(params string) bool {
} }
return parts[0] == "27" && parts[1] == "5" && parts[2] == "107" return parts[0] == "27" && parts[1] == "5" && parts[2] == "107"
} }
// matchCtrlChar reports whether chunk[i:] starts with Ctrl+<ch> 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;<key>~.
// 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
}

View File

@@ -79,10 +79,39 @@ func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, par
} }
env = append(env, p.MCPInjection.Var+"="+mcpConfigPath) env = append(env, p.MCPInjection.Var+"="+mcpConfigPath)
case "config_file": case "config_file":
// SPEC §10 mentions merging into an external config file. We // Merge patterm's MCP entry into a vendored copy of the
// expose the config_path via an env var the user can read // user's existing config file, then point the child at the
// at preset-creation time; full merge is deferred. // 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) 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: default:
return nil, fmt.Errorf("preset %s: unknown mcp_injection.kind %q", p.Name, p.MCPInjection.Kind) 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 == "" { if initialPrompt == "" {
return 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 return c, nil
} }

View File

@@ -40,7 +40,11 @@ func newTerminalLayout(cols, rows uint16) terminalLayout {
l.sidebarVisible = true l.sidebarVisible = true
l.sidebarWidth = sidebarCols l.sidebarWidth = sidebarCols
l.sidebarLeft = cols - sidebarCols + 1 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 reservedRows := tabBarRows + statusRows

View File

@@ -11,13 +11,13 @@ func TestTerminalLayoutWideUsesMainViewport(t *testing.T) {
if !l.sidebarVisible { if !l.sidebarVisible {
t.Fatal("wide layout should show sidebar") t.Fatal("wide layout should show sidebar")
} }
if l.childCols() != 92 { if l.childCols() != 91 {
t.Fatalf("child cols: got %d want 92", l.childCols()) t.Fatalf("child cols: got %d want 91", l.childCols())
} }
if l.childRows() != 36 { if l.childRows() != 37 {
t.Fatalf("child rows: got %d want 36", l.childRows()) 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) 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 { if l.childCols() != 38 {
t.Fatalf("child cols: got %d want 38", l.childCols()) t.Fatalf("child cols: got %d want 38", l.childCols())
} }
if l.childRows() != 8 { if l.childRows() != 9 {
t.Fatalf("child rows: got %d want 8", l.childRows()) t.Fatalf("child rows: got %d want 9", l.childRows())
} }
} }
@@ -46,13 +46,13 @@ func TestSpawnSizingUsesViewportDimensions(t *testing.T) {
l := newTerminalLayout(120, 40) l := newTerminalLayout(120, 40)
launcher := NewLauncher(nil, "", l.childCols(), l.childRows()) launcher := NewLauncher(nil, "", l.childCols(), l.childRows())
cols, rows := launcher.size() cols, rows := launcher.size()
if cols != 92 || rows != 36 { if cols != 91 || rows != 37 {
t.Fatalf("launcher size: got %dx%d want 92x36", cols, rows) t.Fatalf("launcher size: got %dx%d want 91x37", cols, rows)
} }
host := newToolHost(nil, nil, nil, preset.Set{}, nil, l.childCols(), l.childRows()) host := newToolHost(nil, nil, nil, preset.Set{}, nil, l.childCols(), l.childRows())
cols, rows = host.size() cols, rows = host.size()
if cols != 92 || rows != 36 { if cols != 91 || rows != 37 {
t.Fatalf("tool host size: got %dx%d want 92x36", cols, rows) t.Fatalf("tool host size: got %dx%d want 91x37", cols, rows)
} }
} }

377
internal/app/mcp_inject.go Normal file
View File

@@ -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/<identity>/
// 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 `[<mergeKey>.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")
}

View File

@@ -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)
}
}

View File

@@ -38,6 +38,29 @@ type paletteState struct {
items []paletteItem 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 <query>` filters to switch entries only,
// `k <query>` to kills, `sp <query>` 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 { func newPalette(children []*Child, focused string, presets preset.Set) *paletteState {
p := &paletteState{children: children, focused: focused, presets: presets} p := &paletteState{children: children, focused: focused, presets: presets}
p.rebuild() p.rebuild()
@@ -47,6 +70,21 @@ func newPalette(children []*Child, focused string, presets preset.Set) *paletteS
func (p *paletteState) rebuild() { func (p *paletteState) rebuild() {
all := p.allItems() all := p.allItems()
q := strings.ToLower(string(p.query)) 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 == "" { if q == "" {
p.items = all p.items = all
} else { } else {
@@ -68,8 +106,32 @@ func (p *paletteState) rebuild() {
func (p *paletteState) allItems() []paletteItem { func (p *paletteState) allItems() []paletteItem {
var out []paletteItem var out []paletteItem
// Preset commands first — SPEC §4 calls these out as the primary // Switch entries first — existing open agents/processes should
// way to spawn anything. One entry per file under presets/. // 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 { for _, pr := range p.presets.Agents {
out = append(out, paletteItem{ out = append(out, paletteItem{
label: "Spawn agent: " + pr.Name, label: "Spawn agent: " + pr.Name,
@@ -85,22 +147,7 @@ func (p *paletteState) allItems() []paletteItem {
}) })
} }
// Switch / Kill entries — one per existing child. // Kill entries last among the action rows, before Quit.
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},
})
}
for _, c := range p.children { for _, c := range p.children {
if c.Status() != StatusRunning { if c.Status() != StatusRunning {
continue continue
@@ -447,7 +494,7 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) {
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset) b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++ row++
footer := "↵ run · esc close · ↑↓ navigate" footer := "↵ run · esc close · ↑↓ navigate · sw/k/sp <q> filter"
fLen := utf8.RuneCountInString(footer) fLen := utf8.RuneCountInString(footer)
fPad := content - fLen fPad := content - fLen
if fPad < 0 { if fPad < 0 {

View File

@@ -152,8 +152,17 @@ func (st *uiState) drawSidebar() {
write("") write("")
} }
frame := b.String()
st.chromeCacheMu.Lock()
if frame == st.sidebarCache {
st.chromeCacheMu.Unlock()
return
}
st.sidebarCache = frame
st.chromeCacheMu.Unlock()
st.outMu.Lock() st.outMu.Lock()
// Save cursor; emit the sidebar; restore. // 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() st.outMu.Unlock()
} }

View File

@@ -7,15 +7,15 @@ import (
"unicode/utf8" "unicode/utf8"
) )
// Three-row tab bar: labels row, subtitle row, underline row. The PTY // Two-row tab bar: labels row, underline row. The PTY viewport's top
// viewport's top row is therefore mainTop == tabBarRows + 1. // row is therefore mainTop == tabBarRows + 1.
const tabBarRows = 3 const tabBarRows = 2
// drawTabBar renders the top tab strip across the full host width. The // drawTabBar renders the top tab strip across the full host width.
// strip has three rows: labels (with horizontal padding), a dim // Tabs share the available width with a flex layout — each visible
// subtitle showing each child's argv, and an underline that's thick + // session gets roughly width/N cells, with the remainder distributed
// accent for the focused tab and faint for the rest. Subtitles are // to the leftmost tabs so the strip fills the screen edge-to-edge.
// truncated with `…` to the tab's width. // A trailing "+ new" hint sits in the rightmost reserved slot.
func (st *uiState) drawTabBar() { func (st *uiState) drawTabBar() {
st.mu.Lock() st.mu.Lock()
palOpen := st.palette != nil 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 { type tabRect struct {
startCol int startCol int
width int width int
label string label string
subtitle string
active bool active bool
} }
const ( // Reserve space at the right edge for "+ new". If there are too
leadingPad = 2 // host columns before the first tab // many tabs to fit even at minTabWidth, drop tabs from the right
tabPad = 2 // spaces on each side of the label inside the tab // until they do. The current focus stays visible.
tabGap = 1 // gap columns between adjacent tabs tabBudget := width - newHintW
tailReserve = 8 // reserve room for the trailing "+ new" hint if tabBudget < minTabWidth {
) tabBudget = width
newHintW = 0
}
tabs := make([]tabRect, 0, len(sessions)) visible := sessions
cur := leadingPad + 1 if len(visible) > 0 {
for _, c := range sessions { 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 := 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 label := c.Name
labelW := utf8.RuneCountInString(label) labelW := utf8.RuneCountInString(label)
tabW := labelW + tabPad*2 maxLabelW := w - 2 // one pad on each side
if maxLabelW < 1 {
// If the tab won't fit, try truncating the label down to whatever maxLabelW = 1
// space is left (label still has to leave room for "…"). }
if cur+tabW+tabGap+tailReserve > width+1 { if labelW > maxLabelW {
avail := width + 1 - cur - tabGap - tailReserve - tabPad*2 if maxLabelW > 1 {
if avail < 3 { label = clipRunes(label, maxLabelW-1) + "…"
break } else {
label = clipRunes(label, maxLabelW)
} }
label = clipRunes(label, avail-1) + "…"
labelW = utf8.RuneCountInString(label) 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
} }
tabs = append(tabs, tabRect{ tabs = append(tabs, tabRect{
startCol: cur, width: tabW, startCol: col,
label: label, subtitle: strings.Join(c.Argv, " "), width: w,
label: label,
active: c.ID == focus, active: c.ID == focus,
}) })
cur += tabW + tabGap col += w
}
} }
var b strings.Builder var b strings.Builder
// Clear all three rows up front so a stale label from the previous // Clear both rows so a stale label from the previous frame can't
// frame can't bleed through. // bleed through.
b.WriteString("\x1b[1;1H\x1b[2K") b.WriteString("\x1b[1;1H\x1b[2K")
b.WriteString("\x1b[2;1H\x1b[2K") b.WriteString("\x1b[2;1H\x1b[2K")
b.WriteString("\x1b[3;1H\x1b[2K")
for _, t := range tabs { 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) fmt.Fprintf(&b, "\x1b[1;%dH", t.startCol)
if t.active { if t.active {
b.WriteString(styleActive) b.WriteString(styleActive)
} else { } else {
b.WriteString(styleHint) b.WriteString(styleHint)
} }
b.WriteString(strings.Repeat(" ", tabPad)) b.WriteString(strings.Repeat(" ", leftPad))
b.WriteString(t.label) b.WriteString(t.label)
b.WriteString(strings.Repeat(" ", tabPad)) b.WriteString(strings.Repeat(" ", rightPad))
b.WriteString(styleReset) b.WriteString(styleReset)
// Row 2: subtitle, truncated to tab width and dimmed. // Row 2: underline. Thick accent for the active tab, faint
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
// border for the rest. // border for the rest.
fmt.Fprintf(&b, "\x1b[3;%dH", t.startCol) fmt.Fprintf(&b, "\x1b[2;%dH", t.startCol)
if t.active { if t.active {
b.WriteString(styleAccent) b.WriteString(styleAccent)
b.WriteString(strings.Repeat("━", t.width)) b.WriteString(strings.Repeat("━", t.width))
@@ -135,26 +164,26 @@ func (st *uiState) drawTabBar() {
b.WriteString(styleReset) b.WriteString(styleReset)
} }
// "+ new" hint at the end of the labels row, in dim. // "+ new" hint right-aligned in the reserved slot.
if cur+3 <= width { if newHintW > 0 {
fmt.Fprintf(&b, "\x1b[1;%dH%s+ new%s", cur+1, styleDim, styleReset) 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 frame := b.String()
// the tab strip reads as one continuous divider. st.chromeCacheMu.Lock()
if cur <= width { if frame == st.tabBarCache {
remain := width - cur + 1 st.chromeCacheMu.Unlock()
if remain > 0 { return
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)
} }
st.tabBarCache = frame
st.chromeCacheMu.Unlock()
st.outMu.Lock() st.outMu.Lock()
defer st.outMu.Unlock() defer st.outMu.Unlock()
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", b.String()) fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame)
} }

View File

@@ -57,3 +57,96 @@ func firstRunningTopLevel(children []*Child) *Child {
} }
return nil 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
}

View File

@@ -39,3 +39,61 @@ func childIDs(cs []*Child) []string {
} }
return ids 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)
}
}

View File

@@ -161,6 +161,10 @@ func (vr *viewportRenderer) emitCSI() {
return return
} }
switch n { switch n {
case 0:
vr.pending.WriteString(vr.clearViewportFromCursor())
case 1:
vr.pending.WriteString(vr.clearViewportToCursor())
case 2, 3: case 2, 3:
vr.pending.WriteString(vr.clearViewport()) vr.pending.WriteString(vr.clearViewport())
default: default:
@@ -203,6 +207,54 @@ func (vr *viewportRenderer) clearViewport() string {
return b.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 { func (vr *viewportRenderer) clearLine(n int) string {
right := int(vr.layout.childCols()) right := int(vr.layout.childCols())
if vr.col < 1 { if vr.col < 1 {

View File

@@ -8,7 +8,7 @@ import (
func TestViewportRendererShiftsCursor(t *testing.T) { func TestViewportRendererShiftsCursor(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40)) vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[H"))) got := string(vr.Render([]byte("\x1b[H")))
if got != "\x1b[4;1H" { if got != "\x1b[3;1H" {
t.Fatalf("CUP home: got %q", got) t.Fatalf("CUP home: got %q", got)
} }
} }
@@ -22,17 +22,17 @@ func TestViewportRendererSwallowsAltScreenToggles(t *testing.T) {
} }
func TestViewportRendererClearScreenIsViewportOnly(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. // 1-row status reservation.
vr := newViewportRenderer(newTerminalLayout(20, 7)) vr := newViewportRenderer(newTerminalLayout(20, 7))
got := string(vr.Render([]byte("\x1b[2J"))) got := string(vr.Render([]byte("\x1b[2J")))
if strings.Contains(got, "\x1b[2J") { if strings.Contains(got, "\x1b[2J") {
t.Fatalf("host clear-screen leaked through: %q", got) 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) 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) 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) { func TestViewportRendererTracksPrintableCursor(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(20, 5)) vr := newViewportRenderer(newTerminalLayout(20, 5))
got := string(vr.Render([]byte("hello\x1b[K"))) got := string(vr.Render([]byte("hello\x1b[K")))

View File

@@ -115,21 +115,25 @@ func (s *Server) handleConn(conn net.Conn) {
} else { } else {
// Treat as a real request from an unknown caller. // Treat as a real request from an unknown caller.
resp := s.dispatch("", greeting) resp := s.dispatch("", greeting)
if resp != nil {
resp = append(resp, '\n') resp = append(resp, '\n')
if _, werr := conn.Write(resp); werr != nil { if _, werr := conn.Write(resp); werr != nil {
return return
} }
} }
}
for { for {
line, err := r.ReadBytes('\n') line, err := r.ReadBytes('\n')
if len(line) > 0 { if len(line) > 0 {
resp := s.dispatch(callerID, line) resp := s.dispatch(callerID, line)
if resp != nil {
resp = append(resp, '\n') resp = append(resp, '\n')
if _, werr := conn.Write(resp); werr != nil { if _, werr := conn.Write(resp); werr != nil {
return return
} }
} }
}
if err != nil { if err != nil {
return return
} }

415
internal/mcp/protocol.go Normal file
View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -237,6 +237,9 @@ func (s *Server) SetHost(h ToolHost) {
// dispatch routes a single JSON-RPC request. callerID is the ID of the // dispatch routes a single JSON-RPC request. callerID is the ID of the
// process that owns this connection (resolved at greeting time). // 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 { func (s *Server) dispatch(callerID string, req []byte) []byte {
var msg struct { var msg struct {
JSONRPC string `json:"jsonrpc"` 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 { if err := json.Unmarshal(req, &msg); err != nil {
return jsonRPCError(nil, codeParseError, "parse error: "+err.Error(), 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() s.mu.Lock()
host := s.host host := s.host
s.mu.Unlock() s.mu.Unlock()
if host == nil { if host == nil {
if isNotification {
return nil
}
return jsonRPCError(msg.ID, codeInternal, "patterm: tool host not initialized", nil) return jsonRPCError(msg.ID, codeInternal, "patterm: tool host not initialized", nil)
} }
result, code, errMsg, data := callTool(host, callerID, msg.Method, msg.Params) result, code, errMsg, data := callTool(host, callerID, msg.Method, msg.Params)
if isNotification {
return nil
}
if errMsg != "" { if errMsg != "" {
return jsonRPCError(msg.ID, code, errMsg, data) return jsonRPCError(msg.ID, code, errMsg, data)
} }

View File

@@ -45,16 +45,33 @@ type Preset struct {
ChromeTrimHints []string `json:"chrome_trim_hints,omitempty"` ChromeTrimHints []string `json:"chrome_trim_hints,omitempty"`
} }
// MCPInjection covers the three strategies SPEC §10 enumerates: a CLI // MCPInjection covers the strategies SPEC §10 enumerates plus
// flag (claude --mcp-config ...), an external config file we merge into // `cli_override` for agents (like codex) that accept inline config
// (codex ~/.codex/config.toml), or an env var. // 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 { 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"` Flag string `json:"flag,omitempty"`
ConfigPath string `json:"config_path,omitempty"` ConfigPath string `json:"config_path,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"` Path string `json:"path,omitempty"`
MergeKey string `json:"merge_key,omitempty"` MergeKey string `json:"merge_key,omitempty"`
Var string `json:"var,omitempty"` Format string `json:"format,omitempty"`
HomeVar string `json:"home_var,omitempty"`
HomePath string `json:"home_path,omitempty"`
// cli_override fields. patterm emits one `<Flag> <KeyPrefix>.<k>=<v>`
// 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. // ReadySignal lets a preset override the default 1s-idle heuristic.
@@ -196,7 +213,12 @@ func ensureDefaults(base string) error {
`{ `{
"name": "codex", "name": "codex",
"argv": ["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 }, "ready_signal": { "idle_ms": 1000 },
"chrome_trim_hints": [ "chrome_trim_hints": [
"^OpenAI Codex", "^OpenAI Codex",
@@ -213,7 +235,13 @@ func ensureDefaults(base string) error {
`{ `{
"name": "opencode", "name": "opencode",
"argv": ["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 }, "ready_signal": { "idle_ms": 1000 },
"chrome_trim_hints": [ "chrome_trim_hints": [
"^\\s*█", "^\\s*█",