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.
583 lines
14 KiB
Go
583 lines
14 KiB
Go
package app
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"github.com/hjbdev/patterm/internal/preset"
|
||
)
|
||
|
||
// paletteAction is what the palette returns when the user picks an item.
|
||
type paletteAction struct {
|
||
// kind: "spawn-agent" | "spawn-process" | "switch" | "kill" | "quit" | "cancel"
|
||
kind string
|
||
|
||
// For spawn-*, the preset to launch.
|
||
preset *preset.Preset
|
||
|
||
// For "switch" and "kill", the target child id.
|
||
childID string
|
||
}
|
||
|
||
type paletteItem struct {
|
||
label string
|
||
hint string
|
||
action paletteAction
|
||
}
|
||
|
||
// paletteState is the in-memory model for the overlay. SPEC §4: a
|
||
// single fuzzy-searchable list of commands scoped to the current focus.
|
||
type paletteState struct {
|
||
query []rune
|
||
cursor int
|
||
children []*Child
|
||
focused string
|
||
presets preset.Set
|
||
|
||
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()
|
||
return p
|
||
}
|
||
|
||
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 {
|
||
p.items = p.items[:0]
|
||
for _, it := range all {
|
||
if fuzzyMatch(strings.ToLower(it.label+" "+it.hint), q) {
|
||
p.items = append(p.items, it)
|
||
}
|
||
}
|
||
}
|
||
if p.cursor >= len(p.items) {
|
||
p.cursor = len(p.items) - 1
|
||
}
|
||
if p.cursor < 0 {
|
||
p.cursor = 0
|
||
}
|
||
}
|
||
|
||
func (p *paletteState) allItems() []paletteItem {
|
||
var out []paletteItem
|
||
|
||
// 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,
|
||
hint: strings.Join(pr.Argv, " "),
|
||
action: paletteAction{kind: "spawn-agent", preset: pr},
|
||
})
|
||
}
|
||
for _, pr := range p.presets.Processes {
|
||
out = append(out, paletteItem{
|
||
label: "Run process: " + pr.Name,
|
||
hint: strings.Join(pr.Argv, " "),
|
||
action: paletteAction{kind: "spawn-process", preset: pr},
|
||
})
|
||
}
|
||
|
||
// Kill entries last among the action rows, before Quit.
|
||
for _, c := range p.children {
|
||
if c.Status() != StatusRunning {
|
||
continue
|
||
}
|
||
out = append(out, paletteItem{
|
||
label: "Kill " + c.Name,
|
||
hint: "SIGTERM " + strings.Join(c.Argv, " "),
|
||
action: paletteAction{kind: "kill", childID: c.ID},
|
||
})
|
||
}
|
||
|
||
out = append(out, paletteItem{
|
||
label: "Quit",
|
||
hint: "exit patterm; SIGTERM every child",
|
||
action: paletteAction{kind: "quit"},
|
||
})
|
||
return out
|
||
}
|
||
|
||
func fuzzyMatch(hay, needle string) bool {
|
||
if needle == "" {
|
||
return true
|
||
}
|
||
hi := 0
|
||
for _, r := range needle {
|
||
idx := strings.IndexRune(hay[hi:], r)
|
||
if idx < 0 {
|
||
return false
|
||
}
|
||
hi += idx + utf8.RuneLen(r)
|
||
}
|
||
return true
|
||
}
|
||
|
||
// kitty functional keycodes for arrows.
|
||
const (
|
||
kittyKeyUp = 57352
|
||
kittyKeyDown = 57353
|
||
)
|
||
|
||
// peekArrowEvent classifies the CSI sequence at chunk[i:] as Up ('U'),
|
||
// Down ('D'), or none (0) and returns the byte length of that sequence.
|
||
// Used by the palette input loop to suppress duplicate adjacent
|
||
// arrow events some terminals emit for a single physical keypress
|
||
// (either two legacy `CSI B` in a row, or a legacy + kitty pair).
|
||
func peekArrowEvent(chunk []byte, i int) (nav byte, advance int) {
|
||
if i >= len(chunk) || chunk[i] != 0x1b {
|
||
return 0, 0
|
||
}
|
||
n := csiLen(chunk, i)
|
||
if n == 0 {
|
||
return 0, 0
|
||
}
|
||
final := chunk[i+n-1]
|
||
switch final {
|
||
case 'A':
|
||
return 'U', n
|
||
case 'B':
|
||
return 'D', n
|
||
case 'u':
|
||
k, ok := decodeCSIu(string(chunk[i+2 : i+n-1]))
|
||
if !ok || k.event != 1 {
|
||
return 0, n
|
||
}
|
||
switch k.key {
|
||
case kittyKeyUp:
|
||
return 'U', n
|
||
case kittyKeyDown:
|
||
return 'D', n
|
||
}
|
||
}
|
||
return 0, 0
|
||
}
|
||
|
||
// handleInput consumes one keystroke from chunk[i:] and updates palette
|
||
// state. advance is how many bytes the keystroke occupies (1 for legacy
|
||
// keys, longer for CSI sequences). Returning done=true tells the caller
|
||
// the palette is finished and action describes what to do next.
|
||
//
|
||
// Recognised input includes both legacy byte forms and the kitty
|
||
// keyboard CSI u encoding that codex/ratatui pushes onto the terminal.
|
||
// Unknown CSI sequences (including release events from kitty flag 2)
|
||
// are consumed silently so they don't fall through to the ESC branch
|
||
// and accidentally cancel the palette.
|
||
func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, done bool, advance int) {
|
||
b := chunk[i]
|
||
if b == 0x1b {
|
||
if n := csiLen(chunk, i); n > 0 {
|
||
return p.handleCSI(chunk[i+2:i+n-1], chunk[i+n-1], n)
|
||
}
|
||
// Bare ESC (no CSI follow-up): cancel.
|
||
return paletteAction{kind: "cancel"}, true, 1
|
||
}
|
||
switch b {
|
||
case '\r', '\n':
|
||
return p.accept(), true, 1
|
||
case 0x7f, 0x08:
|
||
p.backspace()
|
||
case 0x15: // Ctrl-U
|
||
p.clearQuery()
|
||
case 0x0e: // Ctrl-N
|
||
p.cursorDown()
|
||
case 0x10: // Ctrl-P
|
||
p.cursorUp()
|
||
case 0x0b: // Ctrl-K inside palette is a no-op (would re-open); ignore.
|
||
case 0x16: // Ctrl-V literal-paste — ignore in palette.
|
||
default:
|
||
if b >= 0x20 && b < 0x7f {
|
||
p.query = append(p.query, rune(b))
|
||
p.rebuild()
|
||
}
|
||
}
|
||
return paletteAction{}, false, 1
|
||
}
|
||
|
||
func (p *paletteState) handleCSI(params []byte, final byte, n int) (paletteAction, bool, int) {
|
||
switch final {
|
||
case 'A':
|
||
p.cursorUp()
|
||
return paletteAction{}, false, n
|
||
case 'B':
|
||
p.cursorDown()
|
||
return paletteAction{}, false, n
|
||
case 'u':
|
||
k, ok := decodeCSIu(string(params))
|
||
if !ok || k.event != 1 {
|
||
// Repeat / release events, or malformed: ignore.
|
||
return paletteAction{}, false, n
|
||
}
|
||
switch k.key {
|
||
case 13: // Enter
|
||
return p.accept(), true, n
|
||
case 27: // Escape
|
||
return paletteAction{kind: "cancel"}, true, n
|
||
case 127, 8: // Backspace
|
||
p.backspace()
|
||
case kittyKeyUp:
|
||
p.cursorUp()
|
||
case kittyKeyDown:
|
||
p.cursorDown()
|
||
default:
|
||
// Ctrl-modified character keys.
|
||
if k.mods == 5 {
|
||
switch k.key {
|
||
case 'u':
|
||
p.clearQuery()
|
||
case 'n':
|
||
p.cursorDown()
|
||
case 'p':
|
||
p.cursorUp()
|
||
}
|
||
return paletteAction{}, false, n
|
||
}
|
||
// Unmodified printable ASCII typed via CSI u (flag 8): treat
|
||
// as a query keystroke.
|
||
if k.mods == 1 && k.key >= 0x20 && k.key < 0x7f {
|
||
p.query = append(p.query, rune(k.key))
|
||
p.rebuild()
|
||
}
|
||
}
|
||
return paletteAction{}, false, n
|
||
}
|
||
// Anything else (~, function keys, etc.): consume silently.
|
||
return paletteAction{}, false, n
|
||
}
|
||
|
||
func (p *paletteState) accept() paletteAction {
|
||
if p.cursor >= 0 && p.cursor < len(p.items) {
|
||
return p.items[p.cursor].action
|
||
}
|
||
return paletteAction{kind: "cancel"}
|
||
}
|
||
|
||
func (p *paletteState) backspace() {
|
||
if len(p.query) > 0 {
|
||
p.query = p.query[:len(p.query)-1]
|
||
p.rebuild()
|
||
}
|
||
}
|
||
|
||
func (p *paletteState) clearQuery() {
|
||
p.query = p.query[:0]
|
||
p.rebuild()
|
||
}
|
||
|
||
func (p *paletteState) cursorUp() {
|
||
p.cursor--
|
||
if p.cursor < 0 {
|
||
p.cursor = 0
|
||
}
|
||
}
|
||
|
||
func (p *paletteState) cursorDown() {
|
||
p.cursor++
|
||
if p.cursor >= len(p.items) {
|
||
p.cursor = len(p.items) - 1
|
||
}
|
||
}
|
||
|
||
// render draws the palette onto out. Layout is a rounded box with a
|
||
// title bar, query line, divider, item list, divider, and footer.
|
||
// The caller is responsible for the screen clear before the first
|
||
// render.
|
||
func (p *paletteState) render(out writeFlusher, cols, rows int) {
|
||
if cols < 32 {
|
||
cols = 32
|
||
}
|
||
if rows < 10 {
|
||
rows = 10
|
||
}
|
||
width := cols - 8
|
||
if width > 72 {
|
||
width = 72
|
||
}
|
||
if width < 40 {
|
||
width = cols - 2
|
||
}
|
||
if width < 32 {
|
||
width = 32
|
||
}
|
||
leftPad := (cols - width) / 2
|
||
if leftPad < 1 {
|
||
leftPad = 1
|
||
}
|
||
content := width - 4 // visible cells between the " " padding on each side
|
||
|
||
var b strings.Builder
|
||
b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J")
|
||
|
||
row := 2
|
||
titleText := "patterm"
|
||
keyHint := "Ctrl-K"
|
||
// ╭─ patterm ─...─ Ctrl-K ─╮ uses: 3 + len(title) + 1 + dashes + 1 + len(hint) + 3
|
||
dashes := width - 3 - len(titleText) - 1 - 1 - len(keyHint) - 3
|
||
if dashes < 2 {
|
||
dashes = 2
|
||
}
|
||
moveTo(&b, row, leftPad)
|
||
b.WriteString(styleBorder + "╭─ " + styleActive + titleText + styleReset + styleBorder + " " +
|
||
strings.Repeat("─", dashes) + " " + styleHint + keyHint + styleReset + styleBorder + " ─╮" + styleReset)
|
||
row++
|
||
|
||
queryStr := string(p.query)
|
||
queryRow := row
|
||
qLen := utf8.RuneCountInString(queryStr)
|
||
qPad := content - 2 - qLen
|
||
if qPad < 0 {
|
||
qPad = 0
|
||
}
|
||
moveTo(&b, row, leftPad)
|
||
b.WriteString(styleBorder + "│" + styleReset + " " + styleAccent + "❯" + styleReset + " " + queryStr +
|
||
strings.Repeat(" ", qPad) + " " + styleBorder + "│" + styleReset)
|
||
row++
|
||
|
||
moveTo(&b, row, leftPad)
|
||
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
|
||
row++
|
||
|
||
maxItems := rows - 6
|
||
if maxItems > 12 {
|
||
maxItems = 12
|
||
}
|
||
if maxItems < 1 {
|
||
maxItems = 1
|
||
}
|
||
start := 0
|
||
if p.cursor >= maxItems {
|
||
start = p.cursor - maxItems + 1
|
||
}
|
||
end := start + maxItems
|
||
if end > len(p.items) {
|
||
end = len(p.items)
|
||
}
|
||
|
||
for i := 0; i < maxItems; i++ {
|
||
moveTo(&b, row, leftPad)
|
||
if start+i >= end {
|
||
if len(p.items) == 0 && i == 0 {
|
||
msg := styleDim + "no matches" + styleReset
|
||
pad := content - 2 - 10
|
||
if pad < 0 {
|
||
pad = 0
|
||
}
|
||
b.WriteString(styleBorder + "│" + styleReset + " " + msg +
|
||
strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset)
|
||
} else {
|
||
b.WriteString(styleBorder + "│" + styleReset + strings.Repeat(" ", width-2) +
|
||
styleBorder + "│" + styleReset)
|
||
}
|
||
row++
|
||
continue
|
||
}
|
||
|
||
it := p.items[start+i]
|
||
isSel := (start + i) == p.cursor
|
||
avail := content - 2 // 2 cells reserved for the selection indicator
|
||
|
||
label := it.label
|
||
hint := it.hint
|
||
labelLen := utf8.RuneCountInString(label)
|
||
hintLen := utf8.RuneCountInString(hint)
|
||
|
||
if labelLen > avail {
|
||
label = clipRunes(label, avail-1) + "…"
|
||
labelLen = utf8.RuneCountInString(label)
|
||
hint = ""
|
||
hintLen = 0
|
||
} else if hintLen > 0 {
|
||
gap := avail - labelLen - hintLen
|
||
if gap < 3 {
|
||
budget := avail - labelLen - 3
|
||
if budget > 1 {
|
||
hint = clipRunes(hint, budget-1) + "…"
|
||
hintLen = utf8.RuneCountInString(hint)
|
||
} else {
|
||
hint = ""
|
||
hintLen = 0
|
||
}
|
||
}
|
||
}
|
||
gap := avail - labelLen - hintLen
|
||
if gap < 0 {
|
||
gap = 0
|
||
}
|
||
|
||
var indicator, labelStr, hintStr string
|
||
if isSel {
|
||
indicator = styleAccent + "▎" + styleReset + " "
|
||
labelStr = styleBold + label + styleReset
|
||
} else {
|
||
indicator = " "
|
||
labelStr = label
|
||
}
|
||
if hint != "" {
|
||
hintStr = styleHint + hint + styleReset
|
||
}
|
||
|
||
b.WriteString(styleBorder + "│" + styleReset + " " + indicator + labelStr +
|
||
strings.Repeat(" ", gap) + hintStr + " " + styleBorder + "│" + styleReset)
|
||
row++
|
||
}
|
||
|
||
moveTo(&b, row, leftPad)
|
||
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
|
||
row++
|
||
|
||
footer := "↵ run · esc close · ↑↓ navigate · sw/k/sp <q> filter"
|
||
fLen := utf8.RuneCountInString(footer)
|
||
fPad := content - fLen
|
||
if fPad < 0 {
|
||
fPad = 0
|
||
}
|
||
moveTo(&b, row, leftPad)
|
||
b.WriteString(styleBorder + "│" + styleReset + " " + styleHint + footer + styleReset +
|
||
strings.Repeat(" ", fPad) + " " + styleBorder + "│" + styleReset)
|
||
row++
|
||
|
||
moveTo(&b, row, leftPad)
|
||
b.WriteString(styleBorder + "╰" + strings.Repeat("─", width-2) + "╯" + styleReset)
|
||
|
||
// Park the real terminal cursor at the end of the query so it
|
||
// blinks naturally in place of the old underscore stub.
|
||
moveTo(&b, queryRow, leftPad+4+qLen)
|
||
b.WriteString("\x1b[?25h")
|
||
|
||
_, _ = out.Write([]byte(b.String()))
|
||
_ = out.Flush()
|
||
}
|
||
|
||
func clipRunes(s string, n int) string {
|
||
if n <= 0 {
|
||
return ""
|
||
}
|
||
count := 0
|
||
for i := range s {
|
||
if count == n {
|
||
return s[:i]
|
||
}
|
||
count++
|
||
}
|
||
return s
|
||
}
|
||
|
||
type writeFlusher interface {
|
||
Write(p []byte) (int, error)
|
||
Flush() error
|
||
}
|
||
|
||
type writeFlusherBase interface {
|
||
Write(p []byte) (int, error)
|
||
}
|
||
|
||
type nopFlusher struct{ io writeFlusherBase }
|
||
|
||
func wrapWriter(w writeFlusherBase) writeFlusher { return nopFlusher{io: w} }
|
||
func (n nopFlusher) Write(p []byte) (int, error) { return n.io.Write(p) }
|
||
func (n nopFlusher) Flush() error { return nil }
|
||
|
||
func moveTo(b *strings.Builder, row, col int) {
|
||
fmt.Fprintf(b, "\x1b[%d;%dH", row, col)
|
||
}
|
||
|
||
func padRight(s string, width int) string {
|
||
w := width - visibleLen(s)
|
||
if w <= 0 {
|
||
return s
|
||
}
|
||
return s + strings.Repeat(" ", w)
|
||
}
|
||
|
||
func visibleLen(s string) int {
|
||
n := 0
|
||
in := false
|
||
for _, r := range s {
|
||
if r == 0x1b {
|
||
in = true
|
||
continue
|
||
}
|
||
if in {
|
||
if r == 'm' || r == 'H' {
|
||
in = false
|
||
}
|
||
continue
|
||
}
|
||
n++
|
||
}
|
||
return n
|
||
}
|
||
|
||
func countAnsi(s string) int {
|
||
return len(s) - visibleLen(s)
|
||
}
|