From 81bc77366fb3af2b1c3f0560a1f9c3286ea0d75c Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Fri, 15 May 2026 16:41:44 +0100 Subject: [PATCH] Overhaul command palette UX Six-phase sweep: section headers (Focused / Open / Spawn / Quit) with header-skip cursor; chip strip mirroring sw/sp/k macros, driven by Tab; unified Spawn verbs across agent / process / terminal / custom; dropped duplicate global Close list in favor of Ctrl-X inline close on a Switch row plus the [Close] chip; scored matching (prefix > word-boundary > substring > fuzzy) with matched-char highlighting; title bar surfaces focus subject; rename forms split long subject onto its own row; new Alt-1..9 quick-pick, Home/End, ? help overlay, and Ctrl-R relaunch toggle inside the spawn-process form. Scroll indicator and cursor/total counter round out the footer. --- CHANGELOG.md | 40 + internal/app/palette.go | 1095 +++++++++++++---- internal/app/palette_context_test.go | 6 +- internal/app/palette_input_test.go | 30 +- internal/app/palette_ux_test.go | 359 ++++++ .../scenarios/rename_process_via_palette.json | 2 +- 6 files changed, 1299 insertions(+), 233 deletions(-) create mode 100644 internal/app/palette_ux_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c193e15..e2a3ed7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,46 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed +- Command palette UX overhaul. The single flat list grew section + bands (`── Focused ──`, `── Open ──`, `── Spawn ──`, `── Quit ──`) + so the rows are scannable at a glance; cursor navigation skips + the dim header rows transparently. A chip strip — `[All] Open + Spawn Close` — sits below the query line and tracks the active + macro filter; `Tab` / `Shift-Tab` cycle through the chips, and + the typed-prefix macros (`sw `, `sp `, `k `) still work and now + collapse the whole prefix on a single backspace instead of + leaving a stray `sw` behind. The title bar surfaces the current + focus subject (`on: ` / `pad: `) so the user knows + which Focused row is targeting what. The duplicate global Close + list is gone — close is reachable via the Focused-section action, + the `k ` macro / `[Close]` chip, or the new `Ctrl-X` inline close + on a Switch row. The "(current)" marker on the focused Switch row + became a leading `▶`. The empty-state hint now reads `no matches + · ⌫ to widen` instead of bare `no matches`. The middle divider + shows a `▼ N more` / `▲ N above` scroll indicator when the list + overflows, and the footer carries a `cursor/total` counter. +- Spawn verbs are unified on **Spawn**: `Run process: …` → + `Spawn process: …`, `New Terminal` → `Spawn terminal`, and the + freeform-form row is now `Spawn process… (custom)` so the + trailing ellipsis still signals it opens a form. +- Filtering switched from binary fuzzy-include to scored ranking. + Prefix matches beat word-boundary matches beat substring matches + beat scattered-fuzzy matches; ties fall back to section order so + a Focused-section hit always outranks an equally tight Spawn + hit. The matched characters in the rendered label render in + accent+bold so the user can see why a row matched. +- Rename forms split the long subject (`scratchpad: + some-really-long-name.md`) onto its own dim row above the input + so the title bar no longer truncates with an ellipsis when the + subject name is wide. +- New palette accelerators: `Alt-1` … `Alt-9` quick-pick the Nth + visible row, `Home` / `End` jump to first / last selectable row, + `?` (with empty query) opens an inline keybinding cheat-sheet + which any further keystroke dismisses, and `Ctrl-R` inside the + Spawn-process form toggles "Relaunch on exit" without leaving + the command field. + ### Fixed - Typing into a focused child while its emulator viewport is scrolled up into scrollback history now auto-snaps the viewport diff --git a/internal/app/palette.go b/internal/app/palette.go index 88f6955..e3396d5 100644 --- a/internal/app/palette.go +++ b/internal/app/palette.go @@ -2,6 +2,7 @@ package app import ( "fmt" + "sort" "strings" "unicode/utf8" @@ -17,7 +18,7 @@ type paletteAction struct { // "agent-rename" | "agent-rename-form" | "agent-rename-submit" | // "agent-close" | "proc-rename" | "proc-rename-form" | // "proc-rename-submit" | "proc-delete" | "proc-stop" | - // "proc-restart" + // "proc-restart" | "header" kind string // For spawn-agent / spawn-process, the preset to launch. @@ -38,10 +39,32 @@ type paletteAction struct { newName string } +// 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 + groupQuit +) + +var groupLabels = map[int]string{ + groupFocused: "Focused", + groupOpen: "Open", + groupSpawn: "Spawn", + 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 @@ -71,10 +94,11 @@ type spawnProcessForm struct { // 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 scratchpad: notes.md" + 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 } // paletteState is the in-memory model for the overlay. SPEC §4: a @@ -92,25 +116,43 @@ type paletteState struct { mode paletteMode form *spawnProcessForm renameForm *renameForm + + // showHelp swaps the item list for a static keybinding cheat-sheet + // until the next keystroke. Toggled by `?` in picker mode. + showHelp bool } // macroPrefixes maps the palette macro prefix (without trailing space) // to the paletteAction.kind values that should be retained when that // macro is active. Typing `sw ` filters to switch entries only, -// `k ` to kills, `sp ` to spawn entries (agents + -// processes). +// `k ` to close entries, `sp ` to spawn entries. var macroPrefixes = map[string][]string{ "sw": {"switch"}, - "k": {"kill"}, - "sp": {"spawn-agent", "spawn-process"}, + "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(q) > len(k) && q[:len(k)] == k && q[len(k)] == ' ' { + if len(lq) > len(k) && lq[:len(k)] == k && lq[len(k)] == ' ' { return k, q[len(k)+1:] } } @@ -136,111 +178,101 @@ func newPalette(children []*Child, focused, focusedPad string, presets preset.Se } func (p *paletteState) rebuild() { - all := p.allItems() - q := strings.ToLower(string(p.query)) - macro, rest := detectMacro(q) - if macro != "" { - kinds := macroPrefixes[macro] - filtered := all[:0:0] - for _, it := range all { - for _, k := range kinds { - if it.action.kind == k { - filtered = append(filtered, it) - break - } - } + // 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 } - all = filtered - q = rest + it.matches = matches + scoredList = append(scoredList, scored{it: it, score: s, order: i}) } - if q == "" { - p.items = all - } else { - p.items = p.items[:0] - for _, it := range all { - if fuzzyMatch(strings.ToLower(it.label+" "+it.hint), q) { - p.items = append(p.items, it) - } + 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) } - if p.cursor >= len(p.items) { - p.cursor = len(p.items) - 1 - } - if p.cursor < 0 { - p.cursor = 0 - } + p.clampCursor() } -func (p *paletteState) allItems() []paletteItem { +// 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 - // Context-aware entries come first so the most relevant actions for - // whatever is currently focused are one or two keystrokes away. - // Order matters: a focused scratchpad shadows any focused child - // (focus owns one or the other at a time). + // 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}, - }) - out = append(out, paletteItem{ - label: "Rename scratchpad: " + name, - hint: "inline rename · enter to commit", - action: paletteAction{kind: "pad-rename-form", padName: name}, - }) - out = append(out, paletteItem{ - label: "Edit scratchpad: " + name, - hint: "open in external editor (zed)", - action: paletteAction{kind: "pad-edit", padName: name}, - }) + 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}, - }) - out = append(out, paletteItem{ - label: "Close agent: " + name, - hint: "SIGTERM " + strings.Join(c.Argv, " "), - action: paletteAction{kind: "agent-close", childID: c.ID}, - }) + 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}, - }) - out = append(out, paletteItem{ - label: "Delete process: " + name, - hint: "remove entry; SIGKILL if alive", - action: paletteAction{kind: "proc-delete", childID: c.ID}, - }) - out = append(out, paletteItem{ - label: "Stop process: " + name, - hint: "SIGTERM · keep entry for restart", - action: paletteAction{kind: "proc-stop", childID: c.ID}, - }) - out = append(out, paletteItem{ - label: "Restart process: " + name, - hint: "SIGTERM then start with same argv", - action: paletteAction{kind: "proc-restart", childID: c.ID}, - }) + 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}, + ) } } } - // Switch entries first — existing open agents/processes should - // surface above options to spawn new ones. Hide non-running agents - // (e.g. killed ones) so the list doesn't accumulate corpses. Command - // processes are session-persistent, so they remain visible after - // exit to keep restart_process in reach. + // 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 @@ -248,7 +280,7 @@ func (p *paletteState) allItems() []paletteItem { label := "Switch to " + c.DisplayName() hint := strings.Join(c.Argv, " ") if c.ID == p.focused { - label = "• " + label + " (current)" + label = "▶ " + label } if c.Status() != StatusRunning { label = label + " [" + string(c.Status()) + "]" @@ -257,93 +289,189 @@ func (p *paletteState) allItems() []paletteItem { label: label, hint: hint, action: paletteAction{kind: "switch", childID: c.ID}, + group: groupOpen, }) } - // Preset commands — SPEC §4 calls these out as the primary way to - // spawn anything. One entry per file under presets/. + // 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: "Run process: " + pr.Name, + label: "Spawn process: " + pr.Name, hint: strings.Join(pr.Argv, " "), action: paletteAction{kind: "spawn-process", preset: pr}, + group: groupSpawn, }) } - - // "New Terminal" — bare interactive $SHELL pane. Distinct from - // "Run process: …" presets in that it spawns a KindTerminal (which - // disappears from the sidebar on exit rather than sticking around - // for restart). One quick keystroke; no form. out = append(out, paletteItem{ - label: "New Terminal", + label: "Spawn terminal", hint: "bare interactive $SHELL · removed on exit", action: paletteAction{kind: "spawn-terminal"}, + group: groupSpawn, }) - - // Freeform "Spawn process…" entry. Opens a sub-form for typing an - // arbitrary command line and ticking "relaunch on exit". The action - // kind is intercepted by acceptOrEnterForm so accept switches the - // palette into form mode instead of closing it. Placed after the - // preset entries so quick-spawn flows keep the same ordering as - // before this feature landed. out = append(out, paletteItem{ - label: "Spawn process…", - hint: "freeform command · optional relaunch on exit", + label: "Spawn process… (custom)", + hint: "freeform · sh -lc · optional relaunch", action: paletteAction{kind: "spawn-process-form"}, + group: groupSpawn, }) - // Close entries last among the action rows, before Quit. Mirror the - // "(current)" marker from switch entries so the focused tab is - // obvious when scanning the close list. - for _, c := range p.children { - if c.Status() != StatusRunning { - continue - } - label := "Close " + c.DisplayName() - if c.ID == p.focused { - label = "• " + label + " (current)" - } - out = append(out, paletteItem{ - label: label, - hint: "SIGTERM " + strings.Join(c.Argv, " "), - action: paletteAction{kind: "kill", childID: c.ID}, - }) - } - + // Group 3: 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 } -func fuzzyMatch(hay, needle string) bool { - if needle == "" { - return true +// 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 } - hi := 0 - for _, r := range needle { - idx := strings.IndexRune(hay[hi:], r) - if idx < 0 { - return false + 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, + }) } - hi += idx + utf8.RuneLen(r) + result = append(result, it) } - return true + return result } -// kitty functional keycodes for arrows. +// 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'), @@ -381,15 +509,9 @@ func peekArrowEvent(chunk []byte, i int) (nav byte, advance int) { } // handleInput consumes one keystroke from chunk[i:] and updates palette -// state. advance is how many bytes the keystroke occupies (1 for legacy -// keys, longer for CSI sequences). Returning done=true tells the caller -// the palette is finished and action describes what to do next. -// -// Recognised input includes both legacy byte forms and the kitty -// keyboard CSI u encoding that codex/ratatui pushes onto the terminal. -// Unknown CSI sequences (including release events from kitty flag 2) -// are consumed silently so they don't fall through to the ESC branch -// and accidentally cancel the palette. +// 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) @@ -397,8 +519,33 @@ func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, d if p.mode == paletteModeRenameForm { return p.handleRenameInput(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) } @@ -410,14 +557,27 @@ func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, d 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)) @@ -434,37 +594,42 @@ func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, d 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 "pad-rename-form": - p.enterRenameForm("pad", a.padName, a.padName, "Rename scratchpad: "+a.padName) + p.enterRenameForm("pad", a.padName, a.padName, "scratchpad: "+a.padName) return paletteAction{}, false, adv case "agent-rename-form", "proc-rename-form": subject := "agent" - title := "Rename agent: " + subjLabel := "agent: " if a.kind == "proc-rename-form" { subject = "proc" - title = "Rename process: " + subjLabel = "process: " } current := "" if c := findChildByID(p.children, a.childID); c != nil { current = c.DisplayName() } - p.enterRenameForm(subject, a.childID, current, title+current) + p.enterRenameForm(subject, a.childID, current, subjLabel+current) return paletteAction{}, false, adv } return a, true, adv } -func (p *paletteState) enterRenameForm(subject, target, current, title string) { +func (p *paletteState) enterRenameForm(subject, target, current, subjectLine string) { p.mode = paletteModeRenameForm p.renameForm = &renameForm{ - name: []rune(current), - subject: subject, - target: target, - title: title, + name: []rune(current), + subject: subject, + target: target, + title: "Rename", + subjectLine: subjectLine, } } @@ -476,6 +641,16 @@ func (p *paletteState) handleCSI(params []byte, final byte, n int) (paletteActio 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 { @@ -487,12 +662,23 @@ func (p *paletteState) handleCSI(params []byte, final byte, n int) (paletteActio 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 { @@ -503,12 +689,27 @@ func (p *paletteState) handleCSI(params []byte, final byte, n int) (paletteActio 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() } @@ -521,9 +722,8 @@ func (p *paletteState) handleCSI(params []byte, final byte, n int) (paletteActio // handleFormInput drives the spawn-process form. Tab cycles fields, // space toggles the relaunch checkbox when it has focus, Enter submits, -// Esc cancels. The form supports both legacy and kitty key encodings to -// match handleInput; bare ESC cancels the entire palette (consistent -// with the picker). +// 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 { @@ -539,11 +739,13 @@ func (p *paletteState) handleFormInput(chunk []byte, i int) (paletteAction, bool 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 if b >= 0x20 && b < 0x7f { - p.form.cmd = append(p.form.cmd, rune(b)) + } else { + p.form.cmd = append(p.form.cmd, ' ') } default: if b >= 0x20 && b < 0x7f && p.form.field == 0 { @@ -578,6 +780,10 @@ func (p *paletteState) handleFormCSI(params []byte, final byte, n int) (paletteA 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)) } @@ -698,17 +904,96 @@ func (p *paletteState) submitForm() paletteAction { } func (p *paletteState) accept() paletteAction { - if p.cursor >= 0 && p.cursor < len(p.items) { - return p.items[p.cursor].action + if p.cursor < 0 || p.cursor >= len(p.items) { + return paletteAction{kind: "cancel"} } - return paletteAction{kind: "cancel"} + it := p.items[p.cursor] + if it.action.kind == "header" { + return paletteAction{kind: "header"} + } + return it.action } -func (p *paletteState) backspace() { - if len(p.query) > 0 { - p.query = p.query[:len(p.query)-1] - p.rebuild() +// quickPick selects the Nth visible non-header item (1-indexed) and +// returns its accepted action. Returns (_, false) if N is out of range +// or if the resolved action would only open a sub-form (which would +// leave the palette in an inconsistent state for a one-shot keystroke). +func (p *paletteState) quickPick(n int) (paletteAction, bool) { + if n <= 0 { + return paletteAction{}, false } + seen := 0 + for i, it := range p.items { + if it.action.kind == "header" { + continue + } + seen++ + if seen == n { + p.cursor = i + a, done, _ := p.acceptOrEnterForm(0) + if !done { + return paletteAction{}, false + } + return a, true + } + } + return paletteAction{}, false +} + +// killCurrentSwitch fires a "kill" action against the child the cursor +// is parked on, but only if that cursor row is a Switch entry. Used by +// Ctrl-X so the user can close a non-focused child without leaving the +// palette. +func (p *paletteState) killCurrentSwitch() (paletteAction, bool) { + if p.cursor < 0 || p.cursor >= len(p.items) { + return paletteAction{}, false + } + it := p.items[p.cursor] + if it.action.kind != "switch" { + return paletteAction{}, false + } + return paletteAction{kind: "kill", childID: it.action.childID}, true +} + +// cycleChip rotates the active macro filter by dir (±1). Empty macro +// is "All"; otherwise the query is rewritten to " " so +// the existing macro pipeline does the actual filtering. +func (p *paletteState) cycleChip(dir int) { + cur, rest := detectMacro(string(p.query)) + idx := 0 + for i, c := range chipOrder { + if c == cur { + idx = i + break + } + } + next := chipOrder[(idx+dir+len(chipOrder))%len(chipOrder)] + if next == "" { + p.query = []rune(rest) + } else { + p.query = []rune(next + " " + rest) + } + p.rebuild() +} + +// backspace deletes the last rune, with one special case: if the +// query is exactly a macro keyword plus its trailing space (e.g. "sw "), +// backspace drops the whole macro back to empty rather than leaving +// "sw" which would now read as a literal search term. +func (p *paletteState) backspace() { + if len(p.query) == 0 { + return + } + q := string(p.query) + if len(q) >= 2 && q[len(q)-1] == ' ' { + if _, ok := macroPrefixes[q[:len(q)-1]]; ok { + p.query = p.query[:0] + p.rebuild() + return + } + } + p.query = p.query[:len(p.query)-1] + p.rebuild() } func (p *paletteState) clearQuery() { @@ -717,23 +1002,120 @@ func (p *paletteState) clearQuery() { } func (p *paletteState) cursorUp() { - p.cursor-- - if p.cursor < 0 { - p.cursor = 0 + 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() { - p.cursor++ - if p.cursor >= len(p.items) { - p.cursor = len(p.items) - 1 + if len(p.items) == 0 { + return + } + for i := p.cursor + 1; i < len(p.items); i++ { + if p.items[i].action.kind != "header" { + p.cursor = i + return + } } } +func (p *paletteState) cursorHome() { + for i := 0; i < len(p.items); i++ { + if p.items[i].action.kind != "header" { + p.cursor = i + return + } + } +} + +func (p *paletteState) cursorEnd() { + for i := len(p.items) - 1; i >= 0; i-- { + if p.items[i].action.kind != "header" { + p.cursor = i + return + } + } +} + +func (p *paletteState) clampCursor() { + if len(p.items) == 0 { + p.cursor = 0 + return + } + if p.cursor >= len(p.items) { + p.cursor = len(p.items) - 1 + } + if p.cursor < 0 { + p.cursor = 0 + } + if p.items[p.cursor].action.kind == "header" { + // Prefer moving down to the next selectable row; fall back to up. + for i := p.cursor + 1; i < len(p.items); i++ { + if p.items[i].action.kind != "header" { + p.cursor = i + return + } + } + for i := p.cursor - 1; i >= 0; i-- { + if p.items[i].action.kind != "header" { + p.cursor = i + return + } + } + } +} + +// visibleSelectableCount returns the total non-header items in p.items. +func (p *paletteState) visibleSelectableCount() int { + n := 0 + for _, it := range p.items { + if it.action.kind != "header" { + n++ + } + } + return n +} + +// selectableIndex returns the 1-based index of the cursor among +// selectable (non-header) items, or 0 if cursor is on a header / empty. +func (p *paletteState) selectableIndex() int { + if p.cursor < 0 || p.cursor >= len(p.items) { + return 0 + } + n := 0 + for i := 0; i <= p.cursor; i++ { + if p.items[i].action.kind != "header" { + n++ + } + } + return n +} + +// focusedSubject returns the short context string shown in the title +// bar — "on: " / "pad: " / "" — so the user knows which +// focus the context-section is targeting. +func (p *paletteState) focusedSubject() string { + if p.focusedPad != "" { + return "pad: " + p.focusedPad + } + if p.focused != "" { + if c := findChildByID(p.children, p.focused); c != nil { + return "on: " + c.DisplayName() + } + } + return "" +} + // render draws the palette onto out. Layout is a rounded box with a -// title bar, query line, divider, item list, divider, and footer. -// The caller is responsible for the screen clear before the first -// render. +// 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) @@ -770,15 +1152,35 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) { row := 2 titleText := "patterm" + subject := p.focusedSubject() keyHint := "Ctrl-K" - // ╭─ patterm ─...─ Ctrl-K ─╮ uses: 3 + len(title) + 1 + dashes + 1 + len(hint) + 3 - dashes := width - 3 - len(titleText) - 1 - 1 - len(keyHint) - 3 + // 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) + " " + styleHint + keyHint + styleReset + styleBorder + " ─╮" + styleReset) + strings.Repeat("─", dashes) + " " + rightStr + styleBorder + " ─╮" + styleReset) row++ queryStr := string(p.query) @@ -793,17 +1195,145 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) { 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 - 6 + 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 @@ -812,13 +1342,22 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) { 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) + moveTo(b, *row, leftPad) if start+i >= end { if len(p.items) == 0 && i == 0 { - msg := styleDim + "no matches" + styleReset - pad := content - 2 - 10 + msg := styleDim + "no matches · ⌫ to widen" + styleReset + msgVisible := visibleLen(msg) + pad := content - 2 - msgVisible if pad < 0 { pad = 0 } @@ -828,16 +1367,23 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) { b.WriteString(styleBorder + "│" + styleReset + strings.Repeat(" ", width-2) + styleBorder + "│" + styleReset) } - row++ + *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) @@ -846,6 +1392,7 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) { labelLen = utf8.RuneCountInString(label) hint = "" hintLen = 0 + matches = trimMatches(matches, labelLen-1) } else if hintLen > 0 { gap := avail - labelLen - hintLen if gap < 3 { @@ -867,45 +1414,134 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) { var indicator, labelStr, hintStr string if isSel { indicator = styleAccent + "▎" + styleReset + " " - labelStr = styleBold + label + styleReset } else { indicator = " " - labelStr = label } + labelStr = styleLabel(label, matches, isSel) if hint != "" { hintStr = styleHint + hint + styleReset } b.WriteString(styleBorder + "│" + styleReset + " " + indicator + labelStr + strings.Repeat(" ", gap) + hintStr + " " + styleBorder + "│" + styleReset) - row++ + *row++ } +} - moveTo(&b, row, leftPad) - b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset) - row++ - - footer := "↵ run · esc close · ↑↓ navigate · sw/k/sp filter" - fLen := utf8.RuneCountInString(footer) - fPad := content - fLen - if fPad < 0 { - fPad = 0 +// 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) } - moveTo(&b, row, leftPad) - b.WriteString(styleBorder + "│" + styleReset + " " + styleHint + footer + styleReset + - strings.Repeat(" ", fPad) + " " + styleBorder + "│" + styleReset) - row++ + pad := content - visible + if pad < 0 { + pad = 0 + } + return styleBorder + "│" + styleReset + " " + styleHint + label + styleReset + + strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset +} - moveTo(&b, row, leftPad) - b.WriteString(styleBorder + "╰" + strings.Repeat("─", width-2) + "╯" + 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() +} - // Park the real terminal cursor at the end of the query so it - // blinks naturally in place of the old underscore stub. - moveTo(&b, queryRow, leftPad+4+qLen) - b.WriteString("\x1b[?25h") +// 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 +} - _, _ = out.Write([]byte(b.String())) - _ = out.Flush() +// renderHelpRows paints the help cheat-sheet in place of the item list +// when showHelp is active. +func (p *paletteState) renderHelpRows(b *strings.Builder, row *int, leftPad, width, content, maxItems int) { + lines := []struct{ key, desc string }{ + {"↵", "run selected"}, + {"esc", "close palette"}, + {"↑ ↓", "navigate (also ⌃p / ⌃n)"}, + {"tab / ⇧tab", "cycle filter chip"}, + {"home / end", "jump to first / last"}, + {"alt-1..9", "quick-pick that visible row"}, + {"⌃x", "close child (on a Switch row)"}, + {"⌃u", "clear query"}, + {"sw·sp·k ", "macro filters (same as chips)"}, + {"⌫", "delete · widen results"}, + {"?", "this help · any key to close"}, + } + for i := 0; i < maxItems; i++ { + moveTo(b, *row, leftPad) + if i >= len(lines) { + b.WriteString(styleBorder + "│" + styleReset + strings.Repeat(" ", width-2) + + styleBorder + "│" + styleReset) + *row++ + continue + } + ln := lines[i] + keyPart := styleAccent + ln.key + styleReset + descPart := styleHint + ln.desc + styleReset + visible := utf8.RuneCountInString(ln.key) + 3 + utf8.RuneCountInString(ln.desc) + pad := content - 2 - visible + if pad < 0 { + pad = 0 + } + b.WriteString(styleBorder + "│" + styleReset + " " + keyPart + " " + descPart + + strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset) + *row++ + } } // renderForm paints the "Spawn process…" two-field form. Layout @@ -999,14 +1635,14 @@ func (p *paletteState) renderForm(out writeFlusher, cols, rows int) { b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset) row++ - footer := "↵ spawn · esc cancel · tab cycle · space toggle" - fLen := utf8.RuneCountInString(footer) + 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 + " " + styleHint + footer + styleReset + + b.WriteString(styleBorder + "│" + styleReset + " " + footer + strings.Repeat(" ", fpad) + " " + styleBorder + "│" + styleReset) row++ @@ -1025,8 +1661,9 @@ func (p *paletteState) renderForm(out writeFlusher, cols, rows int) { _ = out.Flush() } -// renderRename paints the single-field rename form. Layout mirrors the -// spawn form so the user keeps the same mental model. +// 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{} @@ -1063,10 +1700,6 @@ func (p *paletteState) renderRename(out writeFlusher, cols, rows int) { } hint := "esc cancel" titleLen := utf8.RuneCountInString(title) - if titleLen > width-12 { - title = clipRunes(title, width-13) + "…" - titleLen = utf8.RuneCountInString(title) - } dashes := width - 3 - titleLen - 1 - 1 - len(hint) - 3 if dashes < 2 { dashes = 2 @@ -1076,6 +1709,24 @@ func (p *paletteState) renderRename(out writeFlusher, cols, rows int) { 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 diff --git a/internal/app/palette_context_test.go b/internal/app/palette_context_test.go index bb141ed..6528c97 100644 --- a/internal/app/palette_context_test.go +++ b/internal/app/palette_context_test.go @@ -31,8 +31,10 @@ func findItem(p *paletteState, want string) (int, *paletteItem) { func TestContextItemsScratchpad(t *testing.T) { p := newPalette(nil, "", "notes.md", preset.Set{}) - if i, _ := findItem(p, "pad-delete"); i != 0 { - t.Fatalf("pad-delete at %d; want top", i) + // pad-delete is the first selectable row; the Focused section header + // (a non-selectable row) sits above it. + if i, _ := findItem(p, "pad-delete"); i != 1 { + t.Fatalf("pad-delete at %d; want 1 (after Focused header)", i) } if _, it := findItem(p, "pad-rename-form"); it == nil || it.action.padName != "notes.md" { t.Fatalf("pad-rename-form missing or wrong padName: %+v", it) diff --git a/internal/app/palette_input_test.go b/internal/app/palette_input_test.go index 2f379d7..0c9072b 100644 --- a/internal/app/palette_input_test.go +++ b/internal/app/palette_input_test.go @@ -47,36 +47,50 @@ func TestPaletteBareEscCancels(t *testing.T) { } } +// firstSelectable returns the lowest item index whose action is +// selectable (not a section header), or -1 if the palette has no +// selectable rows. +func firstSelectable(p *paletteState) int { + for i, it := range p.items { + if it.action.kind != "header" { + return i + } + } + return -1 +} + func TestPaletteKittyArrowsNavigate(t *testing.T) { pr := []*preset.Preset{{Name: "a"}, {Name: "b"}, {Name: "c"}} p := newPalette(nil, "", "", preset.Set{Agents: pr}) - if p.cursor != 0 { - t.Fatalf("initial cursor %d", p.cursor) + first := firstSelectable(p) + if first < 0 || p.cursor != first { + t.Fatalf("initial cursor %d, want first selectable %d", p.cursor, first) } // Kitty functional Down arrow. _, _, adv := p.handleInput([]byte("\x1b[57353u"), 0) if adv != 8 { t.Fatalf("advance %d", adv) } - if p.cursor != 1 { - t.Fatalf("cursor %d after Down, want 1", p.cursor) + if p.cursor != first+1 { + t.Fatalf("cursor %d after Down, want %d", p.cursor, first+1) } // Kitty functional Up arrow. _, _, _ = p.handleInput([]byte("\x1b[57352u"), 0) - if p.cursor != 0 { - t.Fatalf("cursor %d after Up, want 0", p.cursor) + if p.cursor != first { + t.Fatalf("cursor %d after Up, want %d", p.cursor, first) } } func TestPaletteLegacyArrowsStillWork(t *testing.T) { pr := []*preset.Preset{{Name: "a"}, {Name: "b"}} p := newPalette(nil, "", "", preset.Set{Agents: pr}) + first := firstSelectable(p) _, _, adv := p.handleInput([]byte("\x1b[B"), 0) if adv != 3 { t.Fatalf("advance %d", adv) } - if p.cursor != 1 { - t.Fatalf("cursor %d, want 1", p.cursor) + if p.cursor != first+1 { + t.Fatalf("cursor %d, want %d", p.cursor, first+1) } } diff --git a/internal/app/palette_ux_test.go b/internal/app/palette_ux_test.go new file mode 100644 index 0000000..8c38c52 --- /dev/null +++ b/internal/app/palette_ux_test.go @@ -0,0 +1,359 @@ +package app + +import ( + "strings" + "testing" + + "github.com/hjbdev/patterm/internal/preset" +) + +// -- Phase 1: naming & dropped global Close list --------------------- + +func TestPaletteVerbsAreUnified(t *testing.T) { + procs := []*preset.Preset{{Name: "dev"}} + agents := []*preset.Preset{{Name: "claude"}} + p := newPalette(nil, "", "", preset.Set{Agents: agents, Processes: procs}) + gotLabels := make([]string, 0, len(p.items)) + for _, it := range p.items { + if it.action.kind == "header" { + continue + } + gotLabels = append(gotLabels, it.label) + } + joined := strings.Join(gotLabels, "\n") + + mustContain := []string{ + "Spawn agent: claude", + "Spawn process: dev", + "Spawn terminal", + "Spawn process… (custom)", + } + for _, want := range mustContain { + if !strings.Contains(joined, want) { + t.Errorf("missing unified-verb label %q in:\n%s", want, joined) + } + } + // The pre-overhaul verb forms must not appear anywhere. + mustNotContain := []string{"Run process:", "New Terminal", "Spawn process… (custom)"} + for _, bad := range mustNotContain { + if strings.Contains(joined, bad) { + t.Errorf("leftover legacy verb %q present in:\n%s", bad, joined) + } + } +} + +func TestPaletteDropsGlobalCloseList(t *testing.T) { + c1 := makeFakeChild("a", "claude", KindAgent) + c2 := makeFakeChild("b", "dev", KindCommand) + p := newPalette([]*Child{c1, c2}, "", "", preset.Set{}) + // No focus → no Focused context, so no "kill" / "agent-close" / + // "proc-stop" rows should exist at all. + for _, kind := range []string{"kill", "agent-close", "proc-stop", "proc-delete"} { + if i, _ := findItem(p, kind); i != -1 { + t.Fatalf("kind %q present at %d; global Close list should be gone", kind, i) + } + } +} + +// -- Phase 2: section headers and cursor skip ------------------------ + +func TestPaletteSectionHeadersPresent(t *testing.T) { + c := makeFakeChild("a", "claude", KindAgent) + p := newPalette([]*Child{c}, "a", "", preset.Set{Agents: []*preset.Preset{{Name: "codex"}}}) + wantSections := []string{"Focused", "Open", "Spawn", "Quit"} + for _, w := range wantSections { + found := false + for _, it := range p.items { + if it.action.kind == "header" && strings.Contains(it.label, w) { + found = true + break + } + } + if !found { + t.Errorf("section header %q missing from items", w) + } + } +} + +func TestPaletteCursorSkipsHeaders(t *testing.T) { + pr := []*preset.Preset{{Name: "a"}, {Name: "b"}} + p := newPalette(nil, "", "", preset.Set{Agents: pr}) + // Initial cursor must land on a selectable row, never a header. + if p.items[p.cursor].action.kind == "header" { + t.Fatalf("initial cursor sits on a header: %+v", p.items[p.cursor]) + } + // Walk to the end with cursorDown; every stop must be selectable. + for i := 0; i < len(p.items)*2; i++ { + p.cursorDown() + if p.items[p.cursor].action.kind == "header" { + t.Fatalf("cursorDown landed on a header at index %d", p.cursor) + } + } + // Walk back to top. + for i := 0; i < len(p.items)*2; i++ { + p.cursorUp() + if p.items[p.cursor].action.kind == "header" { + t.Fatalf("cursorUp landed on a header at index %d", p.cursor) + } + } +} + +func TestPaletteEnterOnHeaderIsNoOp(t *testing.T) { + pr := []*preset.Preset{{Name: "a"}} + p := newPalette(nil, "", "", preset.Set{Agents: pr}) + // Force the cursor onto a header. + for i, it := range p.items { + if it.action.kind == "header" { + p.cursor = i + break + } + } + _, done, _ := p.handleInput([]byte("\r"), 0) + if done { + t.Fatalf("Enter on header closed palette; expected no-op") + } +} + +// -- Phase 3: filter chips & macro coexistence ----------------------- + +func TestPaletteTabCyclesChip(t *testing.T) { + p := newTestPalette() + // All → Open + _, _, _ = p.handleInput([]byte{'\t'}, 0) + if string(p.query) != "sw " { + t.Fatalf("Tab #1: query %q, want %q", string(p.query), "sw ") + } + // Open → Spawn + _, _, _ = p.handleInput([]byte{'\t'}, 0) + if string(p.query) != "sp " { + t.Fatalf("Tab #2: query %q, want %q", string(p.query), "sp ") + } + // Spawn → Close + _, _, _ = p.handleInput([]byte{'\t'}, 0) + if string(p.query) != "k " { + t.Fatalf("Tab #3: query %q, want %q", string(p.query), "k ") + } + // Close → All (wraps) + _, _, _ = p.handleInput([]byte{'\t'}, 0) + if string(p.query) != "" { + t.Fatalf("Tab #4 wrap: query %q, want empty", string(p.query)) + } +} + +func TestPaletteShiftTabCyclesBackwards(t *testing.T) { + p := newTestPalette() + // Shift-Tab via legacy CSI Z: All → Close + _, _, _ = p.handleInput([]byte("\x1b[Z"), 0) + if string(p.query) != "k " { + t.Fatalf("Shift-Tab: query %q, want %q", string(p.query), "k ") + } +} + +func TestPaletteBackspaceThroughTrailingMacro(t *testing.T) { + p := newTestPalette() + p.query = []rune("sw ") + p.rebuild() + p.backspace() + if string(p.query) != "" { + t.Fatalf("backspace through 'sw ' left %q; want empty", string(p.query)) + } +} + +func TestPaletteMacroPreservesQueryCase(t *testing.T) { + // Tab cycling shouldn't downcase the user-typed search text. + p := newTestPalette() + p.query = []rune("Foo") + p.rebuild() + _, _, _ = p.handleInput([]byte{'\t'}, 0) + if string(p.query) != "sw Foo" { + t.Fatalf("query after Tab over 'Foo' = %q; want 'sw Foo'", string(p.query)) + } +} + +// -- Phase 4: scored matching ---------------------------------------- + +func TestFuzzyScorePrefixBeatsBoundaryBeatsSubstring(t *testing.T) { + prefix, _ := fuzzyScore("spawn agent: foo", "", "spa") + boundary, _ := fuzzyScore("hello spam", "", "spa") + substring, _ := fuzzyScore("escapade", "", "spa") + if !(prefix > boundary && boundary > substring) { + t.Fatalf("score ordering wrong: prefix=%d boundary=%d substring=%d", prefix, boundary, substring) + } +} + +func TestFuzzyScoreReturnsMatchPositions(t *testing.T) { + _, pos := fuzzyScore("spawn process: dev", "", "dev") + want := []int{15, 16, 17} + if len(pos) != len(want) { + t.Fatalf("positions = %v, want %v", pos, want) + } + for i, p := range pos { + if p != want[i] { + t.Fatalf("pos[%d] = %d, want %d (full %v)", i, p, want[i], pos) + } + } +} + +func TestPaletteScoredResultsDropHeaders(t *testing.T) { + pr := []*preset.Preset{{Name: "claude"}, {Name: "codex"}} + p := newPalette(nil, "", "", preset.Set{Agents: pr}) + // Type a needle that matches both. + p.query = []rune("c") + p.rebuild() + for _, it := range p.items { + if it.action.kind == "header" { + t.Fatalf("scored mode should not emit header rows; got %+v", it) + } + } +} + +func TestPaletteScoringFloatsPrefixMatchToTop(t *testing.T) { + // "x" is a prefix of "xtest" preset; it's a scattered-fuzzy match + // against many other rows. Scoring should land the prefix match at + // the top regardless of group order. + pr := []*preset.Preset{ + {Name: "alpha"}, + {Name: "xtest"}, + {Name: "beta"}, + } + p := newPalette(nil, "", "", preset.Set{Agents: pr}) + p.query = []rune("xt") + p.rebuild() + if len(p.items) == 0 { + t.Fatalf("no scored items for needle 'xt'") + } + if !strings.Contains(p.items[0].label, "xtest") { + t.Fatalf("expected xtest at top of scored list, got %q", p.items[0].label) + } +} + +// -- Phase 5: power-user accelerators -------------------------------- + +func TestPaletteCtrlXOnSwitchKills(t *testing.T) { + c := makeFakeChild("a", "claude", KindAgent) + p := newPalette([]*Child{c}, "", "", preset.Set{}) + // Cursor should already be on the switch row (it's the first + // selectable item with no Focused section). + idx, _ := findItem(p, "switch") + if idx < 0 { + t.Fatalf("no switch item in palette") + } + p.cursor = idx + action, done, _ := p.handleInput([]byte{0x18}, 0) + if !done { + t.Fatalf("Ctrl-X on switch row didn't close palette: action=%+v", action) + } + if action.kind != "kill" || action.childID != "a" { + t.Fatalf("Ctrl-X action = %+v, want kill of 'a'", action) + } +} + +func TestPaletteCtrlXOnNonSwitchIsNoOp(t *testing.T) { + p := newPalette(nil, "", "", preset.Set{}) + // Cursor parks on Quit or Spawn entries — neither is a switch row. + _, done, _ := p.handleInput([]byte{0x18}, 0) + if done { + t.Fatalf("Ctrl-X on non-switch closed palette") + } +} + +func TestPaletteHelpToggle(t *testing.T) { + p := newTestPalette() + // `?` with empty query opens help. + _, done, _ := p.handleInput([]byte("?"), 0) + if done { + t.Fatalf("? closed palette") + } + if !p.showHelp { + t.Fatalf("? didn't open help") + } + // Next keystroke dismisses. + _, _, _ = p.handleInput([]byte("a"), 0) + if p.showHelp { + t.Fatalf("help still showing after dismissing keystroke") + } +} + +func TestPaletteHelpDoesNotInterceptInQuery(t *testing.T) { + p := newTestPalette() + p.query = []rune("dev") + p.rebuild() + _, _, _ = p.handleInput([]byte("?"), 0) + if p.showHelp { + t.Fatalf("? with non-empty query incorrectly opened help") + } + if string(p.query) != "dev?" { + t.Fatalf("? with non-empty query failed to append: %q", string(p.query)) + } +} + +func TestPaletteHomeEndJumpsOverHeaders(t *testing.T) { + pr := []*preset.Preset{{Name: "a"}, {Name: "b"}} + p := newPalette(nil, "", "", preset.Set{Agents: pr}) + // End jumps to last selectable. + p.cursorEnd() + if p.items[p.cursor].action.kind == "header" { + t.Fatalf("End landed on header: %+v", p.items[p.cursor]) + } + if p.items[p.cursor].action.kind != "quit" { + t.Fatalf("End on simple palette should park on Quit; got %+v", p.items[p.cursor]) + } + // Home returns to first selectable. + p.cursorHome() + if p.items[p.cursor].action.kind == "header" { + t.Fatalf("Home landed on header: %+v", p.items[p.cursor]) + } +} + +func TestPaletteAltDigitQuickPick(t *testing.T) { + pr := []*preset.Preset{{Name: "first"}, {Name: "second"}} + p := newPalette(nil, "", "", preset.Set{Agents: pr}) + // Alt-1 picks the first selectable item (Spawn agent: first). + action, done, adv := p.handleInput([]byte("\x1b1"), 0) + if adv != 2 { + t.Fatalf("Alt-1 advance %d, want 2", adv) + } + if !done { + t.Fatalf("Alt-1 didn't close palette") + } + if action.kind != "spawn-agent" || action.preset == nil || action.preset.Name != "first" { + t.Fatalf("Alt-1 action = %+v, want spawn-agent first", action) + } +} + +func TestPaletteFormCtrlRTogglesRelaunchFromCommandField(t *testing.T) { + p := newPalette(nil, "", "", preset.Set{}) + p.mode = paletteModeSpawnForm + p.form = &spawnProcessForm{} + // Type without leaving the command field, then Ctrl-R. + for _, b := range []byte("xyz") { + _, _, _ = p.handleInput([]byte{b}, 0) + } + if p.form.field != 0 { + t.Fatalf("field jumped to %d", p.form.field) + } + _, _, _ = p.handleInput([]byte{0x12}, 0) + if !p.form.relaunch { + t.Fatalf("Ctrl-R didn't toggle relaunch from command field") + } + // Second press toggles back. + _, _, _ = p.handleInput([]byte{0x12}, 0) + if p.form.relaunch { + t.Fatalf("second Ctrl-R didn't toggle off") + } +} + +// -- Phase 6: counter / scroll indicator ----------------------------- + +func TestPaletteFooterCounter(t *testing.T) { + pr := []*preset.Preset{{Name: "a"}, {Name: "b"}, {Name: "c"}} + p := newPalette(nil, "", "", preset.Set{Agents: pr}) + total := p.visibleSelectableCount() + if total < 4 { // 3 spawn-agents + terminal + custom + quit + t.Fatalf("expected ≥4 selectables; got %d", total) + } + idx := p.selectableIndex() + if idx <= 0 { + t.Fatalf("selectable index = %d on freshly-built palette; want ≥1", idx) + } +} diff --git a/internal/harness/scenarios/rename_process_via_palette.json b/internal/harness/scenarios/rename_process_via_palette.json index f33e029..63a4902 100644 --- a/internal/harness/scenarios/rename_process_via_palette.json +++ b/internal/harness/scenarios/rename_process_via_palette.json @@ -16,7 +16,7 @@ { "type": "send_chord", "chord": "ctrl-k" }, { "type": "send_text", "text": "Rename process" }, { "type": "send_chord", "chord": "enter" }, - { "type": "wait_text", "contains": "Rename process", "timeout_ms": 3000 }, + { "type": "wait_text", "contains": "process: original", "timeout_ms": 3000 }, { "type": "send_chord", "chord": "ctrl-u" }, { "type": "send_text", "text": "renamed-pane" }, { "type": "send_chord", "chord": "enter" }, -- 2.49.1