diff --git a/CHANGELOG.md b/CHANGELOG.md index ac85cfe..195cbbe 100644 --- a/CHANGELOG.md +++ b/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 ` row for + the pane you're already focused on. ## [0.0.4] - 2026-05-15 diff --git a/TODO.md b/TODO.md index 343b903..e69de29 100644 --- a/TODO.md +++ b/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. diff --git a/internal/app/palette.go b/internal/app/palette.go index 6988d9c..5eef1ae 100644 --- a/internal/app/palette.go +++ b/internal/app/palette.go @@ -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) } diff --git a/internal/app/palette_context_test.go b/internal/app/palette_context_test.go index 6528c97..85ca443 100644 --- a/internal/app/palette_context_test.go +++ b/internal/app/palette_context_test.go @@ -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 { diff --git a/internal/app/palette_ux_test.go b/internal/app/palette_ux_test.go index 7247a8b..fe1719a 100644 --- a/internal/app/palette_ux_test.go +++ b/internal/app/palette_ux_test.go @@ -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) {