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:
@@ -235,6 +235,17 @@ type uiState struct {
|
||||
hostCols, hostRows uint16
|
||||
stdinTTY bool
|
||||
|
||||
// chromeCacheMu guards the last-rendered byte cache for each chrome
|
||||
// element. The tab bar, sidebar, and status line all repaint on
|
||||
// many state changes and on every PTY chunk, but their content
|
||||
// usually doesn't change between calls — caching the rendered
|
||||
// output and skipping a write when it matches eliminates the
|
||||
// flicker (especially in the sidebar's session tree).
|
||||
chromeCacheMu sync.Mutex
|
||||
tabBarCache string
|
||||
sidebarCache string
|
||||
statusLineCache string
|
||||
|
||||
lastExit atomic.Int32
|
||||
}
|
||||
|
||||
@@ -300,14 +311,28 @@ func (st *uiState) OnChildSpawned(c *Child) {
|
||||
st.mu.Lock()
|
||||
st.focusedID = c.ID
|
||||
st.focusedName = c.Name
|
||||
st.renderer = newViewportRenderer(st.layoutSnapshot())
|
||||
if st.palette != nil {
|
||||
renderer := newViewportRenderer(st.layoutSnapshot())
|
||||
st.renderer = renderer
|
||||
palOpen := st.palette != nil
|
||||
if palOpen {
|
||||
st.palette.children = st.sess.Children()
|
||||
st.palette.focused = st.focusedID
|
||||
st.palette.rebuild()
|
||||
st.renderPaletteLocked()
|
||||
}
|
||||
st.mu.Unlock()
|
||||
|
||||
// Wipe the viewport area so the previous focused child's PTY
|
||||
// output doesn't bleed through beneath the new pane. The palette
|
||||
// branch is skipped because the palette overlay covers the whole
|
||||
// screen and is about to take focus back to OnChildSpawned's
|
||||
// caller path.
|
||||
if !palOpen {
|
||||
st.outMu.Lock()
|
||||
_, _ = os.Stdout.Write(renderer.ClearViewport())
|
||||
st.outMu.Unlock()
|
||||
}
|
||||
|
||||
st.moveToViewportOrigin()
|
||||
st.drawTabBar()
|
||||
st.drawSidebar()
|
||||
@@ -402,11 +427,26 @@ func (st *uiState) leaveScreen() {
|
||||
}
|
||||
|
||||
func (st *uiState) clearScreen() {
|
||||
st.invalidateChromeCache()
|
||||
st.outMu.Lock()
|
||||
defer st.outMu.Unlock()
|
||||
_, _ = os.Stdout.Write([]byte("\x1b[?25h\x1b[H\x1b[2J"))
|
||||
}
|
||||
|
||||
// invalidateChromeCache forces the next drawTabBar / drawSidebar /
|
||||
// drawStatusLine call to actually emit bytes, regardless of cached
|
||||
// content. Anything that clears or repaints the screen (resize, focus
|
||||
// change, full repaint) must call this — otherwise the chrome stays
|
||||
// blank because the cached frame still matches the unchanged state
|
||||
// even though the wire was cleared.
|
||||
func (st *uiState) invalidateChromeCache() {
|
||||
st.chromeCacheMu.Lock()
|
||||
st.tabBarCache = ""
|
||||
st.sidebarCache = ""
|
||||
st.statusLineCache = ""
|
||||
st.chromeCacheMu.Unlock()
|
||||
}
|
||||
|
||||
func (st *uiState) moveToViewportOrigin() {
|
||||
layout := st.layoutSnapshot()
|
||||
st.outMu.Lock()
|
||||
@@ -489,6 +529,14 @@ func (st *uiState) drawStatusLine() {
|
||||
if len(line) > int(cols) {
|
||||
line = line[:int(cols)]
|
||||
}
|
||||
st.chromeCacheMu.Lock()
|
||||
if line == st.statusLineCache {
|
||||
st.chromeCacheMu.Unlock()
|
||||
return
|
||||
}
|
||||
st.statusLineCache = line
|
||||
st.chromeCacheMu.Unlock()
|
||||
|
||||
st.outMu.Lock()
|
||||
defer st.outMu.Unlock()
|
||||
// Save cursor, move to last row col 1, write, restore.
|
||||
@@ -535,6 +583,36 @@ func (st *uiState) layoutLocked() terminalLayout {
|
||||
return newTerminalLayout(st.hostCols, st.hostRows)
|
||||
}
|
||||
|
||||
// splitOnEnter walks input and returns each Enter byte (CR or LF) as
|
||||
// its own slice, with the surrounding non-Enter bytes batched between.
|
||||
// Empty pieces are dropped. The result preserves byte order, so
|
||||
// "hello\rworld\n" yields ["hello", "\r", "world", "\n"]. Callers use
|
||||
// this to keep Enter keystrokes from getting bundled into the same
|
||||
// PTY write as the text that preceded them — TUI agents' paste
|
||||
// detection (claude/codex/opencode) otherwise swallows the CR as
|
||||
// literal content instead of treating it as a key event.
|
||||
func splitOnEnter(in []byte) [][]byte {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
var out [][]byte
|
||||
start := 0
|
||||
for i, b := range in {
|
||||
if b != '\r' && b != '\n' {
|
||||
continue
|
||||
}
|
||||
if i > start {
|
||||
out = append(out, in[start:i])
|
||||
}
|
||||
out = append(out, in[i:i+1])
|
||||
start = i + 1
|
||||
}
|
||||
if start < len(in) {
|
||||
out = append(out, in[start:])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (st *uiState) stdinLoop() error {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
@@ -616,6 +694,9 @@ func (st *uiState) processStdin(chunk []byte) {
|
||||
if st.focusedID != "" {
|
||||
if c := st.sess.FindChild(st.focusedID); c != nil && c.Status() == StatusRunning {
|
||||
prev := c.Owner()
|
||||
// InjectAsUser splits Enter bytes onto their own
|
||||
// writes so claude / codex / opencode don't treat a
|
||||
// "text\r" batch as a paste.
|
||||
_ = c.InjectAsUser(forward)
|
||||
if prev != OwnerUser {
|
||||
go st.drawStatusLine()
|
||||
@@ -626,6 +707,7 @@ func (st *uiState) processStdin(chunk []byte) {
|
||||
}
|
||||
|
||||
var pendingAction *paletteAction
|
||||
var pendingNavID string
|
||||
|
||||
// Tracks the last arrow direction and the byte offset immediately
|
||||
// after its CSI sequence. Some terminals emit a duplicate adjacent
|
||||
@@ -691,6 +773,37 @@ func (st *uiState) processStdin(chunk []byte) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Ctrl+WASD: directional focus navigation, matching the four
|
||||
// arrow keys you'd expect in a tiling layout. A/D step between
|
||||
// top-level tabs; W/S step through the current tab's process
|
||||
// list (root first, then sub-agents). Bytes after the chord
|
||||
// in the same chunk are dropped — the focus change makes
|
||||
// further forwarding ambiguous between old and new pane.
|
||||
if hit, adv := matchCtrlChar(chunk, i, 'a'); hit {
|
||||
flushForward()
|
||||
pendingNavID = nextTabID(st.sess.Children(), st.focusedID, -1)
|
||||
i += adv
|
||||
break
|
||||
}
|
||||
if hit, adv := matchCtrlChar(chunk, i, 'd'); hit {
|
||||
flushForward()
|
||||
pendingNavID = nextTabID(st.sess.Children(), st.focusedID, +1)
|
||||
i += adv
|
||||
break
|
||||
}
|
||||
if hit, adv := matchCtrlChar(chunk, i, 'w'); hit {
|
||||
flushForward()
|
||||
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, -1)
|
||||
i += adv
|
||||
break
|
||||
}
|
||||
if hit, adv := matchCtrlChar(chunk, i, 's'); hit {
|
||||
flushForward()
|
||||
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, +1)
|
||||
i += adv
|
||||
break
|
||||
}
|
||||
|
||||
forward = append(forward, b)
|
||||
i++
|
||||
}
|
||||
@@ -700,6 +813,9 @@ func (st *uiState) processStdin(chunk []byte) {
|
||||
if pendingAction != nil {
|
||||
st.closePalette(*pendingAction)
|
||||
}
|
||||
if pendingNavID != "" {
|
||||
st.focusProcess(pendingNavID)
|
||||
}
|
||||
}
|
||||
|
||||
func (st *uiState) openPaletteLocked() {
|
||||
|
||||
@@ -429,24 +429,42 @@ func (c *Child) teardownPTY() {
|
||||
// pane. SPEC §6: the user's first keystroke flips ownership.
|
||||
func (c *Child) InjectAsUser(b []byte) error {
|
||||
c.SetOwner(OwnerUser)
|
||||
pty := c.PTY()
|
||||
if pty == nil {
|
||||
return errors.New("child has no pty")
|
||||
}
|
||||
_, err := pty.Write(b)
|
||||
return err
|
||||
return c.writeInput(b)
|
||||
}
|
||||
|
||||
// InjectAsOrchestrator is the path send_message / initial_prompt /
|
||||
// timer_wait writes take. Ownership flips back to orchestrator. SPEC §6.
|
||||
func (c *Child) InjectAsOrchestrator(b []byte) error {
|
||||
c.SetOwner(OwnerOrchestrator)
|
||||
return c.writeInput(b)
|
||||
}
|
||||
|
||||
// writeInput is the shared PTY write path used by both injection
|
||||
// flavours. Each Enter byte (CR or LF) is split onto its own write
|
||||
// with a brief delay so TUI agents with paste-detection (claude,
|
||||
// codex, opencode) don't coalesce a trailing CR into the text that
|
||||
// preceded it. Without the split, `pty.Write([]byte("hello\r"))`
|
||||
// arrives at the agent as one read() and gets treated as multi-line
|
||||
// pasted content rather than "key Enter".
|
||||
func (c *Child) writeInput(b []byte) error {
|
||||
pty := c.PTY()
|
||||
if pty == nil {
|
||||
return errors.New("child has no pty")
|
||||
}
|
||||
_, err := pty.Write(b)
|
||||
return err
|
||||
pieces := splitOnEnter(b)
|
||||
if len(pieces) <= 1 {
|
||||
_, err := pty.Write(b)
|
||||
return err
|
||||
}
|
||||
for i, piece := range pieces {
|
||||
if i > 0 {
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
}
|
||||
if _, err := pty.Write(piece); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mintIdentity() string {
|
||||
|
||||
@@ -523,7 +523,12 @@ func encodeInput(args mcp.SendInputArgs) ([]byte, error) {
|
||||
}
|
||||
out := []byte(args.Text)
|
||||
if submit {
|
||||
out = append(out, '\n')
|
||||
// CR (`\r`) is what every terminal emits for Enter in raw
|
||||
// mode, and what TUI agents (claude/codex/…) bind to
|
||||
// "submit". Sending `\n` here used to land as a literal
|
||||
// newline inside their textareas, leaving the message
|
||||
// composed but not sent.
|
||||
out = append(out, '\r')
|
||||
}
|
||||
return out, nil
|
||||
case "paste":
|
||||
@@ -635,13 +640,13 @@ func classifySendMessage(caller, target *Child, callerID, message string) (strin
|
||||
return "", mcp.Errorf("not_related", "send_message: cannot send to self")
|
||||
}
|
||||
if caller != nil && target.ParentID == caller.ID {
|
||||
return "[orchestrator] " + message + "\n", nil
|
||||
return "[orchestrator] " + message + "\r", nil
|
||||
}
|
||||
if caller != nil && caller.ParentID == target.ID {
|
||||
return fmt.Sprintf("[sub-agent:%s] %s\n", caller.DisplayName(), message), nil
|
||||
return fmt.Sprintf("[sub-agent:%s] %s\r", caller.DisplayName(), message), nil
|
||||
}
|
||||
if caller == nil && target.ParentID == "" {
|
||||
return "[orchestrator] " + message + "\n", nil
|
||||
return "[orchestrator] " + message + "\r", nil
|
||||
}
|
||||
return "", mcp.Errorf("not_related", "send_message: %q is neither parent nor child of caller (siblings must route through the parent in v1)", target.ID)
|
||||
}
|
||||
@@ -670,7 +675,7 @@ func (h *toolHost) TimerWait(callerID string, seconds float64, label string) (st
|
||||
if !caller.IsLive() {
|
||||
return
|
||||
}
|
||||
line := fmt.Sprintf("[system] Your timer [%s] has completed.\n", label)
|
||||
line := fmt.Sprintf("[system] Your timer [%s] has completed.\r", label)
|
||||
_ = caller.InjectAsOrchestrator([]byte(line))
|
||||
}()
|
||||
return id, nil
|
||||
|
||||
@@ -142,3 +142,37 @@ func isModifyOtherKeysCtrlK(params string) bool {
|
||||
}
|
||||
return parts[0] == "27" && parts[1] == "5" && parts[2] == "107"
|
||||
}
|
||||
|
||||
// matchCtrlChar reports whether chunk[i:] starts with Ctrl+<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)
|
||||
case "config_file":
|
||||
// SPEC §10 mentions merging into an external config file. We
|
||||
// expose the config_path via an env var the user can read
|
||||
// at preset-creation time; full merge is deferred.
|
||||
// Merge patterm's MCP entry into a vendored copy of the
|
||||
// user's existing config file, then point the child at the
|
||||
// vendored copy via the preset's home_var. The real config
|
||||
// file is never modified.
|
||||
envAssign, _, mErr := mcpConfigMerge(p, p.MCPInjection, identity, l.bin, l.mcpSocket)
|
||||
if mErr != nil {
|
||||
_ = os.Remove(mcpConfigPath)
|
||||
return nil, mErr
|
||||
}
|
||||
env = append(env, envAssign)
|
||||
env = append(env, "PATTERM_MCP_CONFIG="+mcpConfigPath)
|
||||
case "cli_override":
|
||||
// Inline -c key=value overrides for agents that accept
|
||||
// them (codex's `-c mcp_servers.patterm.command=...`). No
|
||||
// filesystem footprint, so the user's real config and auth
|
||||
// are untouched.
|
||||
extra, err := mcpCLIOverrideArgs(p, p.MCPInjection, identity, l.bin, l.mcpSocket)
|
||||
if err != nil {
|
||||
_ = os.Remove(mcpConfigPath)
|
||||
return nil, err
|
||||
}
|
||||
argv = append(argv, extra...)
|
||||
case "config_env":
|
||||
// Read the user's config, merge patterm in, and pass the
|
||||
// merged document inline via an env var (opencode's
|
||||
// OPENCODE_CONFIG_CONTENT). Nothing is written to disk and
|
||||
// XDG_CONFIG_HOME stays as the user set it.
|
||||
assignment, err := mcpConfigEnv(p, p.MCPInjection, identity, l.bin, l.mcpSocket)
|
||||
if err != nil {
|
||||
_ = os.Remove(mcpConfigPath)
|
||||
return nil, err
|
||||
}
|
||||
env = append(env, assignment)
|
||||
default:
|
||||
return nil, fmt.Errorf("preset %s: unknown mcp_injection.kind %q", p.Name, p.MCPInjection.Kind)
|
||||
}
|
||||
@@ -114,7 +143,10 @@ func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, par
|
||||
if initialPrompt == "" {
|
||||
return
|
||||
}
|
||||
_ = c.InjectAsOrchestrator([]byte(initialPrompt + "\n"))
|
||||
// InjectAsOrchestrator splits Enter onto its own PTY write so
|
||||
// claude / codex / opencode treat the CR as a key event
|
||||
// rather than the tail end of a multi-byte paste.
|
||||
_ = c.InjectAsOrchestrator([]byte(initialPrompt + "\r"))
|
||||
}()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -40,7 +40,11 @@ func newTerminalLayout(cols, rows uint16) terminalLayout {
|
||||
l.sidebarVisible = true
|
||||
l.sidebarWidth = sidebarCols
|
||||
l.sidebarLeft = cols - sidebarCols + 1
|
||||
l.mainCols = cols - sidebarCols
|
||||
// The sidebar's left border lives one column to the left of
|
||||
// sidebarLeft. The viewport must stop one column short of that
|
||||
// border or child output (and clearViewport ECH) would erase
|
||||
// it whenever the cursor reached the right margin.
|
||||
l.mainCols = cols - sidebarCols - 1
|
||||
}
|
||||
|
||||
reservedRows := tabBarRows + statusRows
|
||||
|
||||
@@ -11,13 +11,13 @@ func TestTerminalLayoutWideUsesMainViewport(t *testing.T) {
|
||||
if !l.sidebarVisible {
|
||||
t.Fatal("wide layout should show sidebar")
|
||||
}
|
||||
if l.childCols() != 92 {
|
||||
t.Fatalf("child cols: got %d want 92", l.childCols())
|
||||
if l.childCols() != 91 {
|
||||
t.Fatalf("child cols: got %d want 91", l.childCols())
|
||||
}
|
||||
if l.childRows() != 36 {
|
||||
t.Fatalf("child rows: got %d want 36", l.childRows())
|
||||
if l.childRows() != 37 {
|
||||
t.Fatalf("child rows: got %d want 37", l.childRows())
|
||||
}
|
||||
if l.mainTop != 4 || l.statusRow != 40 {
|
||||
if l.mainTop != 3 || l.statusRow != 40 {
|
||||
t.Fatalf("unexpected vertical chrome: mainTop=%d statusRow=%d", l.mainTop, l.statusRow)
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,8 @@ func TestTerminalLayoutNarrowHidesSidebar(t *testing.T) {
|
||||
if l.childCols() != 38 {
|
||||
t.Fatalf("child cols: got %d want 38", l.childCols())
|
||||
}
|
||||
if l.childRows() != 8 {
|
||||
t.Fatalf("child rows: got %d want 8", l.childRows())
|
||||
if l.childRows() != 9 {
|
||||
t.Fatalf("child rows: got %d want 9", l.childRows())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,13 +46,13 @@ func TestSpawnSizingUsesViewportDimensions(t *testing.T) {
|
||||
l := newTerminalLayout(120, 40)
|
||||
launcher := NewLauncher(nil, "", l.childCols(), l.childRows())
|
||||
cols, rows := launcher.size()
|
||||
if cols != 92 || rows != 36 {
|
||||
t.Fatalf("launcher size: got %dx%d want 92x36", cols, rows)
|
||||
if cols != 91 || rows != 37 {
|
||||
t.Fatalf("launcher size: got %dx%d want 91x37", cols, rows)
|
||||
}
|
||||
|
||||
host := newToolHost(nil, nil, nil, preset.Set{}, nil, l.childCols(), l.childRows())
|
||||
cols, rows = host.size()
|
||||
if cols != 92 || rows != 36 {
|
||||
t.Fatalf("tool host size: got %dx%d want 92x36", cols, rows)
|
||||
if cols != 91 || rows != 37 {
|
||||
t.Fatalf("tool host size: got %dx%d want 91x37", cols, rows)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
p := &paletteState{children: children, focused: focused, presets: presets}
|
||||
p.rebuild()
|
||||
@@ -47,6 +70,21 @@ func newPalette(children []*Child, focused string, presets preset.Set) *paletteS
|
||||
func (p *paletteState) rebuild() {
|
||||
all := p.allItems()
|
||||
q := strings.ToLower(string(p.query))
|
||||
macro, rest := detectMacro(q)
|
||||
if macro != "" {
|
||||
kinds := macroPrefixes[macro]
|
||||
filtered := all[:0:0]
|
||||
for _, it := range all {
|
||||
for _, k := range kinds {
|
||||
if it.action.kind == k {
|
||||
filtered = append(filtered, it)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
all = filtered
|
||||
q = rest
|
||||
}
|
||||
if q == "" {
|
||||
p.items = all
|
||||
} else {
|
||||
@@ -68,8 +106,32 @@ func (p *paletteState) rebuild() {
|
||||
func (p *paletteState) allItems() []paletteItem {
|
||||
var out []paletteItem
|
||||
|
||||
// Preset commands first — SPEC §4 calls these out as the primary
|
||||
// way to spawn anything. One entry per file under presets/.
|
||||
// Switch entries first — existing open agents/processes should
|
||||
// surface above options to spawn new ones. Hide non-running agents
|
||||
// (e.g. killed ones) so the list doesn't accumulate corpses. Command
|
||||
// processes are session-persistent, so they remain visible after
|
||||
// exit to keep restart_process in reach.
|
||||
for _, c := range p.children {
|
||||
if c.Kind == KindAgent && c.Status() != StatusRunning {
|
||||
continue
|
||||
}
|
||||
label := "Switch to " + c.Name
|
||||
hint := strings.Join(c.Argv, " ")
|
||||
if c.ID == p.focused {
|
||||
label = "• " + label + " (current)"
|
||||
}
|
||||
if c.Status() != StatusRunning {
|
||||
label = label + " [" + string(c.Status()) + "]"
|
||||
}
|
||||
out = append(out, paletteItem{
|
||||
label: label,
|
||||
hint: hint,
|
||||
action: paletteAction{kind: "switch", childID: c.ID},
|
||||
})
|
||||
}
|
||||
|
||||
// Preset commands — SPEC §4 calls these out as the primary way to
|
||||
// spawn anything. One entry per file under presets/.
|
||||
for _, pr := range p.presets.Agents {
|
||||
out = append(out, paletteItem{
|
||||
label: "Spawn agent: " + pr.Name,
|
||||
@@ -85,22 +147,7 @@ func (p *paletteState) allItems() []paletteItem {
|
||||
})
|
||||
}
|
||||
|
||||
// Switch / Kill entries — one per existing child.
|
||||
for _, c := range p.children {
|
||||
label := "Switch to " + c.Name
|
||||
hint := strings.Join(c.Argv, " ")
|
||||
if c.ID == p.focused {
|
||||
label = "• " + label + " (current)"
|
||||
}
|
||||
if c.Status() != StatusRunning {
|
||||
label = label + " [" + string(c.Status()) + "]"
|
||||
}
|
||||
out = append(out, paletteItem{
|
||||
label: label,
|
||||
hint: hint,
|
||||
action: paletteAction{kind: "switch", childID: c.ID},
|
||||
})
|
||||
}
|
||||
// Kill entries last among the action rows, before Quit.
|
||||
for _, c := range p.children {
|
||||
if c.Status() != StatusRunning {
|
||||
continue
|
||||
@@ -447,7 +494,7 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) {
|
||||
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
|
||||
row++
|
||||
|
||||
footer := "↵ run · esc close · ↑↓ navigate"
|
||||
footer := "↵ run · esc close · ↑↓ navigate · sw/k/sp <q> filter"
|
||||
fLen := utf8.RuneCountInString(footer)
|
||||
fPad := content - fLen
|
||||
if fPad < 0 {
|
||||
|
||||
@@ -152,8 +152,17 @@ func (st *uiState) drawSidebar() {
|
||||
write("")
|
||||
}
|
||||
|
||||
frame := b.String()
|
||||
st.chromeCacheMu.Lock()
|
||||
if frame == st.sidebarCache {
|
||||
st.chromeCacheMu.Unlock()
|
||||
return
|
||||
}
|
||||
st.sidebarCache = frame
|
||||
st.chromeCacheMu.Unlock()
|
||||
|
||||
st.outMu.Lock()
|
||||
// Save cursor; emit the sidebar; restore.
|
||||
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", b.String())
|
||||
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame)
|
||||
st.outMu.Unlock()
|
||||
}
|
||||
|
||||
@@ -7,15 +7,15 @@ import (
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Three-row tab bar: labels row, subtitle row, underline row. The PTY
|
||||
// viewport's top row is therefore mainTop == tabBarRows + 1.
|
||||
const tabBarRows = 3
|
||||
// Two-row tab bar: labels row, underline row. The PTY viewport's top
|
||||
// row is therefore mainTop == tabBarRows + 1.
|
||||
const tabBarRows = 2
|
||||
|
||||
// drawTabBar renders the top tab strip across the full host width. The
|
||||
// strip has three rows: labels (with horizontal padding), a dim
|
||||
// subtitle showing each child's argv, and an underline that's thick +
|
||||
// accent for the focused tab and faint for the rest. Subtitles are
|
||||
// truncated with `…` to the tab's width.
|
||||
// drawTabBar renders the top tab strip across the full host width.
|
||||
// Tabs share the available width with a flex layout — each visible
|
||||
// session gets roughly width/N cells, with the remainder distributed
|
||||
// to the leftmost tabs so the strip fills the screen edge-to-edge.
|
||||
// A trailing "+ new" hint sits in the rightmost reserved slot.
|
||||
func (st *uiState) drawTabBar() {
|
||||
st.mu.Lock()
|
||||
palOpen := st.palette != nil
|
||||
@@ -37,94 +37,123 @@ func (st *uiState) drawTabBar() {
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
newHint = "+ new"
|
||||
minTabWidth = 6 // enough for two pad cells + "x…" or similar
|
||||
)
|
||||
newHintW := utf8.RuneCountInString(newHint) + 2 // " + new " framing
|
||||
|
||||
type tabRect struct {
|
||||
startCol int
|
||||
width int
|
||||
label string
|
||||
subtitle string
|
||||
active bool
|
||||
}
|
||||
|
||||
const (
|
||||
leadingPad = 2 // host columns before the first tab
|
||||
tabPad = 2 // spaces on each side of the label inside the tab
|
||||
tabGap = 1 // gap columns between adjacent tabs
|
||||
tailReserve = 8 // reserve room for the trailing "+ new" hint
|
||||
)
|
||||
// Reserve space at the right edge for "+ new". If there are too
|
||||
// many tabs to fit even at minTabWidth, drop tabs from the right
|
||||
// until they do. The current focus stays visible.
|
||||
tabBudget := width - newHintW
|
||||
if tabBudget < minTabWidth {
|
||||
tabBudget = width
|
||||
newHintW = 0
|
||||
}
|
||||
|
||||
tabs := make([]tabRect, 0, len(sessions))
|
||||
cur := leadingPad + 1
|
||||
for _, c := range sessions {
|
||||
label := c.Name
|
||||
labelW := utf8.RuneCountInString(label)
|
||||
tabW := labelW + tabPad*2
|
||||
|
||||
// If the tab won't fit, try truncating the label down to whatever
|
||||
// space is left (label still has to leave room for "…").
|
||||
if cur+tabW+tabGap+tailReserve > width+1 {
|
||||
avail := width + 1 - cur - tabGap - tailReserve - tabPad*2
|
||||
if avail < 3 {
|
||||
break
|
||||
}
|
||||
label = clipRunes(label, avail-1) + "…"
|
||||
labelW = utf8.RuneCountInString(label)
|
||||
tabW = labelW + tabPad*2
|
||||
tabs = append(tabs, tabRect{
|
||||
startCol: cur, width: tabW,
|
||||
label: label, subtitle: strings.Join(c.Argv, " "),
|
||||
active: c.ID == focus,
|
||||
})
|
||||
cur += tabW + tabGap
|
||||
break
|
||||
visible := sessions
|
||||
if len(visible) > 0 {
|
||||
maxTabs := tabBudget / minTabWidth
|
||||
if maxTabs < 1 {
|
||||
maxTabs = 1
|
||||
}
|
||||
if len(visible) > maxTabs {
|
||||
// Keep the focused tab plus as many leftward tabs as fit.
|
||||
focusIdx := -1
|
||||
for i, c := range visible {
|
||||
if c.ID == focus {
|
||||
focusIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if focusIdx < 0 {
|
||||
focusIdx = 0
|
||||
}
|
||||
start := focusIdx - maxTabs + 1
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := start + maxTabs
|
||||
if end > len(visible) {
|
||||
end = len(visible)
|
||||
}
|
||||
visible = visible[start:end]
|
||||
}
|
||||
}
|
||||
|
||||
tabs = append(tabs, tabRect{
|
||||
startCol: cur, width: tabW,
|
||||
label: label, subtitle: strings.Join(c.Argv, " "),
|
||||
active: c.ID == focus,
|
||||
})
|
||||
cur += tabW + tabGap
|
||||
tabs := make([]tabRect, 0, len(visible))
|
||||
if n := len(visible); n > 0 {
|
||||
base := tabBudget / n
|
||||
extra := tabBudget - base*n
|
||||
col := 1
|
||||
for i, c := range visible {
|
||||
w := base
|
||||
if i < extra {
|
||||
w++
|
||||
}
|
||||
label := c.Name
|
||||
labelW := utf8.RuneCountInString(label)
|
||||
maxLabelW := w - 2 // one pad on each side
|
||||
if maxLabelW < 1 {
|
||||
maxLabelW = 1
|
||||
}
|
||||
if labelW > maxLabelW {
|
||||
if maxLabelW > 1 {
|
||||
label = clipRunes(label, maxLabelW-1) + "…"
|
||||
} else {
|
||||
label = clipRunes(label, maxLabelW)
|
||||
}
|
||||
labelW = utf8.RuneCountInString(label)
|
||||
}
|
||||
tabs = append(tabs, tabRect{
|
||||
startCol: col,
|
||||
width: w,
|
||||
label: label,
|
||||
active: c.ID == focus,
|
||||
})
|
||||
col += w
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
// Clear all three rows up front so a stale label from the previous
|
||||
// frame can't bleed through.
|
||||
// Clear both rows so a stale label from the previous frame can't
|
||||
// bleed through.
|
||||
b.WriteString("\x1b[1;1H\x1b[2K")
|
||||
b.WriteString("\x1b[2;1H\x1b[2K")
|
||||
b.WriteString("\x1b[3;1H\x1b[2K")
|
||||
|
||||
for _, t := range tabs {
|
||||
// Row 1: label
|
||||
// Row 1: centre-ish label inside the tab cell.
|
||||
labelW := utf8.RuneCountInString(t.label)
|
||||
leftPad := (t.width - labelW) / 2
|
||||
if leftPad < 1 {
|
||||
leftPad = 1
|
||||
}
|
||||
rightPad := t.width - labelW - leftPad
|
||||
if rightPad < 0 {
|
||||
rightPad = 0
|
||||
}
|
||||
fmt.Fprintf(&b, "\x1b[1;%dH", t.startCol)
|
||||
if t.active {
|
||||
b.WriteString(styleActive)
|
||||
} else {
|
||||
b.WriteString(styleHint)
|
||||
}
|
||||
b.WriteString(strings.Repeat(" ", tabPad))
|
||||
b.WriteString(strings.Repeat(" ", leftPad))
|
||||
b.WriteString(t.label)
|
||||
b.WriteString(strings.Repeat(" ", tabPad))
|
||||
b.WriteString(strings.Repeat(" ", rightPad))
|
||||
b.WriteString(styleReset)
|
||||
|
||||
// Row 2: subtitle, truncated to tab width and dimmed.
|
||||
sub := t.subtitle
|
||||
if utf8.RuneCountInString(sub) > t.width {
|
||||
if t.width > 1 {
|
||||
sub = clipRunes(sub, t.width-1) + "…"
|
||||
} else {
|
||||
sub = ""
|
||||
}
|
||||
}
|
||||
padR := t.width - utf8.RuneCountInString(sub)
|
||||
if padR < 0 {
|
||||
padR = 0
|
||||
}
|
||||
fmt.Fprintf(&b, "\x1b[2;%dH%s%s%s%s",
|
||||
t.startCol, styleDim, sub, strings.Repeat(" ", padR), styleReset)
|
||||
|
||||
// Row 3: underline. Thick accent for the active tab, faint
|
||||
// Row 2: underline. Thick accent for the active tab, faint
|
||||
// border for the rest.
|
||||
fmt.Fprintf(&b, "\x1b[3;%dH", t.startCol)
|
||||
fmt.Fprintf(&b, "\x1b[2;%dH", t.startCol)
|
||||
if t.active {
|
||||
b.WriteString(styleAccent)
|
||||
b.WriteString(strings.Repeat("━", t.width))
|
||||
@@ -135,26 +164,26 @@ func (st *uiState) drawTabBar() {
|
||||
b.WriteString(styleReset)
|
||||
}
|
||||
|
||||
// "+ new" hint at the end of the labels row, in dim.
|
||||
if cur+3 <= width {
|
||||
fmt.Fprintf(&b, "\x1b[1;%dH%s+ new%s", cur+1, styleDim, styleReset)
|
||||
// "+ new" hint right-aligned in the reserved slot.
|
||||
if newHintW > 0 {
|
||||
hintCol := width - newHintW + 1
|
||||
fmt.Fprintf(&b, "\x1b[1;%dH %s%s%s ", hintCol, styleDim, newHint, styleReset)
|
||||
// Underline continues faintly under the hint so the strip
|
||||
// reads as one bar.
|
||||
fmt.Fprintf(&b, "\x1b[2;%dH%s%s%s",
|
||||
hintCol, styleBorder, strings.Repeat("─", newHintW), styleReset)
|
||||
}
|
||||
|
||||
// Extend the faint underline across the rest of the host width so
|
||||
// the tab strip reads as one continuous divider.
|
||||
if cur <= width {
|
||||
remain := width - cur + 1
|
||||
if remain > 0 {
|
||||
fmt.Fprintf(&b, "\x1b[3;%dH%s%s%s",
|
||||
cur, styleBorder, strings.Repeat("─", remain), styleReset)
|
||||
}
|
||||
}
|
||||
if leadingPad > 0 {
|
||||
fmt.Fprintf(&b, "\x1b[3;1H%s%s%s",
|
||||
styleBorder, strings.Repeat("─", leadingPad), styleReset)
|
||||
frame := b.String()
|
||||
st.chromeCacheMu.Lock()
|
||||
if frame == st.tabBarCache {
|
||||
st.chromeCacheMu.Unlock()
|
||||
return
|
||||
}
|
||||
st.tabBarCache = frame
|
||||
st.chromeCacheMu.Unlock()
|
||||
|
||||
st.outMu.Lock()
|
||||
defer st.outMu.Unlock()
|
||||
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", b.String())
|
||||
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame)
|
||||
}
|
||||
|
||||
@@ -57,3 +57,96 @@ func firstRunningTopLevel(children []*Child) *Child {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runningTopLevels lists every running top-level session in the order
|
||||
// they appear in the snapshot — the same order the tab bar uses, so
|
||||
// Ctrl+A/D navigation matches what the user sees on screen.
|
||||
func runningTopLevels(children []*Child) []*Child {
|
||||
out := make([]*Child, 0, len(children))
|
||||
for _, c := range children {
|
||||
if c.ParentID == "" && c.Status() == StatusRunning {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nextTabID returns the id of the top-level session `step` positions
|
||||
// away from the current focus in the runningTopLevels list, wrapping
|
||||
// at both ends. Returns "" when there's nothing to switch to.
|
||||
func nextTabID(children []*Child, focusID string, step int) string {
|
||||
roots := runningTopLevels(children)
|
||||
if len(roots) == 0 {
|
||||
return ""
|
||||
}
|
||||
rootID := activeRootID(children, focusID)
|
||||
idx := -1
|
||||
for i, r := range roots {
|
||||
if r.ID == rootID {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
idx = (idx + step) % len(roots)
|
||||
if idx < 0 {
|
||||
idx += len(roots)
|
||||
}
|
||||
if roots[idx].ID == focusID {
|
||||
return ""
|
||||
}
|
||||
return roots[idx].ID
|
||||
}
|
||||
|
||||
// currentTabFlat returns the focused tab's processes (root first, then
|
||||
// its running children) in display order. Used to step focus with
|
||||
// Ctrl+W/S.
|
||||
func currentTabFlat(children []*Child, focusID string) []*Child {
|
||||
rootID := activeRootID(children, focusID)
|
||||
if rootID == "" {
|
||||
return nil
|
||||
}
|
||||
out := make([]*Child, 0, 4)
|
||||
for _, c := range children {
|
||||
if c.ID == rootID && c.Status() == StatusRunning {
|
||||
out = append(out, c)
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, c := range children {
|
||||
if c.ParentID == rootID && c.Status() == StatusRunning {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nextChildID returns the process id `step` positions away from the
|
||||
// current focus inside its tab, wrapping at both ends. Empty when
|
||||
// there's only one process in the tab.
|
||||
func nextChildID(children []*Child, focusID string, step int) string {
|
||||
flat := currentTabFlat(children, focusID)
|
||||
if len(flat) < 2 {
|
||||
return ""
|
||||
}
|
||||
idx := -1
|
||||
for i, c := range flat {
|
||||
if c.ID == focusID {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
idx = (idx + step) % len(flat)
|
||||
if idx < 0 {
|
||||
idx += len(flat)
|
||||
}
|
||||
if flat[idx].ID == focusID {
|
||||
return ""
|
||||
}
|
||||
return flat[idx].ID
|
||||
}
|
||||
|
||||
@@ -39,3 +39,61 @@ func childIDs(cs []*Child) []string {
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestNextTabIDWrapsAndSkipsCurrent(t *testing.T) {
|
||||
r1 := testChild("c1", "root1", "", StatusRunning)
|
||||
r2 := testChild("c2", "root2", "", StatusRunning)
|
||||
r3 := testChild("c3", "root3", "", StatusRunning)
|
||||
children := []*Child{r1, r2, r3}
|
||||
|
||||
if got := nextTabID(children, "c1", +1); got != "c2" {
|
||||
t.Fatalf("next from c1: %q", got)
|
||||
}
|
||||
if got := nextTabID(children, "c1", -1); got != "c3" {
|
||||
t.Fatalf("prev from c1: %q", got)
|
||||
}
|
||||
if got := nextTabID(children, "c3", +1); got != "c1" {
|
||||
t.Fatalf("wrap forward from c3: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextTabIDFromSubAgentJumpsByRoot(t *testing.T) {
|
||||
r1 := testChild("c1", "root1", "", StatusRunning)
|
||||
r1Child := testChild("c2", "child1", "c1", StatusRunning)
|
||||
r2 := testChild("c3", "root2", "", StatusRunning)
|
||||
children := []*Child{r1, r1Child, r2}
|
||||
|
||||
// Focus is on a sub-agent of root1; Ctrl+D should jump to root2,
|
||||
// not stay inside root1's sub-tree.
|
||||
if got := nextTabID(children, "c2", +1); got != "c3" {
|
||||
t.Fatalf("next from sub-agent: %q want c3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextChildIDCyclesWithinTab(t *testing.T) {
|
||||
r1 := testChild("c1", "root1", "", StatusRunning)
|
||||
a := testChild("c2", "a", "c1", StatusRunning)
|
||||
b := testChild("c3", "b", "c1", StatusRunning)
|
||||
other := testChild("c4", "other-root", "", StatusRunning)
|
||||
children := []*Child{r1, a, b, other}
|
||||
|
||||
if got := nextChildID(children, "c1", +1); got != "c2" {
|
||||
t.Fatalf("root->first child: %q", got)
|
||||
}
|
||||
if got := nextChildID(children, "c2", +1); got != "c3" {
|
||||
t.Fatalf("a->b: %q", got)
|
||||
}
|
||||
if got := nextChildID(children, "c3", +1); got != "c1" {
|
||||
t.Fatalf("wrap b->root: %q", got)
|
||||
}
|
||||
if got := nextChildID(children, "c1", -1); got != "c3" {
|
||||
t.Fatalf("wrap backward root->b: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextChildIDNoopWhenOnlyOneProcess(t *testing.T) {
|
||||
r := testChild("c1", "solo", "", StatusRunning)
|
||||
if got := nextChildID([]*Child{r}, "c1", +1); got != "" {
|
||||
t.Fatalf("expected empty when only one process in tab, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +161,10 @@ func (vr *viewportRenderer) emitCSI() {
|
||||
return
|
||||
}
|
||||
switch n {
|
||||
case 0:
|
||||
vr.pending.WriteString(vr.clearViewportFromCursor())
|
||||
case 1:
|
||||
vr.pending.WriteString(vr.clearViewportToCursor())
|
||||
case 2, 3:
|
||||
vr.pending.WriteString(vr.clearViewport())
|
||||
default:
|
||||
@@ -203,6 +207,54 @@ func (vr *viewportRenderer) clearViewport() string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// clearViewportFromCursor implements `CSI 0 J` clamped to the viewport.
|
||||
// Without clamping, the child's "clear to end of screen" would reach the
|
||||
// rightmost columns and erase the sidebar.
|
||||
func (vr *viewportRenderer) clearViewportFromCursor() string {
|
||||
row, col := vr.row, vr.col
|
||||
cols := int(vr.layout.childCols())
|
||||
rows := int(vr.layout.childRows())
|
||||
if row < 1 {
|
||||
row = 1
|
||||
}
|
||||
if col < 1 {
|
||||
col = 1
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("\x1b7")
|
||||
if remaining := cols - col + 1; remaining > 0 {
|
||||
fmt.Fprintf(&b, "\x1b[%dX", remaining)
|
||||
}
|
||||
for r := row + 1; r <= rows; r++ {
|
||||
fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX",
|
||||
int(vr.layout.mainTop)+r-1, int(vr.layout.mainLeft), cols)
|
||||
}
|
||||
b.WriteString("\x1b8")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// clearViewportToCursor implements `CSI 1 J` clamped to the viewport.
|
||||
func (vr *viewportRenderer) clearViewportToCursor() string {
|
||||
row, col := vr.row, vr.col
|
||||
cols := int(vr.layout.childCols())
|
||||
if row < 1 {
|
||||
row = 1
|
||||
}
|
||||
if col < 1 {
|
||||
col = 1
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("\x1b7")
|
||||
for r := 1; r < row; r++ {
|
||||
fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX",
|
||||
int(vr.layout.mainTop)+r-1, int(vr.layout.mainLeft), cols)
|
||||
}
|
||||
fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX",
|
||||
int(vr.layout.mainTop)+row-1, int(vr.layout.mainLeft), col)
|
||||
b.WriteString("\x1b8")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (vr *viewportRenderer) clearLine(n int) string {
|
||||
right := int(vr.layout.childCols())
|
||||
if vr.col < 1 {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
func TestViewportRendererShiftsCursor(t *testing.T) {
|
||||
vr := newViewportRenderer(newTerminalLayout(120, 40))
|
||||
got := string(vr.Render([]byte("\x1b[H")))
|
||||
if got != "\x1b[4;1H" {
|
||||
if got != "\x1b[3;1H" {
|
||||
t.Fatalf("CUP home: got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -22,17 +22,17 @@ func TestViewportRendererSwallowsAltScreenToggles(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestViewportRendererClearScreenIsViewportOnly(t *testing.T) {
|
||||
// hostRows=7 leaves three viewport rows after the 3-row tab bar and
|
||||
// hostRows=7 leaves four viewport rows after the 2-row tab bar and
|
||||
// 1-row status reservation.
|
||||
vr := newViewportRenderer(newTerminalLayout(20, 7))
|
||||
got := string(vr.Render([]byte("\x1b[2J")))
|
||||
if strings.Contains(got, "\x1b[2J") {
|
||||
t.Fatalf("host clear-screen leaked through: %q", got)
|
||||
}
|
||||
if strings.Count(got, "\x1b[20X") != 3 {
|
||||
if strings.Count(got, "\x1b[20X") != 4 {
|
||||
t.Fatalf("clear rows: got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "\x1b[4;1H") || !strings.Contains(got, "\x1b[6;1H") {
|
||||
if !strings.Contains(got, "\x1b[3;1H") || !strings.Contains(got, "\x1b[6;1H") {
|
||||
t.Fatalf("clear did not target viewport rows: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,45 @@ func TestViewportRendererClearLineStopsAtViewportRight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewportRendererClearToEndIsViewportOnly(t *testing.T) {
|
||||
// Reproduces the sidebar-wipe bug: claude's Ctrl+O expansion emits
|
||||
// `CSI 0 J` (clear from cursor to end of screen). Forwarded verbatim,
|
||||
// it would erase every host column to the right of the cursor —
|
||||
// including the sidebar — because the cursor is at host coordinates
|
||||
// but the J sequence isn't constrained to the viewport.
|
||||
vr := newViewportRenderer(newTerminalLayout(40, 7))
|
||||
got := string(vr.Render([]byte("\x1b[H\x1b[0J")))
|
||||
if strings.Contains(got, "\x1b[0J") || strings.Contains(got, "\x1b[J") {
|
||||
t.Fatalf("host clear-to-end leaked through: %q", got)
|
||||
}
|
||||
// childCols == 19 (40 cols - 28 sidebar - 1 gap - 0-index fudge).
|
||||
// Each of the 4 viewport rows should get a 19-cell erase.
|
||||
// childCols == 11 with hostCols=40 (28 sidebar + 1 gap reserved).
|
||||
// 4 viewport rows, but the cursor row uses ECH at cursor (col 1),
|
||||
// so we expect 4 erases of 11 cells each.
|
||||
count := strings.Count(got, "\x1b[11X")
|
||||
if count != 4 {
|
||||
t.Fatalf("expected 4 ECH-11 sequences, got %d in %q", count, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewportRendererClearToStartIsViewportOnly(t *testing.T) {
|
||||
vr := newViewportRenderer(newTerminalLayout(40, 7))
|
||||
// Park the cursor mid-viewport, then issue `CSI 1 J`.
|
||||
got := string(vr.Render([]byte("\x1b[3;5H\x1b[1J")))
|
||||
if strings.Contains(got, "\x1b[1J") {
|
||||
t.Fatalf("host clear-to-start leaked through: %q", got)
|
||||
}
|
||||
// Two full rows above (childCols-wide erase, 11 cells each) plus a
|
||||
// 5-cell erase on the cursor row.
|
||||
if !strings.Contains(got, "\x1b[11X") {
|
||||
t.Fatalf("expected viewport-wide ECH for rows above cursor: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "\x1b[5X") {
|
||||
t.Fatalf("expected 5-cell ECH on cursor row: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewportRendererTracksPrintableCursor(t *testing.T) {
|
||||
vr := newViewportRenderer(newTerminalLayout(20, 5))
|
||||
got := string(vr.Render([]byte("hello\x1b[K")))
|
||||
|
||||
Reference in New Issue
Block a user