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" | "spawn-process-form" | // "spawn-process-submit" | "switch" | "kill" | "quit" | "cancel" kind string // For spawn-agent / spawn-process, the preset to launch. preset *preset.Preset // For "switch" and "kill", the target child id. childID string // For "spawn-process-submit": the freeform command line the user // typed and the relaunch-on-exit flag they ticked. command string relaunch bool } type paletteItem struct { label string hint string action paletteAction } // paletteMode toggles the palette between its fuzzy-picker UI and the // freeform "spawn process" form. The form lives inside the palette so // it shares the same modal-input contract (every byte intercepted; no // PTY forwarding) without needing a second overlay. type paletteMode int const ( paletteModePicker paletteMode = iota paletteModeSpawnForm ) // spawnProcessForm is the state for the "Spawn process…" two-field // form: a command line plus a "relaunch on exit" toggle. Tab cycles // focus; space toggles the checkbox when it owns focus; Enter submits. type spawnProcessForm struct { cmd []rune relaunch bool field int // 0 = command, 1 = relaunch checkbox } // 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 mode paletteMode form *spawnProcessForm } // 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 ` filters to switch entries only, // `k ` to kills, `sp ` 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.DisplayName() 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}, }) } // Freeform "Spawn process…" entry. Opens a sub-form for typing an // arbitrary command line and ticking "relaunch on exit". The action // kind is intercepted by acceptOrEnterForm so accept switches the // palette into form mode instead of closing it. Placed after the // preset entries so quick-spawn flows keep the same ordering as // before this feature landed. out = append(out, paletteItem{ label: "Spawn process…", hint: "freeform command · optional relaunch on exit", action: paletteAction{kind: "spawn-process-form"}, }) // Kill entries last among the action rows, before Quit. Mirror the // "(current)" marker from switch entries so the focused tab is // obvious when scanning the kill list. for _, c := range p.children { if c.Status() != StatusRunning { continue } label := "Kill " + c.DisplayName() if c.ID == p.focused { label = "• " + label + " (current)" } out = append(out, paletteItem{ label: label, 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) { if p.mode == paletteModeSpawnForm { return p.handleFormInput(chunk, i) } 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.acceptOrEnterForm(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 } // acceptOrEnterForm wraps accept(): if the chosen item opens the // spawn-process form, transition into form mode instead of returning // done=true. The advance count is what the caller already consumed for // the Enter keystroke. func (p *paletteState) acceptOrEnterForm(adv int) (paletteAction, bool, int) { a := p.accept() if a.kind == "spawn-process-form" { p.mode = paletteModeSpawnForm p.form = &spawnProcessForm{} return paletteAction{}, false, adv } return a, true, adv } 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.acceptOrEnterForm(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 } // handleFormInput drives the spawn-process form. Tab cycles fields, // space toggles the relaunch checkbox when it has focus, Enter submits, // Esc cancels. The form supports both legacy and kitty key encodings to // match handleInput; bare ESC cancels the entire palette (consistent // with the picker). func (p *paletteState) handleFormInput(chunk []byte, i int) (paletteAction, bool, int) { b := chunk[i] if b == 0x1b { if n := csiLen(chunk, i); n > 0 { return p.handleFormCSI(chunk[i+2:i+n-1], chunk[i+n-1], n) } return paletteAction{kind: "cancel"}, true, 1 } switch b { case '\r', '\n': return p.submitForm(), true, 1 case '\t': p.cycleFormField() case 0x7f, 0x08: p.formBackspace() case ' ': if p.form.field == 1 { p.form.relaunch = !p.form.relaunch } else if b >= 0x20 && b < 0x7f { p.form.cmd = append(p.form.cmd, rune(b)) } default: if b >= 0x20 && b < 0x7f && p.form.field == 0 { p.form.cmd = append(p.form.cmd, rune(b)) } } return paletteAction{}, false, 1 } func (p *paletteState) handleFormCSI(params []byte, final byte, n int) (paletteAction, bool, int) { switch final { case 'A', 'B': // Arrow up/down cycles field. p.cycleFormField() return paletteAction{}, false, n case 'u': k, ok := decodeCSIu(string(params)) if !ok || k.event != 1 { return paletteAction{}, false, n } switch k.key { case 13: return p.submitForm(), true, n case 27: return paletteAction{kind: "cancel"}, true, n case 9: p.cycleFormField() case 127, 8: p.formBackspace() case ' ': if p.form.field == 1 { p.form.relaunch = !p.form.relaunch } default: if k.mods == 1 && k.key >= 0x20 && k.key < 0x7f && p.form.field == 0 { p.form.cmd = append(p.form.cmd, rune(k.key)) } } } return paletteAction{}, false, n } func (p *paletteState) cycleFormField() { p.form.field++ if p.form.field > 1 { p.form.field = 0 } } func (p *paletteState) formBackspace() { if p.form.field == 0 && len(p.form.cmd) > 0 { p.form.cmd = p.form.cmd[:len(p.form.cmd)-1] } } func (p *paletteState) submitForm() paletteAction { cmd := strings.TrimSpace(string(p.form.cmd)) if cmd == "" { return paletteAction{kind: "cancel"} } return paletteAction{ kind: "spawn-process-submit", command: cmd, relaunch: p.form.relaunch, } } 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 p.mode == paletteModeSpawnForm { p.renderForm(out, cols, rows) return } 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 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() } // renderForm paints the "Spawn process…" two-field form. Layout // mirrors the picker (centered rounded box) so the user feels like // they're still inside the palette. Cursor parks at the active field // so it blinks where the next byte will land. func (p *paletteState) renderForm(out writeFlusher, cols, rows int) { if p.form == nil { p.form = &spawnProcessForm{} } 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 var b strings.Builder b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J") row := 2 title := "Spawn process" hint := "esc cancel" dashes := width - 3 - len(title) - 1 - 1 - len(hint) - 3 if dashes < 2 { dashes = 2 } moveTo(&b, row, leftPad) b.WriteString(styleBorder + "╭─ " + styleActive + title + styleReset + styleBorder + " " + strings.Repeat("─", dashes) + " " + styleHint + hint + styleReset + styleBorder + " ─╮" + styleReset) row++ cmdStr := string(p.form.cmd) cmdLen := utf8.RuneCountInString(cmdStr) pad := content - 2 - cmdLen if pad < 0 { pad = 0 cmdStr = clipRunes(cmdStr, content-2) cmdLen = utf8.RuneCountInString(cmdStr) } prompt := "❯" if p.form.field == 0 { prompt = styleAccent + "❯" + styleReset } else { prompt = styleDim + "❯" + styleReset } cmdRow := row moveTo(&b, row, leftPad) b.WriteString(styleBorder + "│" + styleReset + " " + prompt + " " + cmdStr + strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset) row++ moveTo(&b, row, leftPad) b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset) row++ box := "[ ]" if p.form.relaunch { box = "[x]" } check := " " + box + " Relaunch on exit" if p.form.field == 1 { check = styleAccent + "▎" + styleReset + " " + styleBold + box + styleReset + " Relaunch on exit" } checkLen := visibleLen(check) cpad := content - checkLen if cpad < 0 { cpad = 0 } moveTo(&b, row, leftPad) b.WriteString(styleBorder + "│" + styleReset + " " + check + strings.Repeat(" ", cpad) + " " + styleBorder + "│" + styleReset) row++ moveTo(&b, row, leftPad) b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset) row++ footer := "↵ spawn · esc cancel · tab cycle · space toggle" 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 cursor on the command line if that field is focused. if p.form.field == 0 { moveTo(&b, cmdRow, leftPad+4+cmdLen) b.WriteString("\x1b[?25h") } else { b.WriteString("\x1b[?25l") } _, _ = 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) }