Compare commits
3 Commits
worktree-t
...
v0.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
| ef9b8e71c6 | |||
| e64060e40f | |||
| e4ab8c2136 |
14
CHANGELOG.md
14
CHANGELOG.md
@@ -6,6 +6,8 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.0.5] - 2026-05-15
|
||||
|
||||
### Changed
|
||||
- Replaced the single-slot status-line "flash" with a stackable toast
|
||||
surface anchored at the top-right of the focused pane. `flashError`,
|
||||
@@ -17,6 +19,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