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:
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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
|
||||
dismiss; an empty stack lets `Ctrl-N` pass through to the focused
|
||||
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
|
||||
|
||||
|
||||
3
TODO.md
3
TODO.md
@@ -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.
|
||||
|
||||
@@ -54,14 +54,6 @@ const (
|
||||
groupQuit
|
||||
)
|
||||
|
||||
var groupLabels = map[int]string{
|
||||
groupFocused: "Focused",
|
||||
groupOpen: "Open",
|
||||
groupSpawn: "Spawn",
|
||||
groupSettings: "Settings",
|
||||
groupQuit: "Quit",
|
||||
}
|
||||
|
||||
type paletteItem struct {
|
||||
label string
|
||||
hint string
|
||||
@@ -205,8 +197,10 @@ func (p *paletteState) rebuild() {
|
||||
all := p.buildItems(macro)
|
||||
|
||||
if rest == "" {
|
||||
// No textual filter: render with section headers between groups.
|
||||
p.items = itemsWithHeaders(all)
|
||||
// No textual filter: render with blank spacer rows between
|
||||
// groups so sections read as scannable bands without dashed
|
||||
// headers stealing visual weight.
|
||||
p.items = itemsWithSpacers(all)
|
||||
p.clampCursor()
|
||||
return
|
||||
}
|
||||
@@ -243,25 +237,28 @@ func (p *paletteState) rebuild() {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// (Focused → Open → Spawn → Quit). Blank spacer rows are added by
|
||||
// itemsWithSpacers 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
|
||||
|
||||
// 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 {
|
||||
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}, 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)",
|
||||
paletteItem{label: "Edit", hint: "edit scratchpad · " + name + " (opens $EDITOR)",
|
||||
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 != "":
|
||||
if c := findChildByID(p.children, p.focused); c != nil {
|
||||
@@ -269,40 +266,39 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
|
||||
switch c.Kind {
|
||||
case KindAgent:
|
||||
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},
|
||||
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},
|
||||
)
|
||||
default:
|
||||
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},
|
||||
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",
|
||||
paletteItem{label: "Stop", hint: "stop process · " + name + " (SIGTERM, keeps entry)",
|
||||
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},
|
||||
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
|
||||
// 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.
|
||||
// remain so they can be restarted.
|
||||
for _, c := range p.children {
|
||||
if c.ID == p.focused {
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
if c.Status() != StatusRunning {
|
||||
label = label + " [" + string(c.Status()) + "]"
|
||||
}
|
||||
@@ -384,9 +380,11 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
|
||||
return out
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// itemsWithSpacers splices a non-selectable blank row between groups
|
||||
// so the (unfiltered) list reads as scannable bands without dashed
|
||||
// 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 {
|
||||
return nil
|
||||
}
|
||||
@@ -394,16 +392,13 @@ func itemsWithHeaders(items []paletteItem) []paletteItem {
|
||||
currentGroup := -1
|
||||
for _, it := range items {
|
||||
if it.group != currentGroup {
|
||||
currentGroup = it.group
|
||||
label, ok := groupLabels[it.group]
|
||||
if !ok {
|
||||
label = ""
|
||||
if currentGroup != -1 {
|
||||
result = append(result, paletteItem{
|
||||
action: paletteAction{kind: "header"},
|
||||
group: it.group,
|
||||
})
|
||||
}
|
||||
result = append(result, paletteItem{
|
||||
label: "── " + label + " ──",
|
||||
action: paletteAction{kind: "header"},
|
||||
group: it.group,
|
||||
})
|
||||
currentGroup = it.group
|
||||
}
|
||||
result = append(result, it)
|
||||
}
|
||||
|
||||
@@ -31,16 +31,17 @@ func findItem(p *paletteState, want string) (int, *paletteItem) {
|
||||
|
||||
func TestContextItemsScratchpad(t *testing.T) {
|
||||
p := newPalette(nil, "", "notes.md", preset.Set{})
|
||||
// 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)
|
||||
// With the dashed section header gone, pad-edit is the first row;
|
||||
// pad-rename-form follows, with destructive pad-delete last in the
|
||||
// Focused section.
|
||||
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" {
|
||||
t.Fatalf("pad-rename-form missing or wrong padName: %+v", it)
|
||||
}
|
||||
if _, it := findItem(p, "pad-edit"); it == nil {
|
||||
t.Fatalf("pad-edit missing")
|
||||
if i, _ := findItem(p, "pad-delete"); i < 0 {
|
||||
t.Fatalf("pad-delete missing")
|
||||
}
|
||||
// No focused child → no agent/proc context items.
|
||||
if i, _ := findItem(p, "agent-rename-form"); i != -1 {
|
||||
@@ -83,8 +84,11 @@ func TestContextItemsProcess(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContextItemsAppearAboveSwitch(t *testing.T) {
|
||||
c := makeFakeChild("pid", "devserver", KindCommand)
|
||||
p := newPalette([]*Child{c}, "pid", "", preset.Set{})
|
||||
// Two children so there's still a non-focused switch entry to compare
|
||||
// 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")
|
||||
switchIdx, _ := findItem(p, "switch")
|
||||
if procIdx < 0 || switchIdx < 0 {
|
||||
|
||||
@@ -57,22 +57,45 @@ func TestPaletteDropsGlobalCloseList(t *testing.T) {
|
||||
|
||||
// -- 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)
|
||||
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
|
||||
other := makeFakeChild("b", "worker", KindCommand)
|
||||
p := newPalette([]*Child{c, other}, "a", "",
|
||||
preset.Set{Agents: []*preset.Preset{{Name: "codex"}}})
|
||||
|
||||
if len(p.items) == 0 {
|
||||
t.Fatalf("palette built no items")
|
||||
}
|
||||
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
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("section header %q missing from items", w)
|
||||
// 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 transitions == 0 {
|
||||
t.Fatalf("no section transitions found in palette items")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaletteCursorSkipsHeaders(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user