Calm down the focused-section labels in the command palette

Focused-section rows are now bare verbs (Rename, Close, Stop, Restart,
Delete, Edit) instead of repeating the focused name. The title bar
already carries the subject, and the row hint preserves fuzzy-search
matches like "close codex". Section banners are replaced by a single
blank spacer row so the verbs themselves carry the visual weight,
and the Open section no longer lists "Switch to <current>" for the
pane that's already focused.
This commit is contained in:
2026-05-15 20:29:31 +01:00
parent e4ab8c2136
commit e64060e40f
5 changed files with 97 additions and 66 deletions

View File

@@ -17,6 +17,18 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `Ctrl-N` is consumed by the host only when there is a toast to - `Ctrl-N` is consumed by the host only when there is a toast to
dismiss; an empty stack lets `Ctrl-N` pass through to the focused dismiss; an empty stack lets `Ctrl-N` pass through to the focused
child so readline / nano / emacs / opencode keep their bindings. child so readline / nano / emacs / opencode keep their bindings.
- Command palette is calmer when something is focused. Focused-section
rows now read as bare verbs (`Rename`, `Close`, `Stop`, `Restart`,
`Delete`, `Edit`) instead of repeating the focused name (`Close
agent: codex`); the title bar's `on: codex` / `pad: notes.md`
carries the subject. Fuzzy queries still match the dropped context
through the row hint (e.g. typing `close codex` still finds the
Close row).
- Dashed `── Focused ──` / `── Open ──` / `── Spawn ──` section
banners are gone. Sections are separated by a single blank spacer
row, so the action labels themselves carry the visual weight.
- The Open section no longer lists a `Switch to <current>` row for
the pane you're already focused on.
## [0.0.4] - 2026-05-15 ## [0.0.4] - 2026-05-15

View File

@@ -1,3 +0,0 @@
The close action in the command palette should just be "Close current agent" rather than "Close codex"
Same with the other "focused" parts. It seems a bit clunky right now. "Close current agent"
In general I think while the feature set has grown, the actual refinement of it isn't great, it feels a bit cluttered.

View File

