Files
patterm/internal/app/palette.go
Harry Bayliss 3622c41fd0 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.
2026-05-14 19:09:35 +01:00

583 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}