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:
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user