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 ` filters to switch entries only, // `k ` to close entries, `sp ` 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, }) // 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 " " 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: " / "pad: " / "" — 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 "5m": p.settings.AutoSummary.Cadence = "15m" case "15m": p.settings.AutoSummary.Cadence = "30m" default: p.settings.AutoSummary.Cadence = "5m" } 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 · 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 ", "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) }