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:
23
AGENTS.md
23
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.
|
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
95
CHANGELOG.md
Normal 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.
|
||||||
23
CLAUDE.md
23
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.
|
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
11
TODO.md
@@ -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
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -429,24 +429,42 @@ 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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
377
internal/app/mcp_inject.go
Normal 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")
|
||||||
|
}
|
||||||
140
internal/app/mcp_inject_test.go
Normal file
140
internal/app/mcp_inject_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")))
|
||||||
|
|||||||
@@ -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
415
internal/mcp/protocol.go
Normal 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
|
||||||
|
}
|
||||||
128
internal/mcp/protocol_test.go
Normal file
128
internal/mcp/protocol_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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*█",
|
||||||
|
|||||||
Reference in New Issue
Block a user