- Palette's per-child "Kill <name>" action is now labelled "Close <name>" (action kind unchanged; still SIGTERM). Matches the existing "Close agent: …" context entry and reads less violent for a graceful term. - New "New Terminal" palette entry spawns a bare interactive $SHELL pane via LaunchTerminal (kind=terminal). Replaces the default "shell" process preset that was seeded on first run. - Exited KindTerminal entries are now dropped from the session in reapChild — terminals have no restart path, so leaving them behind as greyed rows in the Processes sidebar was just clutter. processList also filters defensively.
1181 lines
31 KiB
Go
1181 lines
31 KiB
Go
package app
|
||
|
||
import (
|
||
"fmt"
|
||
"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"
|
||
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
|
||
}
|
||
|
||
type paletteItem struct {
|
||
label string
|
||
hint string
|
||
action paletteAction
|
||
}
|
||
|
||
// 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
|
||
)
|
||
|
||
// 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 scratchpad: notes.md"
|
||
}
|
||
|
||
// 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
|
||
|
||
items []paletteItem
|
||
|
||
mode paletteMode
|
||
form *spawnProcessForm
|
||
renameForm *renameForm
|
||
}
|
||
|
||
// 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 kills, `sp <query>` to spawn entries (agents +
|
||
// processes).
|
||
var macroPrefixes = map[string][]string{
|
||
"sw": {"switch"},
|
||
"k": {"kill"},
|
||
"sp": {"spawn-agent", "spawn-process"},
|
||
}
|
||
|
||
// 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.
|
||
func detectMacro(q string) (macro, rest string) {
|
||
for k := range macroPrefixes {
|
||
if len(q) > len(k) && q[:len(k)] == k && q[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) *paletteState {
|
||
p := &paletteState{children: children, focused: focused, focusedPad: focusedPad, presets: presets}
|
||
p.rebuild()
|
||
return p
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
}
|
||
all = filtered
|
||
q = rest
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
if p.cursor >= len(p.items) {
|
||
p.cursor = len(p.items) - 1
|
||
}
|
||
if p.cursor < 0 {
|
||
p.cursor = 0
|
||
}
|
||
}
|
||
|
||
func (p *paletteState) allItems() []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).
|
||
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},
|
||
})
|
||
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},
|
||
})
|
||
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},
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
for _, c := range p.children {
|
||
if c.Kind == KindAgent && c.Status() != StatusRunning {
|
||
continue
|
||
}
|
||
label := "Switch to " + c.DisplayName()
|
||
hint := strings.Join(c.Argv, " ")
|
||
if c.ID == p.focused {
|
||
label = "• " + label + " (current)"
|
||
}
|
||
if c.Status() != StatusRunning {
|
||
label = label + " [" + string(c.Status()) + "]"
|
||
}
|
||
out = append(out, paletteItem{
|
||
label: label,
|
||
hint: hint,
|
||
action: paletteAction{kind: "switch", childID: c.ID},
|
||
})
|
||
}
|
||
|
||
// Preset commands — SPEC §4 calls these out as the primary way to
|
||
// spawn anything. One entry per file under presets/.
|
||
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},
|
||
})
|
||
}
|
||
for _, pr := range p.presets.Processes {
|
||
out = append(out, paletteItem{
|
||
label: "Run process: " + pr.Name,
|
||
hint: strings.Join(pr.Argv, " "),
|
||
action: paletteAction{kind: "spawn-process", preset: pr},
|
||
})
|
||
}
|
||
|
||
// "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",
|
||
hint: "bare interactive $SHELL · removed on exit",
|
||
action: paletteAction{kind: "spawn-terminal"},
|
||
})
|
||
|
||
// 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",
|
||
action: paletteAction{kind: "spawn-process-form"},
|
||
})
|
||
|
||
// 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},
|
||
})
|
||
}
|
||
|
||
out = append(out, paletteItem{
|
||
label: "Quit",
|
||
hint: "exit patterm; SIGTERM every child",
|
||
action: paletteAction{kind: "quit"},
|
||
})
|
||
return out
|
||
}
|
||
|
||
func fuzzyMatch(hay, needle string) bool {
|
||
if needle == "" {
|
||
return true
|
||
}
|
||
hi := 0
|
||
for _, r := range needle {
|
||
idx := strings.IndexRune(hay[hi:], r)
|
||
if idx < 0 {
|
||
return false
|
||
}
|
||
hi += idx + utf8.RuneLen(r)
|
||
}
|
||
return true
|
||
}
|
||
|
||
// kitty functional keycodes for arrows.
|
||
const (
|
||
kittyKeyUp = 57352
|
||
kittyKeyDown = 57353
|
||
)
|
||
|
||
// 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 (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.
|
||
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)
|
||
}
|
||
b := chunk[i]
|
||
if b == 0x1b {
|
||
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 0x15: // Ctrl-U
|
||
p.clearQuery()
|
||
case 0x0e: // Ctrl-N
|
||
p.cursorDown()
|
||
case 0x10: // Ctrl-P
|
||
p.cursorUp()
|
||
case 0x0b: // Ctrl-K inside palette is a no-op (would re-open); ignore.
|
||
case 0x16: // Ctrl-V literal-paste — ignore in palette.
|
||
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 "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)
|
||
return paletteAction{}, false, adv
|
||
case "agent-rename-form", "proc-rename-form":
|
||
subject := "agent"
|
||
title := "Rename agent: "
|
||
if a.kind == "proc-rename-form" {
|
||
subject = "proc"
|
||
title = "Rename process: "
|
||
}
|
||
current := ""
|
||
if c := findChildByID(p.children, a.childID); c != nil {
|
||
current = c.DisplayName()
|
||
}
|
||
p.enterRenameForm(subject, a.childID, current, title+current)
|
||
return paletteAction{}, false, adv
|
||
}
|
||
return a, true, adv
|
||
}
|
||
|
||
func (p *paletteState) enterRenameForm(subject, target, current, title string) {
|
||
p.mode = paletteModeRenameForm
|
||
p.renameForm = &renameForm{
|
||
name: []rune(current),
|
||
subject: subject,
|
||
target: target,
|
||
title: title,
|
||
}
|
||
}
|
||
|
||
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 '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 127, 8: // Backspace
|
||
p.backspace()
|
||
case kittyKeyUp:
|
||
p.cursorUp()
|
||
case kittyKeyDown:
|
||
p.cursorDown()
|
||
default:
|
||
// Ctrl-modified character keys.
|
||
if k.mods == 5 {
|
||
switch k.key {
|
||
case 'u':
|
||
p.clearQuery()
|
||
case 'n':
|
||
p.cursorDown()
|
||
case 'p':
|
||
p.cursorUp()
|
||
}
|
||
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 {
|
||
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. The form supports both legacy and kitty key encodings to
|
||
// match handleInput; bare ESC cancels the entire palette (consistent
|
||
// with the picker).
|
||
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 ' ':
|
||
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))
|
||
}
|
||
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 == 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 p.items[p.cursor].action
|
||
}
|
||
return paletteAction{kind: "cancel"}
|
||
}
|
||
|
||
func (p *paletteState) backspace() {
|
||
if len(p.query) > 0 {
|
||
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() {
|
||
p.cursor--
|
||
if p.cursor < 0 {
|
||
p.cursor = 0
|
||
}
|
||
}
|
||
|
||
func (p *paletteState) cursorDown() {
|
||
p.cursor++
|
||
if p.cursor >= len(p.items) {
|
||
p.cursor = len(p.items) - 1
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
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 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"
|
||
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
|
||
if dashes < 2 {
|
||
dashes = 2
|
||
}
|
||
moveTo(&b, row, leftPad)
|
||
b.WriteString(styleBorder + "╭─ " + styleActive + titleText + styleReset + styleBorder + " " +
|
||
strings.Repeat("─", dashes) + " " + styleHint + keyHint + styleReset + 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++
|
||
|
||
moveTo(&b, row, leftPad)
|
||
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
|
||
row++
|
||
|
||
maxItems := rows - 6
|
||
if maxItems > 12 {
|
||
maxItems = 12
|
||
}
|
||
if maxItems < 1 {
|
||
maxItems = 1
|
||
}
|
||
start := 0
|
||
if p.cursor >= maxItems {
|
||
start = p.cursor - maxItems + 1
|
||
}
|
||
end := start + maxItems
|
||
if end > len(p.items) {
|
||
end = len(p.items)
|
||
}
|
||
|
||
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" + styleReset
|
||
pad := content - 2 - 10
|
||
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]
|
||
isSel := (start + i) == p.cursor
|
||
avail := content - 2 // 2 cells reserved for the selection indicator
|
||
|
||
label := it.label
|
||
hint := it.hint
|
||
labelLen := utf8.RuneCountInString(label)
|
||
hintLen := utf8.RuneCountInString(hint)
|
||
|
||
if labelLen > avail {
|
||
label = clipRunes(label, avail-1) + "…"
|
||
labelLen = utf8.RuneCountInString(label)
|
||
hint = ""
|
||
hintLen = 0
|
||
} 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 + " "
|
||
labelStr = styleBold + label + styleReset
|
||
} else {
|
||
indicator = " "
|
||
labelStr = label
|
||
}
|
||
if hint != "" {
|
||
hintStr = styleHint + hint + styleReset
|
||
}
|
||
|
||
b.WriteString(styleBorder + "│" + styleReset + " " + indicator + labelStr +
|
||
strings.Repeat(" ", gap) + hintStr + " " + styleBorder + "│" + styleReset)
|
||
row++
|
||
}
|
||
|
||
moveTo(&b, row, leftPad)
|
||
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
|
||
row++
|
||
|
||
footer := "↵ run · esc close · ↑↓ navigate · sw/k/sp <q> filter"
|
||
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)
|
||
|
||
// 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")
|
||
|
||
_, _ = out.Write([]byte(b.String()))
|
||
_ = out.Flush()
|
||
}
|
||
|
||
// 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 := "↵ spawn · esc cancel · tab cycle · space toggle"
|
||
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)
|
||
|
||
// 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. Layout mirrors the
|
||
// spawn form so the user keeps the same mental model.
|
||
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)
|
||
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
|
||
}
|
||
moveTo(&b, row, leftPad)
|
||
b.WriteString(styleBorder + "╭─ " + styleActive + title + styleReset + styleBorder + " " +
|
||
strings.Repeat("─", dashes) + " " + styleHint + hint + styleReset + 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)
|
||
}
|