Files
patterm/internal/app/palette.go
Harry Bayliss f312b6d345 Add stackable toast notifications
Replaces the single-slot status-line flash with a top-right toast
stack over the focused pane. flashError, flashTransient, and
notifyAttention all push onto the same stack (cap 5, FIFO drop).
Ctrl-N dismisses the most recent toast; empty stack falls through to
the focused PTY so readline / nano / emacs / opencode bindings keep
working. A new "Clear notifications" palette item empties the stack.
2026-05-15 20:26:35 +01:00

2315 lines
63 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"
"sort"
"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" | "spawn-terminal" | "switch" |
// "kill" | "quit" | "cancel" | "pad-delete" | "pad-rename" |
// "pad-rename-form" | "pad-rename-submit" | "pad-edit" |
// "agent-rename" | "agent-rename-form" | "agent-rename-submit" |
// "agent-close" | "proc-rename" | "proc-rename-form" |
// "proc-rename-submit" | "proc-delete" | "proc-stop" |
// "proc-restart" | "header"
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
// For pad-* actions, the scratchpad name to operate on.
padName string
// For *-rename-submit actions, the user-typed new name.
newName string
// For settings actions, the updated settings snapshot to persist.
settings *settings
}
// Group ids order the section bands the palette renders when no query
// is active. Lower numbers render first; tie-broken matches in scored
// mode also fall back to group id so a tight Focused-section hit beats
// an equally tight Spawn-section hit.
const (
groupFocused = iota
groupOpen
groupSpawn
groupSettings
groupQuit
)
var groupLabels = map[int]string{
groupFocused: "Focused",
groupOpen: "Open",
groupSpawn: "Spawn",
groupSettings: "Settings",
groupQuit: "Quit",
}
type paletteItem struct {
label string
hint string
action paletteAction
group int
// matches lists rune indexes in label that should render bold during
// scored mode so the user sees why a row matched.
matches []int
}
// 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
paletteModeRenameForm
paletteModeSettings
paletteModeAutoSummary
paletteModeSettingsInput
)
// 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
}
// renameForm is a one-field inline form used by the "Rename scratchpad /
// agent / process" context palette entries. The submit action kind
// determines what gets renamed; the target name (pad name or child id)
// is carried alongside so closePalette knows what to apply the new
// name to.
type renameForm struct {
name []rune
subject string // "pad" | "agent" | "proc"
target string // padName for "pad"; childID for "agent"/"proc"
title string // e.g. "Rename"
subjectLine string // e.g. "scratchpad: notes.md" rendered above the input
}
type settingsInputForm struct {
title string
field string
value []rune
subtitle string
}
// 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
focusedPad string
presets preset.Set
settings settings
items []paletteItem
mode paletteMode
form *spawnProcessForm
renameForm *renameForm
settingsInput *settingsInputForm
// showHelp swaps the item list for a static keybinding cheat-sheet
// until the next keystroke. Toggled by `?` in picker mode.
showHelp bool
}
// 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 close entries, `sp <query>` to spawn entries.
var macroPrefixes = map[string][]string{
"sw": {"switch"},
"k": {"kill", "agent-close", "proc-stop", "proc-delete"},
"sp": {"spawn-agent", "spawn-process", "spawn-terminal", "spawn-process-form"},
}
// chipOrder is the cycle order for Tab / Shift-Tab when the user
// switches between filter categories from the chip strip. The empty
// string is the "All" chip.
var chipOrder = []string{"", "sw", "sp", "k"}
var chipLabels = map[string]string{
"": "All",
"sw": "Open",
"sp": "Spawn",
"k": "Close",
}
// 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.
// Matching is case-insensitive but the returned `rest` preserves the
// user's original case so Tab-cycling chips doesn't downcase the text.
func detectMacro(q string) (macro, rest string) {
lq := strings.ToLower(q)
for k := range macroPrefixes {
if len(lq) > len(k) && lq[:len(k)] == k && lq[len(k)] == ' ' {
return k, q[len(k)+1:]
}
}
return "", q
}
func findChildByID(children []*Child, id string) *Child {
if id == "" {
return nil
}
for _, c := range children {
if c.ID == id {
return c
}
}
return nil
}
func newPalette(children []*Child, focused, focusedPad string, presets preset.Set, appSettings ...settings) *paletteState {
st := defaultSettings()
if len(appSettings) > 0 {
st = appSettings[0].clone()
}
p := &paletteState{children: children, focused: focused, focusedPad: focusedPad, presets: presets, settings: st}
p.rebuild()
return p
}
func (p *paletteState) rebuild() {
// Macro is resolved on the *original-case* query; the returned rest
// keeps the user's casing intact (useful when Tab cycles chips).
macro, rest := detectMacro(string(p.query))
all := p.buildItems(macro)
if rest == "" {
// No textual filter: render with section headers between groups.
p.items = itemsWithHeaders(all)
p.clampCursor()
return
}
needle := strings.ToLower(rest)
type scored struct {
it paletteItem
score int
order int
}
scoredList := make([]scored, 0, len(all))
for i, it := range all {
s, matches := fuzzyScore(strings.ToLower(it.label), strings.ToLower(it.hint), needle)
if s == 0 {
continue
}
it.matches = matches
scoredList = append(scoredList, scored{it: it, score: s, order: i})
}
sort.SliceStable(scoredList, func(i, j int) bool {
if scoredList[i].score != scoredList[j].score {
return scoredList[i].score > scoredList[j].score
}
if scoredList[i].it.group != scoredList[j].it.group {
return scoredList[i].it.group < scoredList[j].it.group
}
return scoredList[i].order < scoredList[j].order
})
p.items = p.items[:0]
for _, s := range scoredList {
p.items = append(p.items, s.it)
}
p.clampCursor()
}
// buildItems assembles every selectable row in fixed group order
// (Focused → Open → Spawn → Quit). Headers are added by
// itemsWithHeaders for the no-query case; scored mode drops them.
// When macro is non-empty the result is filtered down to the kinds
// that macro retains.
func (p *paletteState) buildItems(macro string) []paletteItem {
var out []paletteItem
// Group 0: Focused — context-aware actions for whatever owns focus.
// A focused scratchpad shadows any focused child.
switch {
case p.focusedPad != "":
name := p.focusedPad
out = append(out,
paletteItem{label: "Delete scratchpad: " + name, hint: "remove the file from disk",
action: paletteAction{kind: "pad-delete", padName: name}, group: groupFocused},
paletteItem{label: "Rename scratchpad: " + name, hint: "inline rename · enter to commit",
action: paletteAction{kind: "pad-rename-form", padName: name}, group: groupFocused},
paletteItem{label: "Edit scratchpad: " + name, hint: "open in external editor (zed)",
action: paletteAction{kind: "pad-edit", padName: name}, group: groupFocused},
)
case p.focused != "":
if c := findChildByID(p.children, p.focused); c != nil {
name := c.DisplayName()
switch c.Kind {
case KindAgent:
out = append(out,
paletteItem{label: "Rename agent: " + name, hint: "inline rename · enter to commit",
action: paletteAction{kind: "agent-rename-form", childID: c.ID}, group: groupFocused},
paletteItem{label: "Close agent: " + name, hint: "SIGTERM " + strings.Join(c.Argv, " "),
action: paletteAction{kind: "agent-close", childID: c.ID}, group: groupFocused},
)
default:
out = append(out,
paletteItem{label: "Rename process: " + name, hint: "inline rename · enter to commit",
action: paletteAction{kind: "proc-rename-form", childID: c.ID}, group: groupFocused},
paletteItem{label: "Delete process: " + name, hint: "remove entry; SIGKILL if alive",
action: paletteAction{kind: "proc-delete", childID: c.ID}, group: groupFocused},
paletteItem{label: "Stop process: " + name, hint: "SIGTERM · keep entry for restart",
action: paletteAction{kind: "proc-stop", childID: c.ID}, group: groupFocused},
paletteItem{label: "Restart process: " + name, hint: "SIGTERM then start with same argv",
action: paletteAction{kind: "proc-restart", childID: c.ID}, group: groupFocused},
)
}
}
}
// Group 1: Open — switch entries for every running child. Dead
// agents are filtered out (no restart path); dead command processes
// remain so they can be restarted. The currently-focused child is
// marked with a leading ▶ instead of the older "• … (current)" suffix
// so the row reads cleaner.
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
}
if c.Status() != StatusRunning {
label = label + " [" + string(c.Status()) + "]"
}
out = append(out, paletteItem{
label: label,
hint: hint,
action: paletteAction{kind: "switch", childID: c.ID},
group: groupOpen,
})
}
// Group 2: Spawn — every way to launch something new. Verbs are
// unified on "Spawn" for consistency; the trailing "…" plus the
// `(custom)` suffix on the freeform row signals it opens a form.
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},
group: groupSpawn,
})
}
for _, pr := range p.presets.Processes {
out = append(out, paletteItem{
label: "Spawn process: " + pr.Name,
hint: strings.Join(pr.Argv, " "),
action: paletteAction{kind: "spawn-process", preset: pr},
group: groupSpawn,
})
}
out = append(out, paletteItem{
label: "Spawn terminal",
hint: "bare interactive $SHELL · removed on exit",
action: paletteAction{kind: "spawn-terminal"},
group: groupSpawn,
})
out = append(out, paletteItem{
label: "Spawn process… (custom)",
hint: "freeform · sh -lc · optional relaunch",
action: paletteAction{kind: "spawn-process-form"},
group: groupSpawn,
})
// Group 3: Settings.
out = append(out, paletteItem{
label: "Open Settings",
hint: "configure agents and auto-summary",
action: paletteAction{kind: "settings-open"},
group: groupSettings,
})
out = append(out, paletteItem{
label: "Clear notifications",
hint: "dismiss all toasts in the top-right of the focused pane",
action: paletteAction{kind: "toasts-clear"},
group: groupSettings,
})
// Group 4: Quit.
out = append(out, paletteItem{
label: "Quit",
hint: "exit patterm; SIGTERM every child",
action: paletteAction{kind: "quit"},
group: groupQuit,
})
if macro != "" {
retain := macroPrefixes[macro]
filtered := out[:0:0]
for _, it := range out {
for _, k := range retain {
if it.action.kind == k {
filtered = append(filtered, it)
break
}
}
}
out = filtered
}
return out
}
// itemsWithHeaders splices a non-selectable header row in front of
// each new group so the (unfiltered) list reads as scannable bands.
func itemsWithHeaders(items []paletteItem) []paletteItem {
if len(items) == 0 {
return nil
}
result := make([]paletteItem, 0, len(items)+4)
currentGroup := -1
for _, it := range items {
if it.group != currentGroup {
currentGroup = it.group
label, ok := groupLabels[it.group]
if !ok {
label = ""
}
result = append(result, paletteItem{
label: "── " + label + " ──",
action: paletteAction{kind: "header"},
group: it.group,
})
}
result = append(result, it)
}
return result
}
// fuzzyScore ranks a candidate label against the lowercase needle.
// Returns (score, matchPositions). score==0 means no match. Higher is
// better. matchPositions are rune indexes inside the label that should
// render bold when displayed.
func fuzzyScore(label, hint, needle string) (int, []int) {
if needle == "" {
return 1, nil
}
needleRunes := utf8.RuneCountInString(needle)
if strings.HasPrefix(label, needle) {
pos := make([]int, needleRunes)
for i := range pos {
pos[i] = i
}
return 1000 + needleRunes, pos
}
if byteIdx := wordBoundaryIndex(label, needle); byteIdx >= 0 {
return 500 + needleRunes, runeRange(label, byteIdx, len(needle))
}
if byteIdx := strings.Index(label, needle); byteIdx >= 0 {
return 250 + needleRunes, runeRange(label, byteIdx, len(needle))
}
if strings.Contains(hint, needle) {
return 100 + needleRunes, nil
}
// Scattered fuzzy match: characters of needle appear in order in
// label. Lowest-value bucket, recorded so the user sees something
// highlighted in the rendered row.
pos := make([]int, 0, needleRunes)
runeIdx := 0
needleIdx := 0
nr := []rune(needle)
for _, r := range label {
if needleIdx < len(nr) && r == nr[needleIdx] {
pos = append(pos, runeIdx)
needleIdx++
}
runeIdx++
}
if needleIdx == len(nr) {
return 10 * needleRunes, pos
}
return 0, nil
}
// wordBoundaryIndex returns the byte index of the first occurrence of
// needle in label that starts at a word boundary (label start, or
// after one of " -_:./"), or -1 if no boundary match exists.
func wordBoundaryIndex(label, needle string) int {
idx := 0
for idx <= len(label)-len(needle) {
next := strings.Index(label[idx:], needle)
if next < 0 {
return -1
}
abs := idx + next
if abs == 0 {
return abs
}
prev := label[abs-1]
if prev == ' ' || prev == '-' || prev == '_' || prev == ':' || prev == '.' || prev == '/' {
return abs
}
idx = abs + 1
}
return -1
}
// runeRange converts a (byteStart, byteCount) span inside s into the
// list of rune indexes covered. ASCII labels collapse to byteStart..
// byteStart+byteCount-1; multi-byte runes are accounted for correctly.
func runeRange(s string, byteStart, byteCount int) []int {
var out []int
runeIdx := 0
for byteI := range s {
if byteI >= byteStart && byteI < byteStart+byteCount {
out = append(out, runeIdx)
}
if byteI >= byteStart+byteCount {
break
}
runeIdx++
}
return out
}
// kitty functional keycodes.
const (
kittyKeyUp = 57352
kittyKeyDown = 57353
kittyKeyHome = 57371
kittyKeyEnd = 57368
)
// 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. Returning
// done=true tells the caller the palette is finished and action
// describes what to do next.
func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, done bool, advance int) {
if p.mode == paletteModeSpawnForm {
return p.handleFormInput(chunk, i)
}
if p.mode == paletteModeRenameForm {
return p.handleRenameInput(chunk, i)
}
if p.mode == paletteModeSettings {
return p.handleSettingsInput(chunk, i)
}
if p.mode == paletteModeAutoSummary {
return p.handleAutoSummaryInput(chunk, i)
}
if p.mode == paletteModeSettingsInput {
return p.handleSettingsTextInput(chunk, i)
}
b := chunk[i]
// Help overlay: any keystroke dismisses it back to the picker. We
// consume CSI sequences as one unit so a long arrow encoding doesn't
// leave dangling bytes in the input stream.
if p.showHelp {
p.showHelp = false
if b == 0x1b {
if n := csiLen(chunk, i); n > 0 {
return paletteAction{}, false, n
}
return paletteAction{}, false, 1
}
return paletteAction{}, false, 1
}
if b == 0x1b {
// Alt-digit (ESC then '1'-'9') is the quick-pick accelerator —
// jump straight to the Nth visible selectable item.
if i+1 < len(chunk) && chunk[i+1] >= '1' && chunk[i+1] <= '9' {
n := int(chunk[i+1] - '0')
if a, ok := p.quickPick(n); ok {
return a, true, 2
}
return paletteAction{}, false, 2
}
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 '\t':
p.cycleChip(+1)
case 0x15: // Ctrl-U
p.clearQuery()
case 0x0e: // Ctrl-N
p.cursorDown()
case 0x10: // Ctrl-P
p.cursorUp()
case 0x18: // Ctrl-X — inline close on a Switch entry.
if a, ok := p.killCurrentSwitch(); ok {
return a, true, 1
}
case 0x0b: // Ctrl-K inside palette is a no-op (would re-open); ignore.
case 0x16: // Ctrl-V literal-paste — ignore in palette.
case '?':
if len(p.query) == 0 {
p.showHelp = true
return paletteAction{}, false, 1
}
p.query = append(p.query, '?')
p.rebuild()
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 or one of the rename forms, 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()
switch a.kind {
case "", "header":
// Cursor parked on a header (or empty list) — Enter is a no-op
// rather than closing the palette.
return paletteAction{}, false, adv
case "spawn-process-form":
p.mode = paletteModeSpawnForm
p.form = &spawnProcessForm{}
return paletteAction{}, false, adv
case "settings-open":
p.mode = paletteModeSettings
p.query = nil
p.cursor = 0
p.rebuildSettings()
return paletteAction{}, false, adv
case "pad-rename-form":
p.enterRenameForm("pad", a.padName, a.padName, "scratchpad: "+a.padName)
return paletteAction{}, false, adv
case "agent-rename-form", "proc-rename-form":
subject := "agent"
subjLabel := "agent: "
if a.kind == "proc-rename-form" {
subject = "proc"
subjLabel = "process: "
}
current := ""
if c := findChildByID(p.children, a.childID); c != nil {
current = c.DisplayName()
}
p.enterRenameForm(subject, a.childID, current, subjLabel+current)
return paletteAction{}, false, adv
}
return a, true, adv
}
func (p *paletteState) enterRenameForm(subject, target, current, subjectLine string) {
p.mode = paletteModeRenameForm
p.renameForm = &renameForm{
name: []rune(current),
subject: subject,
target: target,
title: "Rename",
subjectLine: subjectLine,
}
}
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 'H':
p.cursorHome()
return paletteAction{}, false, n
case 'F':
p.cursorEnd()
return paletteAction{}, false, n
case 'Z':
// Shift-Tab — cycle chip backwards.
p.cycleChip(-1)
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 9: // Tab
if k.mods == 2 {
p.cycleChip(-1)
} else {
p.cycleChip(+1)
}
return paletteAction{}, false, n
case 127, 8: // Backspace
p.backspace()
case kittyKeyUp:
p.cursorUp()
case kittyKeyDown:
p.cursorDown()
case kittyKeyHome:
p.cursorHome()
case kittyKeyEnd:
p.cursorEnd()
default:
// Ctrl-modified character keys.
if k.mods == 5 {
switch k.key {
case 'u':
p.clearQuery()
case 'n':
p.cursorDown()
case 'p':
p.cursorUp()
case 'x':
if a, ok := p.killCurrentSwitch(); ok {
return a, true, n
}
}
return paletteAction{}, false, n
}
// Alt-digit via kitty.
if k.mods == 3 && k.key >= '1' && k.key <= '9' {
if a, ok := p.quickPick(int(k.key - '0')); ok {
return a, true, n
}
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 {
if k.key == '?' && len(p.query) == 0 {
p.showHelp = true
return paletteAction{}, false, n
}
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. Ctrl-R toggles relaunch regardless of focused field so
// the user doesn't have to leave the command line to set it.
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 0x12: // Ctrl-R — toggle relaunch in either field.
p.form.relaunch = !p.form.relaunch
case ' ':
if p.form.field == 1 {
p.form.relaunch = !p.form.relaunch
} else {
p.form.cmd = append(p.form.cmd, ' ')
}
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 == 5 && k.key == 'r' {
p.form.relaunch = !p.form.relaunch
return paletteAction{}, false, n
}
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
}
// handleRenameInput drives the single-field rename form. Enter commits
// the typed name, Esc cancels back out of the palette entirely (same
// semantics as the spawn form so the user has one mental model).
func (p *paletteState) handleRenameInput(chunk []byte, i int) (paletteAction, bool, int) {
b := chunk[i]
if b == 0x1b {
if n := csiLen(chunk, i); n > 0 {
return p.handleRenameCSI(chunk[i+2:i+n-1], chunk[i+n-1], n)
}
return paletteAction{kind: "cancel"}, true, 1
}
switch b {
case '\r', '\n':
return p.submitRename(), true, 1
case 0x7f, 0x08:
p.renameBackspace()
case 0x15: // Ctrl-U
if p.renameForm != nil {
p.renameForm.name = p.renameForm.name[:0]
}
default:
if b >= 0x20 && b < 0x7f && p.renameForm != nil {
p.renameForm.name = append(p.renameForm.name, rune(b))
}
}
return paletteAction{}, false, 1
}
func (p *paletteState) handleRenameCSI(params []byte, final byte, n int) (paletteAction, bool, int) {
switch final {
case 'u':
k, ok := decodeCSIu(string(params))
if !ok || k.event != 1 {
return paletteAction{}, false, n
}
switch k.key {
case 13:
return p.submitRename(), true, n
case 27:
return paletteAction{kind: "cancel"}, true, n
case 127, 8:
p.renameBackspace()
default:
if k.mods == 5 && k.key == 'u' {
if p.renameForm != nil {
p.renameForm.name = p.renameForm.name[:0]
}
return paletteAction{}, false, n
}
if k.mods == 1 && k.key >= 0x20 && k.key < 0x7f && p.renameForm != nil {
p.renameForm.name = append(p.renameForm.name, rune(k.key))
}
}
}
return paletteAction{}, false, n
}
func (p *paletteState) renameBackspace() {
if p.renameForm != nil && len(p.renameForm.name) > 0 {
p.renameForm.name = p.renameForm.name[:len(p.renameForm.name)-1]
}
}
func (p *paletteState) submitRename() paletteAction {
if p.renameForm == nil {
return paletteAction{kind: "cancel"}
}
newName := strings.TrimSpace(string(p.renameForm.name))
if newName == "" {
return paletteAction{kind: "cancel"}
}
var kind string
switch p.renameForm.subject {
case "pad":
kind = "pad-rename-submit"
return paletteAction{kind: kind, padName: p.renameForm.target, newName: newName}
case "agent":
kind = "agent-rename-submit"
case "proc":
kind = "proc-rename-submit"
default:
return paletteAction{kind: "cancel"}
}
return paletteAction{kind: kind, childID: p.renameForm.target, newName: newName}
}
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 paletteAction{kind: "cancel"}
}
it := p.items[p.cursor]
if it.action.kind == "header" {
return paletteAction{kind: "header"}
}
return it.action
}
// quickPick selects the Nth visible non-header item (1-indexed) and
// returns its accepted action. Returns (_, false) if N is out of range
// or if the resolved action would only open a sub-form (which would
// leave the palette in an inconsistent state for a one-shot keystroke).
func (p *paletteState) quickPick(n int) (paletteAction, bool) {
if n <= 0 {
return paletteAction{}, false
}
seen := 0
for i, it := range p.items {
if it.action.kind == "header" {
continue
}
seen++
if seen == n {
p.cursor = i
a, done, _ := p.acceptOrEnterForm(0)
if !done {
return paletteAction{}, false
}
return a, true
}
}
return paletteAction{}, false
}
// killCurrentSwitch fires a "kill" action against the child the cursor
// is parked on, but only if that cursor row is a Switch entry. Used by
// Ctrl-X so the user can close a non-focused child without leaving the
// palette.
func (p *paletteState) killCurrentSwitch() (paletteAction, bool) {
if p.cursor < 0 || p.cursor >= len(p.items) {
return paletteAction{}, false
}
it := p.items[p.cursor]
if it.action.kind != "switch" {
return paletteAction{}, false
}
return paletteAction{kind: "kill", childID: it.action.childID}, true
}
// cycleChip rotates the active macro filter by dir (±1). Empty macro
// is "All"; otherwise the query is rewritten to "<macro> <rest>" so
// the existing macro pipeline does the actual filtering.
func (p *paletteState) cycleChip(dir int) {
cur, rest := detectMacro(string(p.query))
idx := 0
for i, c := range chipOrder {
if c == cur {
idx = i
break
}
}
next := chipOrder[(idx+dir+len(chipOrder))%len(chipOrder)]
if next == "" {
p.query = []rune(rest)
} else {
p.query = []rune(next + " " + rest)
}
p.rebuild()
}
// backspace deletes the last rune, with one special case: if the
// query is exactly a macro keyword plus its trailing space (e.g. "sw "),
// backspace drops the whole macro back to empty rather than leaving
// "sw" which would now read as a literal search term.
func (p *paletteState) backspace() {
if len(p.query) == 0 {
return
}
q := string(p.query)
if len(q) >= 2 && q[len(q)-1] == ' ' {
if _, ok := macroPrefixes[q[:len(q)-1]]; ok {
p.query = p.query[:0]
p.rebuild()
return
}
}
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() {
if len(p.items) == 0 {
return
}
for i := p.cursor - 1; i >= 0; i-- {
if p.items[i].action.kind != "header" {
p.cursor = i
return
}
}
}
func (p *paletteState) cursorDown() {
if len(p.items) == 0 {
return
}
for i := p.cursor + 1; i < len(p.items); i++ {
if p.items[i].action.kind != "header" {
p.cursor = i
return
}
}
}
func (p *paletteState) cursorHome() {
for i := 0; i < len(p.items); i++ {
if p.items[i].action.kind != "header" {
p.cursor = i
return
}
}
}
func (p *paletteState) cursorEnd() {
for i := len(p.items) - 1; i >= 0; i-- {
if p.items[i].action.kind != "header" {
p.cursor = i
return
}
}
}
func (p *paletteState) clampCursor() {
if len(p.items) == 0 {
p.cursor = 0
return
}
if p.cursor >= len(p.items) {
p.cursor = len(p.items) - 1
}
if p.cursor < 0 {
p.cursor = 0
}
if p.items[p.cursor].action.kind == "header" {
// Prefer moving down to the next selectable row; fall back to up.
for i := p.cursor + 1; i < len(p.items); i++ {
if p.items[i].action.kind != "header" {
p.cursor = i
return
}
}
for i := p.cursor - 1; i >= 0; i-- {
if p.items[i].action.kind != "header" {
p.cursor = i
return
}
}
}
}
// visibleSelectableCount returns the total non-header items in p.items.
func (p *paletteState) visibleSelectableCount() int {
n := 0
for _, it := range p.items {
if it.action.kind != "header" {
n++
}
}
return n
}
// selectableIndex returns the 1-based index of the cursor among
// selectable (non-header) items, or 0 if cursor is on a header / empty.
func (p *paletteState) selectableIndex() int {
if p.cursor < 0 || p.cursor >= len(p.items) {
return 0
}
n := 0
for i := 0; i <= p.cursor; i++ {
if p.items[i].action.kind != "header" {
n++
}
}
return n
}
// focusedSubject returns the short context string shown in the title
// bar — "on: <child>" / "pad: <name>" / "" — so the user knows which
// focus the context-section is targeting.
func (p *paletteState) focusedSubject() string {
if p.focusedPad != "" {
return "pad: " + p.focusedPad
}
if p.focused != "" {
if c := findChildByID(p.children, p.focused); c != nil {
return "on: " + c.DisplayName()
}
}
return ""
}
func (p *paletteState) rebuildSettings() {
items := []paletteItem{{
label: "Agents / Auto-summarization",
hint: "provider, models, cadence, test",
action: paletteAction{kind: "settings-auto-summary"},
group: groupSettings,
}}
q := strings.TrimSpace(strings.ToLower(string(p.query)))
if q == "" {
p.items = items
p.cursor = 0
return
}
p.items = p.items[:0]
for _, it := range items {
if strings.Contains(strings.ToLower(it.label+" "+it.hint), q) {
p.items = append(p.items, it)
}
}
p.clampCursor()
}
func (p *paletteState) handleSettingsInput(chunk []byte, i int) (paletteAction, bool, int) {
b := chunk[i]
if b == 0x1b {
if n := csiLen(chunk, i); n > 0 {
final := chunk[i+n-1]
switch final {
case 'A':
p.cursorUp()
case 'B':
p.cursorDown()
}
return paletteAction{}, false, n
}
return paletteAction{kind: "cancel"}, true, 1
}
switch b {
case '\r', '\n':
if len(p.items) == 0 {
return paletteAction{}, false, 1
}
a := p.items[p.cursor].action
if a.kind == "settings-auto-summary" {
p.mode = paletteModeAutoSummary
p.cursor = 0
return paletteAction{}, false, 1
}
case 0x7f, 0x08:
p.backspace()
p.rebuildSettings()
case 0x15:
p.query = nil
p.rebuildSettings()
case 0x0e:
p.cursorDown()
case 0x10:
p.cursorUp()
default:
if b >= 0x20 && b < 0x7f {
p.query = append(p.query, rune(b))
p.rebuildSettings()
}
}
return paletteAction{}, false, 1
}
func (p *paletteState) handleAutoSummaryInput(chunk []byte, i int) (paletteAction, bool, int) {
b := chunk[i]
if b == 0x1b {
if n := csiLen(chunk, i); n > 0 {
final := chunk[i+n-1]
switch final {
case 'A':
p.cursor--
if p.cursor < 0 {
p.cursor = len(autoSummaryRows()) - 1
}
case 'B':
p.cursor++
if p.cursor >= len(autoSummaryRows()) {
p.cursor = 0
}
}
return paletteAction{}, false, n
}
return paletteAction{kind: "cancel"}, true, 1
}
switch b {
case '\r', '\n':
return p.activateAutoSummaryRow()
case 0x0e:
p.cursor++
case 0x10:
p.cursor--
}
if p.cursor < 0 {
p.cursor = len(autoSummaryRows()) - 1
}
if p.cursor >= len(autoSummaryRows()) {
p.cursor = 0
}
return paletteAction{}, false, 1
}
func (p *paletteState) handleSettingsTextInput(chunk []byte, i int) (paletteAction, bool, int) {
if p.settingsInput == nil {
p.mode = paletteModeAutoSummary
return paletteAction{}, false, 1
}
b := chunk[i]
if b == 0x1b {
if n := csiLen(chunk, i); n > 0 {
return paletteAction{}, false, n
}
p.mode = paletteModeAutoSummary
return paletteAction{}, false, 1
}
switch b {
case '\r', '\n':
p.applySettingsInput()
p.mode = paletteModeAutoSummary
case 0x7f, 0x08:
if len(p.settingsInput.value) > 0 {
p.settingsInput.value = p.settingsInput.value[:len(p.settingsInput.value)-1]
}
case 0x15:
p.settingsInput.value = nil
default:
if b >= 0x20 && b < 0x7f {
p.settingsInput.value = append(p.settingsInput.value, rune(b))
}
}
return paletteAction{}, false, 1
}
type autoSummaryRow struct {
key string
label string
}
func autoSummaryRows() []autoSummaryRow {
return []autoSummaryRow{
{key: "enabled", label: "Enabled"},
{key: "provider", label: "Provider"},
{key: "codex_model", label: "Codex model"},
{key: "opencode_model", label: "OpenCode model"},
{key: "claude_model", label: "Claude model"},
{key: "cadence", label: "Cadence"},
{key: "test", label: "Test summarizer"},
{key: "run_now", label: "Summarize current top-level agent now"},
{key: "save", label: "Save settings"},
{key: "cancel", label: "Cancel"},
{key: "back", label: "Back to Settings"},
}
}
func (p *paletteState) activateAutoSummaryRow() (paletteAction, bool, int) {
rows := autoSummaryRows()
if p.cursor < 0 || p.cursor >= len(rows) {
return paletteAction{}, false, 1
}
switch rows[p.cursor].key {
case "enabled":
p.settings.AutoSummary.Enabled = !p.settings.AutoSummary.Enabled
case "provider":
switch p.settings.AutoSummary.Provider {
case "codex":
p.settings.AutoSummary.Provider = "opencode"
case "opencode":
p.settings.AutoSummary.Provider = "claude"
default:
p.settings.AutoSummary.Provider = "codex"
}
case "codex_model", "opencode_model", "claude_model":
provider := strings.TrimSuffix(rows[p.cursor].key, "_model")
p.settingsInput = &settingsInputForm{
title: provider + " model",
field: rows[p.cursor].key,
value: []rune(p.settings.AutoSummary.modelFor(provider)),
subtitle: "model flag passed to " + provider,
}
p.mode = paletteModeSettingsInput
case "cadence":
switch p.settings.AutoSummary.Cadence {
case "15s":
p.settings.AutoSummary.Cadence = "30s"
case "30s":
p.settings.AutoSummary.Cadence = "1m"
default:
p.settings.AutoSummary.Cadence = "15s"
}
case "test":
return p.settingsAction("settings-test"), true, 1
case "run_now":
return p.settingsAction("settings-run-now"), true, 1
case "save":
return p.settingsAction("settings-close"), true, 1
case "cancel":
return paletteAction{kind: "cancel"}, true, 1
case "back":
p.mode = paletteModeSettings
p.cursor = 0
p.query = nil
p.rebuildSettings()
}
p.settings.normalize()
return paletteAction{}, false, 1
}
func (p *paletteState) applySettingsInput() {
if p.settingsInput == nil {
return
}
val := strings.TrimSpace(string(p.settingsInput.value))
if val == "" {
return
}
if p.settings.AutoSummary.Models == nil {
p.settings.AutoSummary.Models = defaultSummaryModels()
}
switch p.settingsInput.field {
case "codex_model":
p.settings.AutoSummary.Models["codex"] = val
case "opencode_model":
p.settings.AutoSummary.Models["opencode"] = val
case "claude_model":
p.settings.AutoSummary.Models["claude"] = val
}
p.settings.normalize()
}
func (p *paletteState) settingsCloseAction() paletteAction {
return p.settingsAction("settings-close")
}
func (p *paletteState) settingsAction(kind string) paletteAction {
st := p.settings.clone()
return paletteAction{kind: kind, settings: &st}
}
func (p *paletteState) renderSettings(out writeFlusher, cols, rows int) {
p.renderSimplePicker(out, cols, rows, "Settings", "esc cancel", "search settings")
}
func (p *paletteState) renderSimplePicker(out writeFlusher, cols, rows int, title, hint, placeholder string) {
width, leftPad, content := paletteBox(cols)
maxItems := rows - 7
if maxItems > 10 {
maxItems = 10
}
if maxItems < 1 {
maxItems = 1
}
var b strings.Builder
b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J")
row := 2
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╭─ " + styleActive + title + styleReset + styleBorder + " " + strings.Repeat("─", max(2, width-visibleLen(title)-visibleLen(hint)-9)) + " " + styleHint + hint + styleReset + styleBorder + " ─╮" + styleReset)
row++
query := string(p.query)
if query == "" {
query = styleDim + placeholder + styleReset
}
pad := content - 2 - visibleLen(query)
if pad < 0 {
pad = 0
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + styleAccent + "" + styleReset + " " + query + strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset)
row++
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
p.renderItemRows(&b, &row, leftPad, width, content, maxItems)
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
footer := styleHint + "↵ open · esc cancel · ↑↓ navigate" + styleReset
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + footer + strings.Repeat(" ", max(0, content-visibleLen(footer))) + " " + styleBorder + "│" + styleReset)
row++
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╰" + strings.Repeat("─", width-2) + "╯" + styleReset)
_, _ = out.Write([]byte(b.String()))
_ = out.Flush()
}
func (p *paletteState) renderAutoSummary(out writeFlusher, cols, rows int) {
width, leftPad, content := paletteBox(cols)
var b strings.Builder
b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J")
row := 2
title := "Auto-summarization"
hint := "esc cancel"
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╭─ " + styleActive + title + styleReset + styleBorder + " " + strings.Repeat("─", max(2, width-visibleLen(title)-visibleLen(hint)-9)) + " " + styleHint + hint + styleReset + styleBorder + " ─╮" + styleReset)
row++
lines := p.autoSummaryDisplayRows()
for i, line := range lines {
moveTo(&b, row, leftPad)
prefix := " "
if i == p.cursor {
prefix = styleAccent + "▎" + styleReset + " "
line = styleBold + line + styleReset
}
pad := content - visibleLen(prefix) - visibleLen(line)
if pad < 0 {
pad = 0
}
b.WriteString(styleBorder + "│" + styleReset + " " + prefix + line + strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset)
row++
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
footer := styleHint + "↵ edit/toggle · cadence 15s/30s/1m · save row commits · esc cancel" + styleReset
if visibleLen(footer) > content {
footer = clipRunes(footer, content-1) + "…"
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + footer + strings.Repeat(" ", max(0, content-visibleLen(footer))) + " " + styleBorder + "│" + styleReset)
row++
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╰" + strings.Repeat("─", width-2) + "╯" + styleReset)
_, _ = out.Write([]byte(b.String()))
_ = out.Flush()
}
func (p *paletteState) autoSummaryDisplayRows() []string {
a := p.settings.AutoSummary
enabled := "off"
if a.Enabled {
enabled = "on"
}
values := map[string]string{
"enabled": enabled,
"provider": a.Provider,
"codex_model": a.modelFor("codex"),
"opencode_model": a.modelFor("opencode"),
"claude_model": a.modelFor("claude"),
"cadence": a.Cadence + " minimum after activity",
}
var out []string
for _, row := range autoSummaryRows() {
if v, ok := values[row.key]; ok {
out = append(out, row.label+": "+v)
} else {
out = append(out, row.label)
}
}
return out
}
func (p *paletteState) renderSettingsInput(out writeFlusher, cols, rows int) {
if p.settingsInput == nil {
p.settingsInput = &settingsInputForm{title: "Setting"}
}
width, leftPad, content := paletteBox(cols)
var b strings.Builder
b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J")
row := 2
title := p.settingsInput.title
hint := "esc cancel"
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╭─ " + styleActive + title + styleReset + styleBorder + " " + strings.Repeat("─", max(2, width-visibleLen(title)-visibleLen(hint)-9)) + " " + styleHint + hint + styleReset + styleBorder + " ─╮" + styleReset)
row++
if p.settingsInput.subtitle != "" {
sub := p.settingsInput.subtitle
if visibleLen(sub) > content {
sub = clipRunes(sub, content-1) + "…"
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + styleHint + sub + styleReset + strings.Repeat(" ", max(0, content-visibleLen(sub))) + " " + styleBorder + "│" + styleReset)
row++
}
value := string(p.settingsInput.value)
if visibleLen(value) > content-2 {
value = clipRunes(value, content-3) + "…"
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + styleAccent + "" + styleReset + " " + value + strings.Repeat(" ", max(0, content-2-visibleLen(value))) + " " + styleBorder + "│" + styleReset)
inputRow := row
row++
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
footer := styleHint + "↵ save · esc cancel · ⌃u clear" + styleReset
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + footer + strings.Repeat(" ", max(0, content-visibleLen(footer))) + " " + styleBorder + "│" + styleReset)
row++
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╰" + strings.Repeat("─", width-2) + "╯" + styleReset)
moveTo(&b, inputRow, leftPad+4+visibleLen(value))
b.WriteString("\x1b[?25h")
_, _ = out.Write([]byte(b.String()))
_ = out.Flush()
}
func paletteBox(cols int) (width, leftPad, content int) {
if cols < 32 {
cols = 32
}
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
return width, leftPad, content
}
// render draws the palette onto out. Layout is a rounded box with a
// title bar, query line, chip strip, 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 p.mode == paletteModeRenameForm {
p.renderRename(out, cols, rows)
return
}
if p.mode == paletteModeSettings {
p.renderSettings(out, cols, rows)
return
}
if p.mode == paletteModeAutoSummary {
p.renderAutoSummary(out, cols, rows)
return
}
if p.mode == paletteModeSettingsInput {
p.renderSettingsInput(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"
subject := p.focusedSubject()
keyHint := "Ctrl-K"
// Right-side title contents: optional subject then "Ctrl-K".
subjLen := utf8.RuneCountInString(subject)
keyLen := utf8.RuneCountInString(keyHint)
rightVisible := keyLen
if subjLen > 0 {
rightVisible += subjLen + 2 // " " separator
}
dashes := width - 3 - len(titleText) - 1 - 1 - rightVisible - 3
// If the title-bar can't fit the subject, drop it.
if dashes < 2 && subjLen > 0 {
subject = ""
subjLen = 0
rightVisible = keyLen
dashes = width - 3 - len(titleText) - 1 - 1 - rightVisible - 3
}
if dashes < 2 {
dashes = 2
}
var rightStr string
if subject != "" {
rightStr = styleHint + subject + styleReset + " " + styleHint + keyHint + styleReset
} else {
rightStr = styleHint + keyHint + styleReset
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╭─ " + styleActive + titleText + styleReset + styleBorder + " " +
strings.Repeat("─", dashes) + " " + rightStr + 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++
// Chip strip — visual mirror of the typed-prefix macros. Active
// chip is wrapped in [ ] and styled accent+bold; inactive chips
// fade to styleHint. The user can also drive this with Tab.
activeChip, _ := detectMacro(string(p.query))
chipStr := renderChips(activeChip)
chipVisible := visibleLen(chipStr)
chipPad := content - chipVisible
if chipPad < 0 {
chipPad = 0
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + chipStr +
strings.Repeat(" ", chipPad) + " " + styleBorder + "│" + styleReset)
row++
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
maxItems := rows - 7
if maxItems > 12 {
maxItems = 12
}
if maxItems < 1 {
maxItems = 1
}
if p.showHelp {
p.renderHelpRows(&b, &row, leftPad, width, content, maxItems)
} else {
p.renderItemRows(&b, &row, leftPad, width, content, maxItems)
}
// Bottom divider with scroll indicator when there are more items
// above or below the visible window.
moveTo(&b, row, leftPad)
div := middleDivider(width, p.scrollIndicator(maxItems))
b.WriteString(styleBorder + div + styleReset)
row++
footer := pickerFooter(p.visibleSelectableCount(), p.selectableIndex())
fLen := visibleLen(footer)
fPad := content - fLen
if fPad < 0 {
fPad = 0
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + footer +
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. Help
// overlay hides the cursor since there's nothing to type into.
if !p.showHelp {
moveTo(&b, queryRow, leftPad+4+qLen)
b.WriteString("\x1b[?25h")
} else {
b.WriteString("\x1b[?25l")
}
_, _ = out.Write([]byte(b.String()))
_ = out.Flush()
}
// renderChips returns the chip strip with the active chip wrapped in
// [brackets] and styled accent+bold. Inactive chips render in hint
// gray separated by two spaces.
func renderChips(active string) string {
var sb strings.Builder
for i, k := range chipOrder {
if i > 0 {
sb.WriteString(" ")
}
label := chipLabels[k]
if k == active {
sb.WriteString(styleAccent + styleBold + "[" + label + "]" + styleReset)
} else {
sb.WriteString(styleHint + " " + label + " " + styleReset)
}
}
return sb.String()
}
// pickerFooter returns the styled footer string; appends a "cur/total"
// counter on the right when the list has anything selectable.
func pickerFooter(total, cur int) string {
left := styleHint + "↵ run · esc close · ↑↓ navigate · tab filter · ? help" + styleReset
if total == 0 {
return left
}
return left + styleHint + fmt.Sprintf(" · %d/%d", cur, total) + styleReset
}
// middleDivider returns the divider that separates the item list from
// the footer. When extra is non-empty (a "▼ 3 more" indicator) it is
// embedded inside the divider.
func middleDivider(width int, extra string) string {
if extra == "" {
return "├" + strings.Repeat("─", width-2) + "┤"
}
tag := " " + extra + " "
tagVisible := visibleLen(tag)
dashes := width - 2 - tagVisible
if dashes < 2 {
return "├" + strings.Repeat("─", width-2) + "┤"
}
leftDashes := 2
rightDashes := dashes - leftDashes
return "├" + strings.Repeat("─", leftDashes) + tag + strings.Repeat("─", rightDashes) + "┤"
}
// scrollIndicator returns a "▼ N more" / "▲ N above" / "" string for
// the middle divider, signalling that there are items above or below
// the visible window.
func (p *paletteState) scrollIndicator(maxItems int) string {
if len(p.items) == 0 {
return ""
}
start, end := p.viewWindow(maxItems)
above := start
below := len(p.items) - end
switch {
case above > 0 && below > 0:
return styleHint + fmt.Sprintf("▲ %d ▼ %d", above, below) + styleReset
case above > 0:
return styleHint + fmt.Sprintf("▲ %d above", above) + styleReset
case below > 0:
return styleHint + fmt.Sprintf("▼ %d more", below) + styleReset
}
return ""
}
// viewWindow returns the [start, end) slice indexes into p.items that
// are currently rendered. The cursor is always inside the window.
func (p *paletteState) viewWindow(maxItems int) (int, int) {
start := 0
if p.cursor >= maxItems {
start = p.cursor - maxItems + 1
}
end := start + maxItems
if end > len(p.items) {
end = len(p.items)
}
return start, end
}
// renderItemRows paints the picker's item area into b. row is advanced
// past every row written (including filler rows that keep the box
// height constant).
func (p *paletteState) renderItemRows(b *strings.Builder, row *int, leftPad, width, content, maxItems int) {
start, end := p.viewWindow(maxItems)
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 · ⌫ to widen" + styleReset
msgVisible := visibleLen(msg)
pad := content - 2 - msgVisible
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]
if it.action.kind == "header" {
b.WriteString(renderHeaderRow(it.label, width, content))
*row++
continue
}
isSel := (start + i) == p.cursor
avail := content - 2 // 2 cells reserved for the selection indicator
label := it.label
hint := it.hint
matches := it.matches
labelLen := utf8.RuneCountInString(label)
hintLen := utf8.RuneCountInString(hint)
if labelLen > avail {
label = clipRunes(label, avail-1) + "…"
labelLen = utf8.RuneCountInString(label)
hint = ""
hintLen = 0
matches = trimMatches(matches, labelLen-1)
} 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 + " "
} else {
indicator = " "
}
labelStr = styleLabel(label, matches, isSel)
if hint != "" {
hintStr = styleHint + hint + styleReset
}
b.WriteString(styleBorder + "│" + styleReset + " " + indicator + labelStr +
strings.Repeat(" ", gap) + hintStr + " " + styleBorder + "│" + styleReset)
*row++
}
}
// renderHeaderRow renders a non-selectable section label like
// "── Focused ──────". The header uses dim color so it visually
// recedes from the selectable rows above and below.
func renderHeaderRow(label string, width, content int) string {
visible := utf8.RuneCountInString(label)
if visible > content {
label = clipRunes(label, content)
visible = utf8.RuneCountInString(label)
}
pad := content - visible
if pad < 0 {
pad = 0
}
return styleBorder + "│" + styleReset + " " + styleHint + label + styleReset +
strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset
}
// styleLabel renders label with any rune positions in matches bolded
// in accent. When selected the un-matched portion still bolds via
// styleBold so the whole row visually pops.
func styleLabel(label string, matches []int, selected bool) string {
base := ""
if selected {
base = styleBold
}
if len(matches) == 0 {
if base == "" {
return label
}
return base + label + styleReset
}
set := make(map[int]bool, len(matches))
for _, m := range matches {
set[m] = true
}
var sb strings.Builder
if base != "" {
sb.WriteString(base)
}
runeIdx := 0
inHl := false
for _, r := range label {
want := set[runeIdx]
if want && !inHl {
sb.WriteString(styleAccent + styleBold)
inHl = true
} else if !want && inHl {
sb.WriteString(styleReset)
if base != "" {
sb.WriteString(base)
}
inHl = false
}
sb.WriteRune(r)
runeIdx++
}
sb.WriteString(styleReset)
return sb.String()
}
// trimMatches drops match positions that no longer fall within the
// (possibly truncated) label rune count.
func trimMatches(matches []int, limit int) []int {
if limit <= 0 {
return nil
}
out := matches[:0:0]
for _, m := range matches {
if m < limit {
out = append(out, m)
}
}
if len(out) == 0 {
return nil
}
return out
}
// renderHelpRows paints the help cheat-sheet in place of the item list
// when showHelp is active.
func (p *paletteState) renderHelpRows(b *strings.Builder, row *int, leftPad, width, content, maxItems int) {
lines := []struct{ key, desc string }{
{"↵", "run selected"},
{"esc", "close palette"},
{"↑ ↓", "navigate (also ⌃p / ⌃n)"},
{"tab / ⇧tab", "cycle filter chip"},
{"home / end", "jump to first / last"},
{"alt-1..9", "quick-pick that visible row"},
{"⌃x", "close child (on a Switch row)"},
{"⌃u", "clear query"},
{"sw·sp·k <q>", "macro filters (same as chips)"},
{"⌫", "delete · widen results"},
{"?", "this help · any key to close"},
}
for i := 0; i < maxItems; i++ {
moveTo(b, *row, leftPad)
if i >= len(lines) {
b.WriteString(styleBorder + "│" + styleReset + strings.Repeat(" ", width-2) +
styleBorder + "│" + styleReset)
*row++
continue
}
ln := lines[i]
keyPart := styleAccent + ln.key + styleReset
descPart := styleHint + ln.desc + styleReset
visible := utf8.RuneCountInString(ln.key) + 3 + utf8.RuneCountInString(ln.desc)
pad := content - 2 - visible
if pad < 0 {
pad = 0
}
b.WriteString(styleBorder + "│" + styleReset + " " + keyPart + " " + descPart +
strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset)
*row++
}
}
// 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 := styleHint + "↵ spawn · esc cancel · tab cycle · ⌃r relaunch · runs via sh -lc" + styleReset
fLen := visibleLen(footer)
fpad := content - fLen
if fpad < 0 {
fpad = 0
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + footer +
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()
}
// renderRename paints the single-field rename form. The subject (e.g.
// "scratchpad: notes.md") lives on its own dim row above the input so
// long names don't get cut off in the title bar.
func (p *paletteState) renderRename(out writeFlusher, cols, rows int) {
if p.renameForm == nil {
p.renameForm = &renameForm{}
}
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 := p.renameForm.title
if title == "" {
title = "Rename"
}
hint := "esc cancel"
titleLen := utf8.RuneCountInString(title)
dashes := width - 3 - titleLen - 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++
// Subject line — "scratchpad: foo.md" / "agent: codex" / "process: dev".
subj := p.renameForm.subjectLine
if subj != "" {
subjLen := utf8.RuneCountInString(subj)
if subjLen > content {
subj = clipRunes(subj, content-1) + "…"
subjLen = utf8.RuneCountInString(subj)
}
spad := content - subjLen
if spad < 0 {
spad = 0
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + styleHint + subj + styleReset +
strings.Repeat(" ", spad) + " " + styleBorder + "│" + styleReset)
row++
}
nameStr := string(p.renameForm.name)
nameLen := utf8.RuneCountInString(nameStr)
pad := content - 2 - nameLen
if pad < 0 {
pad = 0
nameStr = clipRunes(nameStr, content-2)
nameLen = utf8.RuneCountInString(nameStr)
}
nameRow := row
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + styleAccent + "" + styleReset + " " + nameStr +
strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset)
row++
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
footer := "↵ commit · esc cancel · ⌃u clear"
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)
moveTo(&b, nameRow, leftPad+4+nameLen)
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)
}