2309 lines
63 KiB
Go
2309 lines
63 KiB
Go
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,
|
||
})
|
||
|
||
// 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 "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 <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)
|
||
}
|