Add context-aware items to the command palette

When opened with Ctrl-K, the palette now prepends entries for whatever
is currently focused:

- Focused scratchpad: Delete / Rename (inline form) / Edit (fire-and-
  forget zed launch with stdio detached so the TUI is not suspended).
- Focused agent: Rename (inline form) / Close.
- Focused process: Rename / Delete (drops the entry; SIGKILL if alive)
  / Stop (SIGTERM, keep entry) / Restart (same argv).

The rename UX is a single-field inline form that mirrors the existing
spawn-process form, so the modal-input contract is unchanged.
scratchpad.Store grows Delete / Rename / Path so the palette can act
on a pad file by name. focusedPad is plumbed onto uiState ahead of the
scratchpad-focus UI work; until that lands it stays empty and the
scratchpad-context entries simply never surface.

Tested with palette_context_test.go and a new rename_process_via_palette
harness scenario.
This commit is contained in:
2026-05-15 00:34:38 +01:00
parent 81a8ac2ba0
commit 05f92a3ed0
7 changed files with 827 additions and 23 deletions

View File

@@ -11,7 +11,12 @@ import (
// 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" | "switch" | "kill" | "quit" | "cancel"
// "spawn-process-submit" | "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.
@@ -24,6 +29,12 @@ type paletteAction struct {
// 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 {
@@ -41,6 +52,7 @@ type paletteMode int
const (
paletteModePicker paletteMode = iota
paletteModeSpawnForm
paletteModeRenameForm
)
// spawnProcessForm is the state for the "Spawn process…" two-field
@@ -52,19 +64,33 @@ type spawnProcessForm struct {
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
presets preset.Set
query []rune
cursor int
children []*Child
focused string
focusedPad string
presets preset.Set
items []paletteItem
mode paletteMode
form *spawnProcessForm
mode paletteMode
form *spawnProcessForm
renameForm *renameForm
}
// macroPrefixes maps the palette macro prefix (without trailing space)
@@ -90,8 +116,20 @@ func detectMacro(q string) (macro, rest string) {
return "", q
}
func newPalette(children []*Child, focused string, presets preset.Set) *paletteState {
p := &paletteState{children: children, focused: focused, presets: presets}
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
}
@@ -135,6 +173,68 @@ func (p *paletteState) rebuild() {
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
@@ -283,6 +383,9 @@ func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, d
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 {
@@ -314,19 +417,46 @@ func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, d
}
// acceptOrEnterForm wraps accept(): if the chosen item opens the
// spawn-process form, transition into form mode instead of returning
// done=true. The advance count is what the caller already consumed for
// the Enter keystroke.
// 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()
if a.kind == "spawn-process-form" {
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':
@@ -445,6 +575,92 @@ func (p *paletteState) handleFormCSI(params []byte, final byte, n int) (paletteA
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 {
@@ -512,6 +728,10 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) {
p.renderForm(out, cols, rows)
return
}
if p.mode == paletteModeRenameForm {
p.renderRename(out, cols, rows)
return
}
if cols < 32 {
cols = 32
}
@@ -794,6 +1014,96 @@ 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.
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 ""