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