package app import ( "fmt" "strings" "unicode/utf8" "github.com/harrybrwn/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 } 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)) 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 // Preset commands first — 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}, }) } // 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}, }) } 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 } func (p *paletteState) handleKey(b byte, peek []byte) (paletteAction, bool) { if b == 0x1b { // Pure Esc cancels; Esc [ A/B is up/down arrow. if len(peek) >= 2 && peek[0] == '[' { switch peek[1] { case 'A': p.cursor-- if p.cursor < 0 { p.cursor = 0 } return paletteAction{}, false case 'B': p.cursor++ if p.cursor >= len(p.items) { p.cursor = len(p.items) - 1 } return paletteAction{}, false } } return paletteAction{kind: "cancel"}, true } switch b { case '\r', '\n': if p.cursor >= 0 && p.cursor < len(p.items) { return p.items[p.cursor].action, true } return paletteAction{kind: "cancel"}, true case 0x7f, 0x08: if len(p.query) > 0 { p.query = p.query[:len(p.query)-1] p.rebuild() } case 0x15: // Ctrl-U p.query = p.query[:0] p.rebuild() case 0x0e: // Ctrl-N p.cursor++ if p.cursor >= len(p.items) { p.cursor = len(p.items) - 1 } case 0x10: // Ctrl-P inside palette: cursor up. p.cursor-- if p.cursor < 0 { p.cursor = 0 } 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 } // render draws the palette onto out. Geometry: title bar + filter line + // items + footer, centred. The caller is responsible for the screen // clear before the first render. func (p *paletteState) render(out writeFlusher, cols, rows int) { if cols < 20 { cols = 20 } if rows < 6 { rows = 6 } width := cols - 4 if width > 80 { width = 80 } if width < 40 { width = cols - 2 } leftPad := (cols - width) / 2 if leftPad < 1 { leftPad = 1 } row := 2 var b strings.Builder b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J") moveTo(&b, row, leftPad) b.WriteString("\x1b[1;7m") b.WriteString(padRight(" patterm — Ctrl-K", width)) b.WriteString("\x1b[0m") row++ moveTo(&b, row, leftPad) b.WriteString("\x1b[7m") b.WriteString(padRight(" › "+string(p.query)+"_", width)) b.WriteString("\x1b[0m") 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 := start; i < end; i++ { it := p.items[i] moveTo(&b, row, leftPad) if i == p.cursor { b.WriteString("\x1b[7m") } else { b.WriteString("\x1b[0m") } line := " " + it.label if it.hint != "" { line += " \x1b[2m— " + it.hint + "\x1b[0m" if i == p.cursor { line += "\x1b[7m" } } b.WriteString(padRight(line, width+countAnsi(line))) b.WriteString("\x1b[0m") row++ } if len(p.items) == 0 { moveTo(&b, row, leftPad) b.WriteString("\x1b[2m no matches\x1b[0m") row++ } moveTo(&b, row, leftPad) b.WriteString("\x1b[2m") b.WriteString(padRight(" Enter to run · Esc to close · ↑↓ to navigate", width)) b.WriteString("\x1b[0m") moveTo(&b, 3, leftPad+4+utf8.RuneCountInString(string(p.query))) b.WriteString("\x1b[?25h") _, _ = out.Write([]byte(b.String())) _ = out.Flush() } 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) }