@@ -54,14 +54,6 @@ const (
groupQuit groupQuit
) )
var groupLabels = map[int]string{
groupFocused: "Focused",
groupOpen: "Open",
groupSpawn: "Spawn",
groupSettings: "Settings",
groupQuit: "Quit",
}
type paletteItem struct { type paletteItem struct {
label string label string
hint string hint string
@@ -205,8 +197,10 @@ func (p *paletteState) rebuild() {
all := p.buildItems(macro) all := p.buildItems(macro)
if rest == "" { if rest == "" {
// No textual filter: render with section headers between groups. // No textual filter: render with blank spacer rows between
p.items = itemsWithHeaders(all) // groups so sections read as scannable bands without dashed
// headers stealing visual weight.
p.items = itemsWithSpacers(all)
p.clampCursor() p.clampCursor()
return return
} }
@@ -243,25 +237,28 @@ func (p *paletteState) rebuild() {
} }
// buildItems assembles every selectable row in fixed group order // buildItems assembles every selectable row in fixed group order
// (Focused → Open → Spawn → Quit). Headers are added by // (Focused → Open → Spawn → Quit). Blank spacer rows are added by
// itemsWithHeaders for the no-query case; scored mode drops them. // itemsWithSpacers for the no-query case; scored mode drops them.
// When macro is non-empty the result is filtered down to the kinds // When macro is non-empty the result is filtered down to the kinds
// that macro retains. // that macro retains.
func (p *paletteState) buildItems(macro string) []paletteItem { func (p *paletteState) buildItems(macro string) []paletteItem {
var out []paletteItem var out []paletteItem
// Group 0: Focused — context-aware actions for whatever owns focus. // Group 0: Focused — context-aware actions for whatever owns focus.
// A focused scratchpad shadows any focused child. // A focused scratchpad shadows any focused child. Labels are bare
// verbs because the title bar already carries the subject ("on:
// codex" / "pad: notes.md"); the noun + name move into the hint so
// fuzzy queries like "close codex" still surface the row.
switch { switch {
case p.focusedPad != "": case p.focusedPad != "":
name := p.focusedPad name := p.focusedPad
out = append(out, out = append(out,
paletteItem{label: "Delete scratchpad: " + name, hint: "remove the file from disk", paletteItem{label: "Edit", hint: "edit scratchpad · " + name + " (opens $EDITOR)",
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}, action: paletteAction{kind: "pad-edit", padName: name}, group: groupFocused},
paletteItem{label: "Rename", hint: "rename scratchpad · " + name,
action: paletteAction{kind: "pad-rename-form", padName: name}, group: groupFocused},
paletteItem{label: "Delete", hint: "delete scratchpad · " + name,
action: paletteAction{kind: "pad-delete", padName: name}, group: groupFocused},
) )
case p.focused != "": case p.focused != "":
if c := findChildByID(p.children, p.focused); c != nil { if c := findChildByID(p.children, p.focused); c != nil {
@@ -269,40 +266,39 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
switch c.Kind { switch c.Kind {
case KindAgent: case KindAgent:
out = append(out, out = append(out,
paletteItem{label: "Rename agent: " + name, hint: "inline rename · enter to commit", paletteItem{label: "Rename", hint: "rename agent · " + name,
action: paletteAction{kind: "agent-rename-form", childID: c.ID}, group: groupFocused}, action: paletteAction{kind: "agent-rename-form", childID: c.ID}, group: groupFocused},
paletteItem{label: "Close agent: " + name, hint: "SIGTERM " + strings.Join(c.Argv, " "), paletteItem{label: "Close", hint: "close agent · " + name + " (SIGTERM)",
action: paletteAction{kind: "agent-close", childID: c.ID}, group: groupFocused}, action: paletteAction{kind: "agent-close", childID: c.ID}, group: groupFocused},
) )
default: default:
out = append(out, out = append(out,
paletteItem{label: "Rename process: " + name, hint: "inline rename · enter to commit", paletteItem{label: "Rename", hint: "rename process · " + name,
action: paletteAction{kind: "proc-rename-form", childID: c.ID}, group: groupFocused}, action: paletteAction{kind: "proc-rename-form", childID: c.ID}, group: groupFocused},
paletteItem{label: "Delete process: " + name, hint: "remove entry; SIGKILL if alive", paletteItem{label: "Stop", hint: "stop process · " + name + " (SIGTERM, keeps entry)",
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}, action: paletteAction{kind: "proc-stop", childID: c.ID}, group: groupFocused},
paletteItem{label: "Restart process: " + name, hint: "SIGTERM then start with same argv", paletteItem{label: "Restart", hint: "restart process · " + name,
action: paletteAction{kind: "proc-restart", childID: c.ID}, group: groupFocused}, action: paletteAction{kind: "proc-restart", childID: c.ID}, group: groupFocused},
paletteItem{label: "Delete", hint: "delete process · " + name + " (SIGKILL if alive)",
action: paletteAction{kind: "proc-delete", childID: c.ID}, group: groupFocused},
) )
} }
} }
} }
// Group 1: Open — switch entries for every running child. Dead // Group 1: Open — switch entries for every running child *other than*
// the one already focused (no point offering a no-op switch). Dead
// agents are filtered out (no restart path); dead command processes // agents are filtered out (no restart path); dead command processes
// remain so they can be restarted. The currently-focused child is // remain so they can be restarted.
// marked with a leading ▶ instead of the older "• … (current)" suffix
// so the row reads cleaner.
for _, c := range p.children { for _, c := range p.children {
if c.ID == p.focused {
continue
}
if c.Kind == KindAgent && c.Status() != StatusRunning { if c.Kind == KindAgent && c.Status() != StatusRunning {
continue continue
} }
label := "Switch to " + c.DisplayName() label := "Switch to " + c.DisplayName()
hint := strings.Join(c.Argv, " ") hint := strings.Join(c.Argv, " ")
if c.ID == p.focused {
label = "▶ " + label
}
if c.Status() != StatusRunning { if c.Status() != StatusRunning {
label = label + " [" + string(c.Status()) + "]" label = label + " [" + string(c.Status()) + "]"
} }
@@ -384,9 +380,11 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
return out return out
} }
// itemsWithHeaders splices a non-selectable header row in front of // itemsWithSpacers splices a non-selectable blank row between groups
// each new group so the (unfiltered) list reads as scannable bands. // so the (unfiltered) list reads as scannable bands without dashed
func itemsWithHeaders(items []paletteItem) []paletteItem { // section headers stealing weight from the actions themselves. The
// first group never gets a leading spacer.
func itemsWithSpacers(items []paletteItem) []paletteItem {
if len(items) == 0 { if len(items) == 0 {
return nil return nil
} }
@@ -394,17 +392,14 @@ func itemsWithHeaders(items []paletteItem) []paletteItem {
currentGroup := -1 currentGroup := -1
for _, it := range items { for _, it := range items {
if it.group != currentGroup { if it.group != currentGroup {
currentGroup = it.group if currentGroup != -1 {
label, ok := groupLabels[it.group]
if !ok {
label = ""
}
result = append(result, paletteItem{ result = append(result, paletteItem{
label: "── " + label + " ──",
action: paletteAction{kind: "header"}, action: paletteAction{kind: "header"},
group: it.group, group: it.group,
}) })
} }
currentGroup = it.group
}
result = append(result, it) result = append(result, it)
} }
return result return result

View File

@@ -31,16 +31,17 @@ func findItem(p *paletteState, want string) (int, *paletteItem) {
func TestContextItemsScratchpad(t *testing.T) { func TestContextItemsScratchpad(t *testing.T) {
p := newPalette(nil, "", "notes.md", preset.Set{}) p := newPalette(nil, "", "notes.md", preset.Set{})
// pad-delete is the first selectable row; the Focused section header // With the dashed section header gone, pad-edit is the first row;
// (a non-selectable row) sits above it. // pad-rename-form follows, with destructive pad-delete last in the
if i, _ := findItem(p, "pad-delete"); i != 1 { // Focused section.
t.Fatalf("pad-delete at %d; want 1 (after Focused header)", i) if i, _ := findItem(p, "pad-edit"); i != 0 {
t.Fatalf("pad-edit at %d; want 0", i)
} }
if _, it := findItem(p, "pad-rename-form"); it == nil || it.action.padName != "notes.md" { 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) t.Fatalf("pad-rename-form missing or wrong padName: %+v", it)
} }
if _, it := findItem(p, "pad-edit"); it == nil { if i, _ := findItem(p, "pad-delete"); i < 0 {
t.Fatalf("pad-edit missing") t.Fatalf("pad-delete missing")
} }
// No focused child → no agent/proc context items. // No focused child → no agent/proc context items.
if i, _ := findItem(p, "agent-rename-form"); i != -1 { if i, _ := findItem(p, "agent-rename-form"); i != -1 {
@@ -83,8 +84,11 @@ func TestContextItemsProcess(t *testing.T) {
} }
func TestContextItemsAppearAboveSwitch(t *testing.T) { func TestContextItemsAppearAboveSwitch(t *testing.T) {
c := makeFakeChild("pid", "devserver", KindCommand) // Two children so there's still a non-focused switch entry to compare
p := newPalette([]*Child{c}, "pid", "", preset.Set{}) // against (the focused child is suppressed from the Open section).
focused := makeFakeChild("pid", "devserver", KindCommand)
other := makeFakeChild("oid", "worker", KindCommand)
p := newPalette([]*Child{focused, other}, "pid", "", preset.Set{})
procIdx, _ := findItem(p, "proc-rename-form") procIdx, _ := findItem(p, "proc-rename-form")
switchIdx, _ := findItem(p, "switch") switchIdx, _ := findItem(p, "switch")
if procIdx < 0 || switchIdx < 0 { if procIdx < 0 || switchIdx < 0 {

View File

@@ -57,21 +57,44 @@ func TestPaletteDropsGlobalCloseList(t *testing.T) {
// -- Phase 2: section headers and cursor skip ------------------------ // -- Phase 2: section headers and cursor skip ------------------------
func TestPaletteSectionHeadersPresent(t *testing.T) { func TestPaletteSectionsSeparatedBySpacers(t *testing.T) {
// Section-named dashed headers are gone; groups are visually
// separated by a single non-selectable blank row. Verify that the
// build emits one such spacer between every pair of adjacent groups
// and never a leading spacer.
c := makeFakeChild("a", "claude", KindAgent) c := makeFakeChild("a", "claude", KindAgent)
p := newPalette([]*Child{c}, "a", "", preset.Set{Agents: []*preset.Preset{{Name: "codex"}}}) other := makeFakeChild("b", "worker", KindCommand)
wantSections := []string{"Focused", "Open", "Spawn", "Quit"} p := newPalette([]*Child{c, other}, "a", "",
for _, w := range wantSections { preset.Set{Agents: []*preset.Preset{{Name: "codex"}}})
found := false
for _, it := range p.items { if len(p.items) == 0 {
if it.action.kind == "header" && strings.Contains(it.label, w) { t.Fatalf("palette built no items")
found = true }
break if p.items[0].action.kind == "header" {
t.Fatalf("first row is a spacer; should be a selectable item")
}
transitions := 0
prevGroup := p.items[0].group
for i := 1; i < len(p.items); i++ {
it := p.items[i]
if it.group != prevGroup {
if it.action.kind != "header" || it.label != "" {
t.Fatalf("group transition at %d not a blank spacer: %+v", i, it)
}
transitions++
// The row immediately after the spacer must be selectable.
if i+1 >= len(p.items) || p.items[i+1].action.kind == "header" {
t.Fatalf("spacer at %d not followed by selectable row", i)
}
prevGroup = p.items[i+1].group
}
// No dashed banners anywhere.
if it.action.kind == "header" && strings.Contains(it.label, "──") {
t.Errorf("dashed section header still present at %d: %q", i, it.label)
} }
} }
if !found { if transitions == 0 {
t.Errorf("section header %q missing from items", w) t.Fatalf("no section transitions found in palette items")
}
} }
} }